diff --git a/.eslintignore.errorfiles b/.eslintignore.errorfiles index 1c0a3d1254..d9177bebb5 100644 --- a/.eslintignore.errorfiles +++ b/.eslintignore.errorfiles @@ -1,7 +1,7 @@ # autogenerated file: run scripts/generate-eslint-error-ignore-file to update. src/Markdown.js -src/Velociraptor.js +src/NodeAnimator.js src/components/structures/RoomDirectory.js src/components/views/rooms/MemberList.js src/ratelimitedfunc.js diff --git a/.gitignore b/.gitignore index e1dd7726e1..50aa10fbfd 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ /*.log package-lock.json +/coverage /node_modules /lib diff --git a/.stylelintrc.js b/.stylelintrc.js index 313102ea83..0e6de7000f 100644 --- a/.stylelintrc.js +++ b/.stylelintrc.js @@ -4,6 +4,7 @@ module.exports = { "stylelint-scss", ], "rules": { + "color-hex-case": null, "indentation": 4, "comment-empty-line-before": null, "declaration-empty-line-before": null, diff --git a/CHANGELOG.md b/CHANGELOG.md index c31eedf93b..d459b4e94a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,14 +1,441 @@ +Changes in [3.19.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.19.0) (2021-04-26) +===================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.19.0-rc.1...v3.19.0) + + * Upgrade to JS SDK 10.0.0 + * [Release] Dynamic max and min zoom in the new ImageView + [\#5927](https://github.com/matrix-org/matrix-react-sdk/pull/5927) + * [Release] Add a WheelEvent normalization function + [\#5911](https://github.com/matrix-org/matrix-react-sdk/pull/5911) + * Add a WheelEvent normalization function + [\#5904](https://github.com/matrix-org/matrix-react-sdk/pull/5904) + * [Release] Use floats for image background opacity + [\#5907](https://github.com/matrix-org/matrix-react-sdk/pull/5907) + +Changes in [3.19.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.19.0-rc.1) (2021-04-21) +=============================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.18.0...v3.19.0-rc.1) + + * Upgrade to JS SDK 10.0.0-rc.1 + * Translations update from Weblate + [\#5896](https://github.com/matrix-org/matrix-react-sdk/pull/5896) + * Fix sticky tags header in room list + [\#5895](https://github.com/matrix-org/matrix-react-sdk/pull/5895) + * Fix spaces filtering sometimes lagging behind or behaving oddly + [\#5893](https://github.com/matrix-org/matrix-react-sdk/pull/5893) + * Fix issue with spaces context switching looping and breaking + [\#5894](https://github.com/matrix-org/matrix-react-sdk/pull/5894) + * Improve RoomList render time when filtering + [\#5874](https://github.com/matrix-org/matrix-react-sdk/pull/5874) + * Avoid being stuck in a space + [\#5891](https://github.com/matrix-org/matrix-react-sdk/pull/5891) + * [Spaces] Context switching + [\#5795](https://github.com/matrix-org/matrix-react-sdk/pull/5795) + * Warn when you attempt to leave room that you are the only member of + [\#5415](https://github.com/matrix-org/matrix-react-sdk/pull/5415) + * Ensure PersistedElement are unmounted on application logout + [\#5884](https://github.com/matrix-org/matrix-react-sdk/pull/5884) + * Add missing space in seshat dialog and the corresponding string + [\#5866](https://github.com/matrix-org/matrix-react-sdk/pull/5866) + * A tiny change to make the Add existing rooms dialog a little nicer + [\#5885](https://github.com/matrix-org/matrix-react-sdk/pull/5885) + * Remove weird margin from the file panel + [\#5889](https://github.com/matrix-org/matrix-react-sdk/pull/5889) + * Trigger lazy loading when filtering using spaces + [\#5882](https://github.com/matrix-org/matrix-react-sdk/pull/5882) + * Fix typo in method call in add existing to space dialog + [\#5883](https://github.com/matrix-org/matrix-react-sdk/pull/5883) + * New Image View fixes/improvements + [\#5872](https://github.com/matrix-org/matrix-react-sdk/pull/5872) + * Limit voice recording length + [\#5871](https://github.com/matrix-org/matrix-react-sdk/pull/5871) + * Clean up add existing to space dialog and include DMs in it too + [\#5881](https://github.com/matrix-org/matrix-react-sdk/pull/5881) + * Fix unknown slash command error exploding + [\#5853](https://github.com/matrix-org/matrix-react-sdk/pull/5853) + * Switch to a spec conforming email validation Regexp + [\#5852](https://github.com/matrix-org/matrix-react-sdk/pull/5852) + * Cleanup unused state in MessageComposer + [\#5877](https://github.com/matrix-org/matrix-react-sdk/pull/5877) + * Pulse animation for voice messages recording state + [\#5869](https://github.com/matrix-org/matrix-react-sdk/pull/5869) + * Don't include invisible rooms in notify summary + [\#5875](https://github.com/matrix-org/matrix-react-sdk/pull/5875) + * Properly disable composer access when recording a voice message + [\#5870](https://github.com/matrix-org/matrix-react-sdk/pull/5870) + * Stabilise starting a DM with multiple people flow + [\#5862](https://github.com/matrix-org/matrix-react-sdk/pull/5862) + * Render msgOption only if showReadReceipts is enabled + [\#5864](https://github.com/matrix-org/matrix-react-sdk/pull/5864) + * Labs: Add quick/cheap "do not disturb" flag + [\#5873](https://github.com/matrix-org/matrix-react-sdk/pull/5873) + * Fix ReadReceipts animations + [\#5836](https://github.com/matrix-org/matrix-react-sdk/pull/5836) + * Add tooltips to message previews + [\#5859](https://github.com/matrix-org/matrix-react-sdk/pull/5859) + * IRC Layout fix layout spacing in replies + [\#5855](https://github.com/matrix-org/matrix-react-sdk/pull/5855) + * Move user to welcome_page if continuing with previous session + [\#5849](https://github.com/matrix-org/matrix-react-sdk/pull/5849) + * Improve image view + [\#5521](https://github.com/matrix-org/matrix-react-sdk/pull/5521) + * Add a button to reset personal encryption state during login + [\#5819](https://github.com/matrix-org/matrix-react-sdk/pull/5819) + * Fix js-sdk import in SlashCommands + [\#5850](https://github.com/matrix-org/matrix-react-sdk/pull/5850) + * Fix useRoomPowerLevels hook + [\#5854](https://github.com/matrix-org/matrix-react-sdk/pull/5854) + * Prevent state events being rendered with invalid state keys + [\#5851](https://github.com/matrix-org/matrix-react-sdk/pull/5851) + * Give server ACLs a name in 'roles & permissions' tab + [\#5838](https://github.com/matrix-org/matrix-react-sdk/pull/5838) + * Don't hide notification badge on the home space button as it has no menu + [\#5845](https://github.com/matrix-org/matrix-react-sdk/pull/5845) + * User Info hide disambiguation as we always show MXID anyway + [\#5843](https://github.com/matrix-org/matrix-react-sdk/pull/5843) + * Improve kick state to not show if the target was not joined to begin with + [\#5846](https://github.com/matrix-org/matrix-react-sdk/pull/5846) + * Fix space store wrongly switching to a non-space filter + [\#5844](https://github.com/matrix-org/matrix-react-sdk/pull/5844) + * Tweak appearance of invite reason + [\#5847](https://github.com/matrix-org/matrix-react-sdk/pull/5847) + * Update Inter font to v3.18 + [\#5840](https://github.com/matrix-org/matrix-react-sdk/pull/5840) + * Enable sharing historical keys on invite + [\#5839](https://github.com/matrix-org/matrix-react-sdk/pull/5839) + * Add ability to hide post-login encryption setup with customisation point + [\#5834](https://github.com/matrix-org/matrix-react-sdk/pull/5834) + * Use LaTeX and TeX delimiters by default + [\#5515](https://github.com/matrix-org/matrix-react-sdk/pull/5515) + * Clone author's deps fork for Netlify previews + [\#5837](https://github.com/matrix-org/matrix-react-sdk/pull/5837) + * Show drop file UI only if dragging a file + [\#5827](https://github.com/matrix-org/matrix-react-sdk/pull/5827) + * Ignore punctuation when filtering rooms + [\#5824](https://github.com/matrix-org/matrix-react-sdk/pull/5824) + * Resizable CallView + [\#5710](https://github.com/matrix-org/matrix-react-sdk/pull/5710) + +Changes in [3.18.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.18.0) (2021-04-12) +===================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.18.0-rc.1...v3.18.0) + + * Upgrade to JS SDK 9.11.0 + * [Release] Tweak appearance of invite reason + [\#5848](https://github.com/matrix-org/matrix-react-sdk/pull/5848) + +Changes in [3.18.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.18.0-rc.1) (2021-04-07) +=============================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.17.0...v3.18.0-rc.1) + + * Upgrade to JS SDK 9.11.0-rc.1 + * Translations update from Weblate + [\#5832](https://github.com/matrix-org/matrix-react-sdk/pull/5832) + * Add fake fallback thumbnail URL for encrypted videos + [\#5826](https://github.com/matrix-org/matrix-react-sdk/pull/5826) + * Fix broken "Go to Home View" shortcut on macOS + [\#5818](https://github.com/matrix-org/matrix-react-sdk/pull/5818) + * Remove status area UI defects when in-call + [\#5828](https://github.com/matrix-org/matrix-react-sdk/pull/5828) + * Fix viewing invitations when the inviter has no avatar set + [\#5829](https://github.com/matrix-org/matrix-react-sdk/pull/5829) + * Restabilize room list ordering with prefiltering on spaces/communities + [\#5825](https://github.com/matrix-org/matrix-react-sdk/pull/5825) + * Show invite reasons + [\#5694](https://github.com/matrix-org/matrix-react-sdk/pull/5694) + * Require strong password in forgot password form + [\#5744](https://github.com/matrix-org/matrix-react-sdk/pull/5744) + * Attended transfer + [\#5798](https://github.com/matrix-org/matrix-react-sdk/pull/5798) + * Make user autocomplete query search beyond prefix + [\#5822](https://github.com/matrix-org/matrix-react-sdk/pull/5822) + * Add reset option for corrupted event index store + [\#5806](https://github.com/matrix-org/matrix-react-sdk/pull/5806) + * Prevent Re-request encryption keys from appearing under redacted messages + [\#5816](https://github.com/matrix-org/matrix-react-sdk/pull/5816) + * Keybindings follow up + [\#5815](https://github.com/matrix-org/matrix-react-sdk/pull/5815) + * Increase default visible tiles for room sublists + [\#5821](https://github.com/matrix-org/matrix-react-sdk/pull/5821) + * Change copy to point to native node modules docs in element desktop + [\#5817](https://github.com/matrix-org/matrix-react-sdk/pull/5817) + * Show waveform and timer in voice messages + [\#5801](https://github.com/matrix-org/matrix-react-sdk/pull/5801) + * Label unlabeled avatar button in event panel + [\#5585](https://github.com/matrix-org/matrix-react-sdk/pull/5585) + * Fix the theme engine breaking with some web theming extensions + [\#5810](https://github.com/matrix-org/matrix-react-sdk/pull/5810) + * Add /spoiler command + [\#5696](https://github.com/matrix-org/matrix-react-sdk/pull/5696) + * Don't specify sample rates for voice messages + [\#5802](https://github.com/matrix-org/matrix-react-sdk/pull/5802) + * Tweak security key error handling + [\#5812](https://github.com/matrix-org/matrix-react-sdk/pull/5812) + * Add user settings for warn before exit + [\#5793](https://github.com/matrix-org/matrix-react-sdk/pull/5793) + * Decouple key bindings from event handling + [\#5720](https://github.com/matrix-org/matrix-react-sdk/pull/5720) + * Fixing spaces papercuts + [\#5792](https://github.com/matrix-org/matrix-react-sdk/pull/5792) + * Share keys for historical messages when inviting users to encrypted rooms + [\#5763](https://github.com/matrix-org/matrix-react-sdk/pull/5763) + * Fix upload bar not populating when starting uploads + [\#5804](https://github.com/matrix-org/matrix-react-sdk/pull/5804) + * Fix crash on login when using social login + [\#5803](https://github.com/matrix-org/matrix-react-sdk/pull/5803) + * Convert AccessSecretStorageDialog to TypeScript + [\#5805](https://github.com/matrix-org/matrix-react-sdk/pull/5805) + * Tweak cross-signing copy + [\#5807](https://github.com/matrix-org/matrix-react-sdk/pull/5807) + * Fix password change popup message + [\#5791](https://github.com/matrix-org/matrix-react-sdk/pull/5791) + * View Source: make Event ID go below Event ID + [\#5790](https://github.com/matrix-org/matrix-react-sdk/pull/5790) + * Fix line numbers when missing trailing newline + [\#5800](https://github.com/matrix-org/matrix-react-sdk/pull/5800) + * Remember reply when switching rooms + [\#5796](https://github.com/matrix-org/matrix-react-sdk/pull/5796) + * Fix edge case with redaction grouper messing up continuations + [\#5797](https://github.com/matrix-org/matrix-react-sdk/pull/5797) + * Only show the ask anyway modal for explicit user lookup failures + [\#5785](https://github.com/matrix-org/matrix-react-sdk/pull/5785) + * Improve error reporting when EventIndex fails on a supported environment + [\#5787](https://github.com/matrix-org/matrix-react-sdk/pull/5787) + * Tweak and fix some space features + [\#5789](https://github.com/matrix-org/matrix-react-sdk/pull/5789) + * Support replying with a message command + [\#5686](https://github.com/matrix-org/matrix-react-sdk/pull/5686) + * Labs feature: Early implementation of voice messages + [\#5769](https://github.com/matrix-org/matrix-react-sdk/pull/5769) + +Changes in [3.17.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.17.0) (2021-03-29) +===================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.17.0-rc.1...v3.17.0) + + * Upgrade to JS SDK 9.10.0 + * [Release] Tweak cross-signing copy + [\#5808](https://github.com/matrix-org/matrix-react-sdk/pull/5808) + * [Release] Fix crash on login when using social login + [\#5809](https://github.com/matrix-org/matrix-react-sdk/pull/5809) + * [Release] Fix edge case with redaction grouper messing up continuations + [\#5799](https://github.com/matrix-org/matrix-react-sdk/pull/5799) + +Changes in [3.17.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.17.0-rc.1) (2021-03-25) +=============================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.16.0...v3.17.0-rc.1) + + * Upgrade to JS SDK 9.10.0-rc.1 + * Translations update from Weblate + [\#5788](https://github.com/matrix-org/matrix-react-sdk/pull/5788) + * Track next event [tile] over group boundaries + [\#5784](https://github.com/matrix-org/matrix-react-sdk/pull/5784) + * Fixing the minor UI issues in the email discovery + [\#5780](https://github.com/matrix-org/matrix-react-sdk/pull/5780) + * Don't overwrite callback with undefined if no customization provided + [\#5783](https://github.com/matrix-org/matrix-react-sdk/pull/5783) + * Fix redaction event list summaries breaking sender profiles + [\#5781](https://github.com/matrix-org/matrix-react-sdk/pull/5781) + * Fix CIDER formatting buttons on Safari + [\#5782](https://github.com/matrix-org/matrix-react-sdk/pull/5782) + * Improve discovery of rooms in a space + [\#5776](https://github.com/matrix-org/matrix-react-sdk/pull/5776) + * Spaces improve creation journeys + [\#5777](https://github.com/matrix-org/matrix-react-sdk/pull/5777) + * Make buttons in verify dialog respect the system font + [\#5778](https://github.com/matrix-org/matrix-react-sdk/pull/5778) + * Collapse redactions into an event list summary + [\#5728](https://github.com/matrix-org/matrix-react-sdk/pull/5728) + * Added invite option to room's context menu + [\#5648](https://github.com/matrix-org/matrix-react-sdk/pull/5648) + * Add an optional config option to make the welcome page the login page + [\#5658](https://github.com/matrix-org/matrix-react-sdk/pull/5658) + * Fix username showing instead of display name in Jitsi widgets + [\#5770](https://github.com/matrix-org/matrix-react-sdk/pull/5770) + * Convert a bunch more js-sdk imports to absolute paths + [\#5774](https://github.com/matrix-org/matrix-react-sdk/pull/5774) + * Remove forgotten rooms from the room list once forgotten + [\#5775](https://github.com/matrix-org/matrix-react-sdk/pull/5775) + * Log error when failing to list usermedia devices + [\#5771](https://github.com/matrix-org/matrix-react-sdk/pull/5771) + * Fix weird timeline jumps + [\#5772](https://github.com/matrix-org/matrix-react-sdk/pull/5772) + * Replace type declaration in Registration.tsx + [\#5773](https://github.com/matrix-org/matrix-react-sdk/pull/5773) + * Add possibility to delay rageshake persistence in app startup + [\#5767](https://github.com/matrix-org/matrix-react-sdk/pull/5767) + * Fix left panel resizing and lower min-width improving flexibility + [\#5764](https://github.com/matrix-org/matrix-react-sdk/pull/5764) + * Work around more cases where a rageshake server might not be present + [\#5766](https://github.com/matrix-org/matrix-react-sdk/pull/5766) + * Iterate space panel visually and functionally + [\#5761](https://github.com/matrix-org/matrix-react-sdk/pull/5761) + * Make some dispatches async + [\#5765](https://github.com/matrix-org/matrix-react-sdk/pull/5765) + * fix: make room directory correct when using a homeserver with explicit port + [\#5762](https://github.com/matrix-org/matrix-react-sdk/pull/5762) + * Hangup all calls on logout + [\#5756](https://github.com/matrix-org/matrix-react-sdk/pull/5756) + * Remove now-unused assets and CSS from CompleteSecurity step + [\#5757](https://github.com/matrix-org/matrix-react-sdk/pull/5757) + * Add details and summary to allowed HTML tags + [\#5760](https://github.com/matrix-org/matrix-react-sdk/pull/5760) + * Support a media handling customisation endpoint + [\#5714](https://github.com/matrix-org/matrix-react-sdk/pull/5714) + * Edit button on View Source dialog that takes you to devtools -> + SendCustomEvent + [\#5718](https://github.com/matrix-org/matrix-react-sdk/pull/5718) + * Show room alias in plain/formatted body + [\#5748](https://github.com/matrix-org/matrix-react-sdk/pull/5748) + * Allow pills on the beginning of a part string + [\#5754](https://github.com/matrix-org/matrix-react-sdk/pull/5754) + * [SK-3] Decorate easy components with replaceableComponent + [\#5734](https://github.com/matrix-org/matrix-react-sdk/pull/5734) + * Use fsync in reskindex to ensure file is written to disk + [\#5753](https://github.com/matrix-org/matrix-react-sdk/pull/5753) + * Remove unused common CSS classes + [\#5752](https://github.com/matrix-org/matrix-react-sdk/pull/5752) + * Rebuild space previews with new designs + [\#5751](https://github.com/matrix-org/matrix-react-sdk/pull/5751) + * Rework cross-signing login flow + [\#5727](https://github.com/matrix-org/matrix-react-sdk/pull/5727) + * Change read receipt drift to be non-fractional + [\#5745](https://github.com/matrix-org/matrix-react-sdk/pull/5745) + +Changes in [3.16.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.16.0) (2021-03-15) +===================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.16.0-rc.2...v3.16.0) + + * Upgrade to JS SDK 9.9.0 + * [Release] Change read receipt drift to be non-fractional + [\#5746](https://github.com/matrix-org/matrix-react-sdk/pull/5746) + * [Release] Properly gate SpaceRoomView behind labs + [\#5750](https://github.com/matrix-org/matrix-react-sdk/pull/5750) + +Changes in [3.16.0-rc.2](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.16.0-rc.2) (2021-03-10) +=============================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.16.0-rc.1...v3.16.0-rc.2) + + * Fixed incorrect build output in rc.1 + +Changes in [3.16.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.16.0-rc.1) (2021-03-10) +=============================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.15.0...v3.16.0-rc.1) + + * Upgrade to JS SDK 9.9.0-rc.1 + * Translations update from Weblate + [\#5743](https://github.com/matrix-org/matrix-react-sdk/pull/5743) + * Document behaviour of showReadReceipts=false for sent receipts + [\#5739](https://github.com/matrix-org/matrix-react-sdk/pull/5739) + * Tweak sent marker code style + [\#5741](https://github.com/matrix-org/matrix-react-sdk/pull/5741) + * Fix sent markers disappearing for edits/reactions + [\#5737](https://github.com/matrix-org/matrix-react-sdk/pull/5737) + * Ignore to-device decryption in the room list store + [\#5740](https://github.com/matrix-org/matrix-react-sdk/pull/5740) + * Spaces suggested rooms support + [\#5736](https://github.com/matrix-org/matrix-react-sdk/pull/5736) + * Add tooltips to sent/sending receipts + [\#5738](https://github.com/matrix-org/matrix-react-sdk/pull/5738) + * Remove a bunch of useless 'use strict' definitions + [\#5735](https://github.com/matrix-org/matrix-react-sdk/pull/5735) + * [SK-1] Fix types for replaceableComponent + [\#5732](https://github.com/matrix-org/matrix-react-sdk/pull/5732) + * [SK-2] Make debugging skinning problems easier + [\#5733](https://github.com/matrix-org/matrix-react-sdk/pull/5733) + * Support sending invite reasons with /invite command + [\#5695](https://github.com/matrix-org/matrix-react-sdk/pull/5695) + * Fix clicking on the avatar for opening member info requires pixel-perfect + accuracy + [\#5717](https://github.com/matrix-org/matrix-react-sdk/pull/5717) + * Display decrypted and encrypted event source on the same dialog + [\#5713](https://github.com/matrix-org/matrix-react-sdk/pull/5713) + * Fix units of TURN server expiry time + [\#5730](https://github.com/matrix-org/matrix-react-sdk/pull/5730) + * Display room name in pills instead of address + [\#5624](https://github.com/matrix-org/matrix-react-sdk/pull/5624) + * Refresh UI for file uploads + [\#5723](https://github.com/matrix-org/matrix-react-sdk/pull/5723) + * UI refresh for uploaded files + [\#5719](https://github.com/matrix-org/matrix-react-sdk/pull/5719) + * Improve message sending states to match new designs + [\#5699](https://github.com/matrix-org/matrix-react-sdk/pull/5699) + * Add clipboard write permission for widgets + [\#5725](https://github.com/matrix-org/matrix-react-sdk/pull/5725) + * Fix widget resizing + [\#5722](https://github.com/matrix-org/matrix-react-sdk/pull/5722) + * Option for audio streaming + [\#5707](https://github.com/matrix-org/matrix-react-sdk/pull/5707) + * Show a specific error for hs_disabled + [\#5576](https://github.com/matrix-org/matrix-react-sdk/pull/5576) + * Add Edge to the targets list + [\#5721](https://github.com/matrix-org/matrix-react-sdk/pull/5721) + * File drop UI fixes and improvements + [\#5505](https://github.com/matrix-org/matrix-react-sdk/pull/5505) + * Fix Bottom border of state counters is white on the dark theme + [\#5715](https://github.com/matrix-org/matrix-react-sdk/pull/5715) + * Trim spurious whitespace of nicknames + [\#5332](https://github.com/matrix-org/matrix-react-sdk/pull/5332) + * Ensure HostSignupDialog border colour matches light theme + [\#5716](https://github.com/matrix-org/matrix-react-sdk/pull/5716) + * Don't place another call if there's already one ongoing + [\#5712](https://github.com/matrix-org/matrix-react-sdk/pull/5712) + * Space room hierarchies + [\#5706](https://github.com/matrix-org/matrix-react-sdk/pull/5706) + * Iterate Space view and right panel + [\#5705](https://github.com/matrix-org/matrix-react-sdk/pull/5705) + * Add a scroll to bottom on message sent setting + [\#5692](https://github.com/matrix-org/matrix-react-sdk/pull/5692) + * Add .tmp files to gitignore + [\#5708](https://github.com/matrix-org/matrix-react-sdk/pull/5708) + * Initial Space Room View and Creation UX + [\#5704](https://github.com/matrix-org/matrix-react-sdk/pull/5704) + * Add multi language spell check + [\#5452](https://github.com/matrix-org/matrix-react-sdk/pull/5452) + * Fix tetris effect (holes) in read receipts + [\#5697](https://github.com/matrix-org/matrix-react-sdk/pull/5697) + * Fixed edit for markdown images + [\#5703](https://github.com/matrix-org/matrix-react-sdk/pull/5703) + * Iterate Space Panel + [\#5702](https://github.com/matrix-org/matrix-react-sdk/pull/5702) + * Fix read receipts for compact layout + [\#5700](https://github.com/matrix-org/matrix-react-sdk/pull/5700) + * Space Store and Space Panel for Room List filtering + [\#5689](https://github.com/matrix-org/matrix-react-sdk/pull/5689) + * Log when turn creds expire + [\#5691](https://github.com/matrix-org/matrix-react-sdk/pull/5691) + * Null check for maxHeight in call view + [\#5690](https://github.com/matrix-org/matrix-react-sdk/pull/5690) + * Autocomplete invited users + [\#5687](https://github.com/matrix-org/matrix-react-sdk/pull/5687) + * Add send message button + [\#5535](https://github.com/matrix-org/matrix-react-sdk/pull/5535) + * Move call buttons to the room header + [\#5693](https://github.com/matrix-org/matrix-react-sdk/pull/5693) + * Use the default SSSS key if the default is set + [\#5638](https://github.com/matrix-org/matrix-react-sdk/pull/5638) + * Initial Spaces feature flag + [\#5668](https://github.com/matrix-org/matrix-react-sdk/pull/5668) + * Clean up code edge cases and add helpers + [\#5667](https://github.com/matrix-org/matrix-react-sdk/pull/5667) + * Clean up widgets when leaving the room + [\#5684](https://github.com/matrix-org/matrix-react-sdk/pull/5684) + * Fix read receipts? + [\#5567](https://github.com/matrix-org/matrix-react-sdk/pull/5567) + * Fix MAU usage alerts + [\#5678](https://github.com/matrix-org/matrix-react-sdk/pull/5678) + Changes in [3.15.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.15.0) (2021-03-01) ===================================================================================================== [Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.15.0-rc.1...v3.15.0) ## Security notice -matrix-react-sdk 3.15.0 fixes a low severity issue (CVE-2021-21320) where the -user content sandbox can be abused to trick users into opening unexpected -documents. The content is opened with a `blob` origin that cannot access Matrix -user data, so messages and secrets are not at risk. Thanks to @keerok for -responsibly disclosing this via Matrix's Security Disclosure Policy. +matrix-react-sdk 3.15.0 fixes a moderate severity issue (CVE-2021-21320) where +the user content sandbox can be abused to trick users into opening unexpected +documents after several user interactions. The content can be opened with a +`blob` origin from the Matrix client, so it is possible for a malicious document +to access user messages and secrets. Thanks to @keerok for responsibly +disclosing this via Matrix's Security Disclosure Policy. ## All changes diff --git a/babel.config.js b/babel.config.js index d5a97d56ce..0a3a34a391 100644 --- a/babel.config.js +++ b/babel.config.js @@ -3,12 +3,15 @@ module.exports = { "presets": [ ["@babel/preset-env", { "targets": [ - "last 2 Chrome versions", "last 2 Firefox versions", "last 2 Safari versions" + "last 2 Chrome versions", + "last 2 Firefox versions", + "last 2 Safari versions", + "last 2 Edge versions", ], }], "@babel/preset-typescript", "@babel/preset-flow", - "@babel/preset-react" + "@babel/preset-react", ], "plugins": [ ["@babel/plugin-proposal-decorators", {legacy: true}], @@ -18,6 +21,6 @@ module.exports = { "@babel/plugin-proposal-object-rest-spread", "@babel/plugin-transform-flow-comments", "@babel/plugin-syntax-dynamic-import", - "@babel/plugin-transform-runtime" - ] + "@babel/plugin-transform-runtime", + ], }; diff --git a/docs/ciderEditor.md b/docs/ciderEditor.md index f522dc2fc4..379b6f5b51 100644 --- a/docs/ciderEditor.md +++ b/docs/ciderEditor.md @@ -21,14 +21,14 @@ caret nodes (more on that later). For these reasons it doesn't use `innerText`, `textContent` or anything similar. The model addresses any content in the editor within as an offset within this string. The caret position is thus also converted from a position in the DOM tree -to an offset in the content string. This happens in `getCaretOffsetAndText` in `dom.js`. +to an offset in the content string. This happens in `getCaretOffsetAndText` in `dom.ts`. Once the content string and caret offset is calculated, it is passed to the `update()` method of the model. The model first calculates the same content string of its current parts, basically just concatenating their text. It then looks for differences between the current and the new content string. The diffing algorithm is very basic, and assumes there is only one change around the caret offset, -so this should be very inexpensive. See `diff.js` for details. +so this should be very inexpensive. See `diff.ts` for details. The result of the diffing is the strings that were added and/or removed from the current content. These differences are then applied to the parts, @@ -51,7 +51,7 @@ which relate poorly to text input or changes, and don't need the `beforeinput` e which isn't broadly supported yet. Once the parts of the model are updated, the DOM of the editor is then reconciled -with the new model state, see `renderModel` in `render.js` for this. +with the new model state, see `renderModel` in `render.ts` for this. If the model didn't reject the input and didn't make any additional changes, this won't make any changes to the DOM at all, and should thus be fairly efficient. diff --git a/docs/media-handling.md b/docs/media-handling.md new file mode 100644 index 0000000000..a4307fb7d4 --- /dev/null +++ b/docs/media-handling.md @@ -0,0 +1,19 @@ +# Media handling + +Surely media should be as easy as just putting a URL into an `img` and calling it good, right? +Not quite. Matrix uses something called a Matrix Content URI (better known as MXC URI) to identify +content, which is then converted to a regular HTTPS URL on the homeserver. However, sometimes that +URL can change depending on deployment considerations. + +The react-sdk features a [customisation endpoint](https://github.com/vector-im/element-web/blob/develop/docs/customisations.md) +for media handling where all conversions from MXC URI to HTTPS URL happen. This is to ensure that +those obscure deployments can route all their media to the right place. + +For development, there are currently two functions available: `mediaFromMxc` and `mediaFromContent`. +The `mediaFromMxc` function should be self-explanatory. `mediaFromContent` takes an event content as +a parameter and will automatically parse out the source media and thumbnail. Both functions return +a `Media` object with a number of options on it, such as getting various common HTTPS URLs for the +media. + +**It is extremely important that all media calls are put through this customisation endpoint.** So +much so it's a lint rule to avoid accidental use of the wrong functions. diff --git a/docs/room-list-store.md b/docs/room-list-store.md index fa849e2505..6fc5f71124 100644 --- a/docs/room-list-store.md +++ b/docs/room-list-store.md @@ -6,7 +6,7 @@ It's so complicated it needs its own README. Legend: * Orange = External event. -* Purple = Deterministic flow. +* Purple = Deterministic flow. * Green = Algorithm definition. * Red = Exit condition/point. * Blue = Process definition. @@ -24,8 +24,8 @@ algorithm to call, instead of having all the logic in the room list store itself Tag sorting is effectively the comparator supplied to the list algorithm. This gives the list algorithm -the power to decide when and how to apply the tag sorting, if at all. For example, the importance algorithm, -later described in this document, heavily uses the list ordering behaviour to break the tag into categories. +the power to decide when and how to apply the tag sorting, if at all. For example, the importance algorithm, +later described in this document, heavily uses the list ordering behaviour to break the tag into categories. Each category then gets sorted by the appropriate tag sorting algorithm. ### Tag sorting algorithm: Alphabetical @@ -36,7 +36,7 @@ useful. ### Tag sorting algorithm: Manual -Manual sorting makes use of the `order` property present on all tags for a room, per the +Manual sorting makes use of the `order` property present on all tags for a room, per the [Matrix specification](https://matrix.org/docs/spec/client_server/r0.6.0#room-tagging). Smaller values of `order` cause rooms to appear closer to the top of the list. @@ -74,7 +74,7 @@ relative (perceived) importance to the user: set to 'All Messages'. * **Bold**: The room has unread messages waiting for the user. Essentially this is a grey room without a badge/notification count (or 'Mentions Only'/'Muted'). -* **Idle**: No useful (see definition of useful above) activity has occurred in the room since the user +* **Idle**: No useful (see definition of useful above) activity has occurred in the room since the user last read it. Conveniently, each tag gets ordered by those categories as presented: red rooms appear above grey, grey @@ -82,7 +82,7 @@ above bold, etc. Once the algorithm has determined which rooms belong in which categories, the tag sorting algorithm gets applied to each category in a sub-list fashion. This should result in the red rooms (for example) -being sorted alphabetically amongst each other as well as the grey rooms sorted amongst each other, but +being sorted alphabetically amongst each other as well as the grey rooms sorted amongst each other, but collectively the tag will be sorted into categories with red being at the top. ## Sticky rooms @@ -103,48 +103,62 @@ receive another notification which causes the room to move into the topmost posi above the sticky room will move underneath to allow for the new room to take the top slot, maintaining the sticky room's position. -Though only applicable to the importance algorithm, the sticky room is not aware of category boundaries -and thus the user can see a shift in what kinds of rooms move around their selection. An example would -be the user having 4 red rooms, the user selecting the third room (leaving 2 above it), and then having -the rooms above it read on another device. This would result in 1 red room and 1 other kind of room +Though only applicable to the importance algorithm, the sticky room is not aware of category boundaries +and thus the user can see a shift in what kinds of rooms move around their selection. An example would +be the user having 4 red rooms, the user selecting the third room (leaving 2 above it), and then having +the rooms above it read on another device. This would result in 1 red room and 1 other kind of room above the sticky room as it will try to maintain 2 rooms above the sticky room. An exception for the sticky room placement is when there's suddenly not enough rooms to maintain the placement exactly. This typically happens if the user selects a room and leaves enough rooms where it cannot maintain the N required rooms above the sticky room. In this case, the sticky room will simply decrease N as needed. -The N value will never increase while selection remains unchanged: adding a bunch of rooms after having +The N value will never increase while selection remains unchanged: adding a bunch of rooms after having put the sticky room in a position where it's had to decrease N will not increase N. ## Responsibilities of the store -The store is responsible for the ordering, upkeep, and tracking of all rooms. The room list component simply gets -an object containing the tags it needs to worry about and the rooms within. The room list component will -decide which tags need rendering (as it commonly filters out empty tags in most cases), and will deal with +The store is responsible for the ordering, upkeep, and tracking of all rooms. The room list component simply gets +an object containing the tags it needs to worry about and the rooms within. The room list component will +decide which tags need rendering (as it commonly filters out empty tags in most cases), and will deal with all kinds of filtering. ## Filtering -Filters are provided to the store as condition classes, which are then passed along to the algorithm -implementations. The implementations then get to decide how to actually filter the rooms, however in -practice the base `Algorithm` class deals with the filtering in a more optimized/generic way. +Filters are provided to the store as condition classes and have two major kinds: Prefilters and Runtime. -The results of filters get cached to avoid needlessly iterating over potentially thousands of rooms, -as the old room list store does. When a filter condition changes, it emits an update which (in this -case) the `Algorithm` class will pick up and act accordingly. Typically, this also means filtering a +Prefilters flush out rooms which shouldn't appear to the algorithm implementations. Typically this is +due to some higher order room list filtering (such as spaces or tags) deliberately exposing a subset of +rooms to the user. The algorithm implementations will not see a room being prefiltered out. + +Runtime filters are used for more dynamic filtering, such as the user filtering by room name. These +filters are passed along to the algorithm implementations where those implementations decide how and +when to apply the filter. In practice, the base `Algorithm` class ends up doing the heavy lifting for +optimization reasons. + +The results of runtime filters get cached to avoid needlessly iterating over potentially thousands of +rooms, as the old room list store does. When a filter condition changes, it emits an update which (in this +case) the `Algorithm` class will pick up and act accordingly. Typically, this also means filtering a minor subset where possible to avoid over-iterating rooms. All filter conditions are considered "stable" by the consumers, meaning that the consumer does not expect a change in the condition unless the condition says it has changed. This is intentional to maintain the caching behaviour described above. +One might ask why we don't just use prefilter conditions for everything, and the answer is one of slight +subtlety: in the cases of prefilters we are knowingly exposing the user to a workspace-style UX where +room notifications are self-contained within that workspace. Runtime filters tend to not want to affect +visible notification counts (as it doesn't want the room header to suddenly be confusing to the user as +they type), and occasionally UX like "found 2/12 rooms" is desirable. If prefiltering were used instead, +the notification counts would vary while the user was typing and "found 2/12" UX would not be possible. + ## Class breakdowns -The `RoomListStore` is the major coordinator of various algorithm implementations, which take care -of the various `ListAlgorithm` and `SortingAlgorithm` options. The `Algorithm` class is responsible -for figuring out which tags get which rooms, as Matrix specifies them as a reverse map: tags get -defined on rooms and are not defined as a collection of rooms (unlike how they are presented to the -user). Various list-specific utilities are also included, though they are expected to move somewhere -more general when needed. For example, the `membership` utilities could easily be moved elsewhere +The `RoomListStore` is the major coordinator of various algorithm implementations, which take care +of the various `ListAlgorithm` and `SortingAlgorithm` options. The `Algorithm` class is responsible +for figuring out which tags get which rooms, as Matrix specifies them as a reverse map: tags get +defined on rooms and are not defined as a collection of rooms (unlike how they are presented to the +user). Various list-specific utilities are also included, though they are expected to move somewhere +more general when needed. For example, the `membership` utilities could easily be moved elsewhere as needed. The various bits throughout the room list store should also have jsdoc of some kind to help describe diff --git a/package.json b/package.json index 7ed1b272da..e54de8a96c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-react-sdk", - "version": "3.15.0", + "version": "3.19.0", "description": "SDK for matrix.org using React", "author": "matrix.org", "repository": { @@ -23,9 +23,7 @@ "package.json" ], "bin": { - "reskindex": "scripts/reskindex.js", - "matrix-gen-i18n": "scripts/gen-i18n.js", - "matrix-prune-i18n": "scripts/prune-i18n.js" + "reskindex": "scripts/reskindex.js" }, "main": "./src/index.js", "matrix_src_main": "./src/index.js", @@ -35,7 +33,7 @@ "prepublishOnly": "yarn build", "i18n": "matrix-gen-i18n", "prunei18n": "matrix-prune-i18n", - "diff-i18n": "cp src/i18n/strings/en_EN.json src/i18n/strings/en_EN_orig.json && ./scripts/gen-i18n.js && node scripts/compare-file.js src/i18n/strings/en_EN_orig.json src/i18n/strings/en_EN.json", + "diff-i18n": "cp src/i18n/strings/en_EN.json src/i18n/strings/en_EN_orig.json && matrix-gen-i18n && matrix-compare-i18n-files src/i18n/strings/en_EN_orig.json src/i18n/strings/en_EN.json", "reskindex": "node scripts/reskindex.js -h header", "reskindex:watch": "node scripts/reskindex.js -h header -w", "rethemendex": "res/css/rethemendex.sh", @@ -51,7 +49,8 @@ "lint:types": "tsc --noEmit --jsx react", "lint:style": "stylelint 'res/css/**/*.scss'", "test": "jest", - "test:e2e": "./test/end-to-end-tests/run.sh --app-url http://localhost:8080" + "test:e2e": "./test/end-to-end-tests/run.sh --app-url http://localhost:8080", + "coverage": "yarn test --coverage" }, "dependencies": { "@babel/runtime": "^7.12.5", @@ -83,6 +82,7 @@ "matrix-js-sdk": "github:matrix-org/matrix-js-sdk#develop", "matrix-widget-api": "^0.1.0-beta.13", "minimist": "^1.2.5", + "opus-recorder": "^8.0.3", "pako": "^2.0.3", "parse5": "^6.0.1", "png-chunks-extract": "^1.0.0", @@ -101,7 +101,6 @@ "tar-js": "^0.3.0", "text-encoding-utf-8": "^1.0.2", "url": "^0.11.0", - "velocity-animate": "^2.0.6", "what-input": "^5.2.10", "zxcvbn": "^4.4.2" }, @@ -157,8 +156,10 @@ "jest": "^26.6.3", "jest-canvas-mock": "^2.3.0", "jest-environment-jsdom-sixteen": "^1.0.3", + "jest-fetch-mock": "^3.0.3", "matrix-mock-request": "^1.2.3", "matrix-react-test-utils": "^0.2.2", + "matrix-web-i18n": "github:matrix-org/matrix-web-i18n", "olm": "https://packages.matrix.org/npm/olm/olm-3.2.1.tgz", "react-test-renderer": "^16.14.0", "rimraf": "^3.0.2", @@ -188,6 +189,12 @@ }, "transformIgnorePatterns": [ "/node_modules/(?!matrix-js-sdk).+$" + ], + "collectCoverageFrom": [ + "/src/**/*.{js,ts,tsx}" + ], + "coverageReporters": [ + "text" ] } } diff --git a/res/css/_common.scss b/res/css/_common.scss index 6e9d252659..d6f85edb86 100644 --- a/res/css/_common.scss +++ b/res/css/_common.scss @@ -28,6 +28,16 @@ $MessageTimestamp_width_hover: calc($MessageTimestamp_width - 2 * $EventTile_e2e :root { font-size: 10px; + + --transition-short: .1s; + --transition-standard: .3s; +} + +@media (prefers-reduced-motion) { + :root { + --transition-short: 0; + --transition-standard: 0; + } } html { @@ -303,7 +313,7 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus { } .mx_Dialog_lightbox .mx_Dialog_background { - opacity: 0.85; + opacity: $lightbox-background-bg-opacity; background-color: $lightbox-background-bg-color; } @@ -315,6 +325,7 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus { max-width: 100%; max-height: 100%; pointer-events: none; + padding: 0; } .mx_Dialog_header { @@ -395,6 +406,7 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus { border: 1px solid $accent-color; color: $accent-color; background-color: $button-secondary-bg-color; + font-family: inherit; } .mx_Dialog button:last-child { @@ -489,54 +501,6 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus { margin-top: 69px; } -.mx_Beta { - color: red; - margin-right: 10px; - position: relative; - top: -3px; - background-color: white; - padding: 0 4px; - border-radius: 3px; - border: 1px solid darkred; - cursor: help; - transition-duration: 200ms; - font-size: smaller; - filter: opacity(0.5); -} - -.mx_Beta:hover { - color: white; - border: 1px solid gray; - background-color: darkred; -} - -.mx_TintableSvgButton { - position: relative; - display: flex; - flex-direction: row; - justify-content: center; - align-content: center; -} - -.mx_TintableSvgButton object { - margin: 0; - padding: 0; - width: 100%; - height: 100%; - max-width: 100%; - max-height: 100%; -} - -.mx_TintableSvgButton span { - position: absolute; - top: 0; - right: 0; - bottom: 0; - left: 0; - opacity: 0; - cursor: pointer; -} - // username colors // used by SenderProfile & RoomPreviewBar .mx_Username_color1 { @@ -606,6 +570,13 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus { } } +@define-mixin ProgressBarBgColour $colour { + background-color: $colour; + &::-webkit-progress-bar { + background-color: $colour; + } +} + @define-mixin ProgressBarBorderRadius $radius { border-radius: $radius; &::-moz-progress-bar { diff --git a/res/css/_components.scss b/res/css/_components.scss index d894688cac..0057f8a8fc 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -111,17 +111,19 @@ @import "./views/elements/_AddressSelector.scss"; @import "./views/elements/_AddressTile.scss"; @import "./views/elements/_DesktopBuildsNotice.scss"; -@import "./views/elements/_DirectorySearchBox.scss"; @import "./views/elements/_DesktopCapturerSourcePicker.scss"; +@import "./views/elements/_DirectorySearchBox.scss"; @import "./views/elements/_Dropdown.scss"; @import "./views/elements/_EditableItemList.scss"; @import "./views/elements/_ErrorBoundary.scss"; @import "./views/elements/_EventListSummary.scss"; +@import "./views/elements/_FacePile.scss"; @import "./views/elements/_Field.scss"; @import "./views/elements/_FormButton.scss"; @import "./views/elements/_ImageView.scss"; @import "./views/elements/_InfoTooltip.scss"; @import "./views/elements/_InlineSpinner.scss"; +@import "./views/elements/_InviteReason.scss"; @import "./views/elements/_ManageIntegsButton.scss"; @import "./views/elements/_MiniAvatarUploader.scss"; @import "./views/elements/_PowerSelector.scss"; @@ -211,13 +213,13 @@ @import "./views/rooms/_SendMessageComposer.scss"; @import "./views/rooms/_Stickers.scss"; @import "./views/rooms/_TopUnreadMessagesBar.scss"; +@import "./views/rooms/_VoiceRecordComposerTile.scss"; @import "./views/rooms/_WhoIsTypingTile.scss"; @import "./views/settings/_AvatarSetting.scss"; @import "./views/settings/_CrossSigningPanel.scss"; @import "./views/settings/_DevicesPanel.scss"; @import "./views/settings/_E2eAdvancedPanel.scss"; @import "./views/settings/_EmailAddresses.scss"; -@import "./views/settings/_SpellCheckLanguages.scss"; @import "./views/settings/_IntegrationManager.scss"; @import "./views/settings/_Notifications.scss"; @import "./views/settings/_PhoneNumbers.scss"; @@ -225,6 +227,7 @@ @import "./views/settings/_SecureBackupPanel.scss"; @import "./views/settings/_SetIdServer.scss"; @import "./views/settings/_SetIntegrationManager.scss"; +@import "./views/settings/_SpellCheckLanguages.scss"; @import "./views/settings/_UpdateCheckButton.scss"; @import "./views/settings/tabs/_SettingsTab.scss"; @import "./views/settings/tabs/room/_GeneralRoomSettingsTab.scss"; @@ -245,8 +248,12 @@ @import "./views/toasts/_AnalyticsToast.scss"; @import "./views/toasts/_NonUrgentEchoFailureToast.scss"; @import "./views/verification/_VerificationShowSas.scss"; +@import "./views/voice_messages/_PlayPauseButton.scss"; +@import "./views/voice_messages/_PlaybackContainer.scss"; +@import "./views/voice_messages/_Waveform.scss"; @import "./views/voip/_CallContainer.scss"; @import "./views/voip/_CallView.scss"; +@import "./views/voip/_CallViewForRoom.scss"; @import "./views/voip/_DialPad.scss"; @import "./views/voip/_DialPadContextMenu.scss"; @import "./views/voip/_DialPadModal.scss"; diff --git a/res/css/structures/_FilePanel.scss b/res/css/structures/_FilePanel.scss index 2aa068b674..7b975110e1 100644 --- a/res/css/structures/_FilePanel.scss +++ b/res/css/structures/_FilePanel.scss @@ -22,7 +22,6 @@ limitations under the License. } .mx_FilePanel .mx_RoomView_messageListWrapper { - margin-right: 20px; flex-direction: row; align-items: center; justify-content: center; diff --git a/res/css/structures/_LeftPanel.scss b/res/css/structures/_LeftPanel.scss index f1f27014ee..7c3cd1c513 100644 --- a/res/css/structures/_LeftPanel.scss +++ b/res/css/structures/_LeftPanel.scss @@ -19,7 +19,8 @@ $roomListCollapsedWidth: 68px; .mx_LeftPanel { background-color: $roomlist-bg-color; - min-width: 260px; + // TODO decrease this once Spaces launches as it'll no longer need to include the 56px Community Panel + min-width: 206px; max-width: 50%; // Create a row-based flexbox for the GroupFilterPanel and the room list @@ -129,6 +130,10 @@ $roomListCollapsedWidth: 68px; mask-repeat: no-repeat; background: $secondary-fg-color; } + + &.mx_LeftPanel_exploreButton_space::before { + mask-image: url('$(res)/img/element-icons/roomlist/browse.svg'); + } } } diff --git a/res/css/structures/_MatrixChat.scss b/res/css/structures/_MatrixChat.scss index 812a7f8472..a220c5d505 100644 --- a/res/css/structures/_MatrixChat.scss +++ b/res/css/structures/_MatrixChat.scss @@ -66,7 +66,7 @@ limitations under the License. } /* not the left panel, and not the resize handle, so the roomview/groupview/... */ -.mx_MatrixChat > :not(.mx_LeftPanel):not(.mx_ResizeHandle) { +.mx_MatrixChat > :not(.mx_LeftPanel):not(.mx_SpacePanel):not(.mx_ResizeHandle) { background-color: $primary-bg-color; flex: 1 1 0; diff --git a/res/css/structures/_RoomSearch.scss b/res/css/structures/_RoomSearch.scss index c33a3c0ff9..7fdafab5a6 100644 --- a/res/css/structures/_RoomSearch.scss +++ b/res/css/structures/_RoomSearch.scss @@ -22,7 +22,7 @@ limitations under the License. // keep border thickness consistent to prevent movement border: 1px solid transparent; height: 28px; - padding: 2px; + padding: 1px; // Create a flexbox for the icons (easier to manage) display: flex; diff --git a/res/css/structures/_RoomStatusBar.scss b/res/css/structures/_RoomStatusBar.scss index 5bf2aee3ae..8cc00aba0f 100644 --- a/res/css/structures/_RoomStatusBar.scss +++ b/res/css/structures/_RoomStatusBar.scss @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_RoomStatusBar { +.mx_RoomStatusBar:not(.mx_RoomStatusBar_unsentMessages) { margin-left: 65px; min-height: 50px; } @@ -68,6 +68,99 @@ limitations under the License. min-height: 58px; } +.mx_RoomStatusBar_unsentMessages { + > div[role="alert"] { + // cheat some basic alignment + display: flex; + align-items: center; + min-height: 70px; + margin: 12px; + padding-left: 16px; + background-color: $header-panel-bg-color; + border-radius: 4px; + } + + .mx_RoomStatusBar_unsentBadge { + margin-right: 12px; + + .mx_NotificationBadge { + // Override sizing from the default badge + width: 24px !important; + height: 24px !important; + border-radius: 24px !important; + + .mx_NotificationBadge_count { + font-size: $font-16px !important; // override default + } + } + } + + .mx_RoomStatusBar_unsentTitle { + color: $warning-color; + font-size: $font-15px; + } + + .mx_RoomStatusBar_unsentDescription { + font-size: $font-12px; + } + + .mx_RoomStatusBar_unsentButtonBar { + flex-grow: 1; + text-align: right; + margin-right: 22px; + color: $muted-fg-color; + + .mx_AccessibleButton { + padding: 5px 10px; + padding-left: 28px; // 16px for the icon, 2px margin to text, 10px regular padding + display: inline-block; + position: relative; + + &:nth-child(2) { + border-left: 1px solid $resend-button-divider-color; + } + + &::before { + content: ''; + position: absolute; + left: 10px; // inset for regular button padding + background-color: $muted-fg-color; + mask-repeat: no-repeat; + mask-position: center; + mask-size: contain; + } + + &.mx_RoomStatusBar_unsentCancelAllBtn::before { + mask-image: url('$(res)/img/element-icons/trashcan.svg'); + width: 12px; + height: 16px; + top: calc(50% - 8px); // text sizes are dynamic + } + + &.mx_RoomStatusBar_unsentResendAllBtn { + padding-left: 34px; // 28px from above, but +6px to account for the wider icon + + &::before { + mask-image: url('$(res)/img/element-icons/retry.svg'); + width: 18px; + height: 18px; + top: calc(50% - 9px); // text sizes are dynamic + } + } + } + + .mx_InlineSpinner { + vertical-align: middle; + margin-right: 5px; + top: 1px; // just to help the vertical alignment be slightly better + + & + span { + margin-right: 10px; // same margin/padding as the rightmost button + } + } + } +} + .mx_RoomStatusBar_connectionLostBar img { padding-left: 10px; padding-right: 10px; @@ -103,7 +196,7 @@ limitations under the License. } .mx_MatrixChat_useCompactLayout { - .mx_RoomStatusBar { + .mx_RoomStatusBar:not(.mx_RoomStatusBar_unsentMessages) { min-height: 40px; } diff --git a/res/css/structures/_RoomView.scss b/res/css/structures/_RoomView.scss index 26382b55e8..cdbe47178d 100644 --- a/res/css/structures/_RoomView.scss +++ b/res/css/structures/_RoomView.scss @@ -262,12 +262,6 @@ hr.mx_RoomView_myReadMarker { padding-top: 1px; } -.mx_RoomView_inCall .mx_RoomView_statusAreaBox { - background-color: $accent-color; - color: $accent-fg-color; - position: relative; -} - .mx_RoomView_voipChevron { position: absolute; bottom: -11px; diff --git a/res/css/structures/_ScrollPanel.scss b/res/css/structures/_ScrollPanel.scss index 699224949b..a4e501b339 100644 --- a/res/css/structures/_ScrollPanel.scss +++ b/res/css/structures/_ScrollPanel.scss @@ -21,6 +21,5 @@ limitations under the License. display: flex; flex-direction: column; justify-content: flex-end; - overflow-y: hidden; } } diff --git a/res/css/structures/_SpacePanel.scss b/res/css/structures/_SpacePanel.scss index 9937117086..59f2ea947c 100644 --- a/res/css/structures/_SpacePanel.scss +++ b/res/css/structures/_SpacePanel.scss @@ -16,9 +16,8 @@ limitations under the License. $topLevelHeight: 32px; $nestedHeight: 24px; -$gutterSize: 17px; -$activeStripeSize: 4px; -$activeBorderTransparentGap: 2px; +$gutterSize: 16px; +$activeBorderTransparentGap: 1px; $activeBackgroundColor: $roomtile-selected-bg-color; $activeBorderColor: $secondary-fg-color; @@ -36,6 +35,7 @@ $activeBorderColor: $secondary-fg-color; .mx_SpacePanel_spaceTreeWrapper { flex: 1; + padding: 8px 8px 16px 0; } .mx_SpacePanel_toggleCollapse { @@ -59,25 +59,29 @@ $activeBorderColor: $secondary-fg-color; margin: 0; list-style: none; padding: 0; - padding-left: 16px; - } - .mx_AutoHideScrollbar { - padding: 16px 12px 16px 0; + > .mx_SpaceItem { + padding-left: 16px; + } } .mx_SpaceButton_toggleCollapse { cursor: pointer; } - .mx_SpaceItem.collapsed { - .mx_SpaceButton { - .mx_NotificationBadge { - right: -4px; - top: -4px; - } - } + .mx_SpaceTreeLevel { + display: flex; + flex-direction: column; + max-width: 250px; + flex-grow: 1; + } + .mx_SpaceItem { + display: inline-flex; + flex-flow: wrap; + } + + .mx_SpaceItem.collapsed { & > .mx_SpaceButton > .mx_SpaceButton_toggleCollapse { transform: rotate(-90deg); } @@ -89,34 +93,42 @@ $activeBorderColor: $secondary-fg-color; .mx_SpaceItem:not(.hasSubSpaces) > .mx_SpaceButton { margin-left: $gutterSize; + min-width: 40px; } .mx_SpaceButton { border-radius: 8px; - position: relative; - margin-bottom: 2px; display: flex; align-items: center; - padding: 4px; + padding: 4px 4px 4px 0; + width: 100%; &.mx_SpaceButton_active { &:not(.mx_SpaceButton_narrow) .mx_SpaceButton_selectionWrapper { background-color: $activeBackgroundColor; - border-radius: 8px; } - &.mx_SpaceButton_narrow { - .mx_BaseAvatar, .mx_SpaceButton_avatarPlaceholder { - border: 2px $activeBorderColor solid; - border-radius: 11px; - } + &.mx_SpaceButton_narrow .mx_SpaceButton_selectionWrapper { + padding: $activeBorderTransparentGap; + border: 3px $activeBorderColor solid; } } .mx_SpaceButton_selectionWrapper { + position: relative; display: flex; flex: 1; align-items: center; + border-radius: 12px; + padding: 4px; + } + + &:not(.mx_SpaceButton_narrow) { + .mx_SpaceButton_selectionWrapper { + width: 100%; + padding-right: 16px; + overflow: hidden; + } } .mx_SpaceButton_name { @@ -124,7 +136,6 @@ $activeBorderColor: $secondary-fg-color; margin-left: 8px; white-space: nowrap; display: block; - max-width: 150px; text-overflow: ellipsis; overflow: hidden; padding-right: 8px; @@ -133,8 +144,7 @@ $activeBorderColor: $secondary-fg-color; } .mx_SpaceButton_toggleCollapse { - width: calc($gutterSize - $activeStripeSize); - margin-left: 1px; + width: $gutterSize; height: 20px; mask-position: center; mask-size: 20px; @@ -172,11 +182,6 @@ $activeBorderColor: $secondary-fg-color; } } - .mx_SpaceButton_avatarPlaceholder { - border: $activeBorderTransparentGap transparent solid; - padding: $activeBorderTransparentGap; - } - &.mx_SpaceButton_new .mx_SpaceButton_icon { background-color: $accent-color; transition: all .1s ease-in-out; // TODO transition @@ -196,21 +201,8 @@ $activeBorderColor: $secondary-fg-color; } } - .mx_BaseAvatar { - /* moving the border-radius to this element from _image - element so we can add a border to it without the initials being displaced */ - overflow: hidden; - border: 2px transparent solid; - padding: $activeBorderTransparentGap; - - .mx_BaseAvatar_initial { - top: $activeBorderTransparentGap; - left: $activeBorderTransparentGap; - } - - .mx_BaseAvatar_image { - border-radius: 8px; - } + .mx_BaseAvatar_image { + border-radius: 8px; } .mx_SpaceButton_menuButton { @@ -219,8 +211,9 @@ $activeBorderColor: $secondary-fg-color; height: 20px; margin-top: auto; margin-bottom: auto; - position: relative; display: none; + position: absolute; + right: 4px; &::before { top: 2px; @@ -239,9 +232,8 @@ $activeBorderColor: $secondary-fg-color; } .mx_SpacePanel_badgeContainer { + position: absolute; height: 16px; - // don't set width so that it takes no space when there is no badge to show - margin: auto 0; // vertically align // Create a flexbox to make aligning dot badges easier display: flex; @@ -261,26 +253,39 @@ $activeBorderColor: $secondary-fg-color; &.collapsed { .mx_SpaceButton { .mx_SpacePanel_badgeContainer { - position: absolute; - right: 0px; - top: 2px; + right: -3px; + top: -3px; + } + + &.mx_SpaceButton_active .mx_SpacePanel_badgeContainer { + // when we draw the selection border we move the relative bounds of our parent + // so update our position within the bounds of the parent to maintain position overall + right: -6px; + top: -6px; } } } &:not(.collapsed) { + .mx_SpacePanel_badgeContainer { + position: absolute; + right: 4px; + } + .mx_SpaceButton:hover, .mx_SpaceButton:focus-within, .mx_SpaceButton_hasMenuOpen { - // Hide the badge container on hover because it'll be a menu button - .mx_SpacePanel_badgeContainer { - width: 0; - height: 0; - display: none; - } + &:not(.mx_SpaceButton_home) { + // Hide the badge container on hover because it'll be a menu button + .mx_SpacePanel_badgeContainer { + width: 0; + height: 0; + display: none; + } - .mx_SpaceButton_menuButton { - display: block; + .mx_SpaceButton_menuButton { + display: block; + } } } } @@ -326,20 +331,20 @@ $activeBorderColor: $secondary-fg-color; mask-image: url('$(res)/img/element-icons/leave.svg'); } - .mx_SpacePanel_iconHome::before { - mask-image: url('$(res)/img/element-icons/roomlist/home.svg'); - } - .mx_SpacePanel_iconMembers::before { mask-image: url('$(res)/img/element-icons/room/members.svg'); } .mx_SpacePanel_iconPlus::before { - mask-image: url('$(res)/img/element-icons/plus.svg'); + mask-image: url('$(res)/img/element-icons/roomlist/plus-circle.svg'); + } + + .mx_SpacePanel_iconHash::before { + mask-image: url('$(res)/img/element-icons/roomlist/hash-circle.svg'); } .mx_SpacePanel_iconExplore::before { - mask-image: url('$(res)/img/element-icons/roomlist/explore.svg'); + mask-image: url('$(res)/img/element-icons/roomlist/browse.svg'); } } diff --git a/res/css/structures/_SpaceRoomDirectory.scss b/res/css/structures/_SpaceRoomDirectory.scss index 5cb91820cf..c7d087d8e0 100644 --- a/res/css/structures/_SpaceRoomDirectory.scss +++ b/res/css/structures/_SpaceRoomDirectory.scss @@ -26,12 +26,16 @@ limitations under the License. word-break: break-word; display: flex; flex-direction: column; +} +.mx_SpaceRoomDirectory, +.mx_SpaceRoomView_landing { .mx_Dialog_title { display: flex; .mx_BaseAvatar { - margin-right: 16px; + margin-right: 12px; + align-self: center; } .mx_BaseAvatar_image { @@ -47,6 +51,7 @@ limitations under the License. } > div { + font-weight: 400; color: $secondary-fg-color; font-size: $font-15px; line-height: $font-24px; @@ -54,39 +59,70 @@ limitations under the License. } } - .mx_Dialog_content { - // TODO fix scrollbar - //display: flex; - //flex-direction: column; - //height: calc(100% - 80px); + .mx_AccessibleButton_kind_link { + padding: 0; + } - .mx_AccessibleButton_kind_link { - padding: 0; - } + .mx_SearchBox { + margin: 24px 0 16px; + } - .mx_SearchBox { - margin: 24px 0 28px; - } + .mx_SpaceRoomDirectory_noResults { + text-align: center; - .mx_SpaceRoomDirectory_listHeader { - display: flex; - font-size: $font-12px; - line-height: $font-15px; + > div { + font-size: $font-15px; + line-height: $font-24px; color: $secondary-fg-color; + } + } - .mx_FormButton { - margin-bottom: 8px; - } + .mx_SpaceRoomDirectory_listHeader { + display: flex; + min-height: 32px; + align-items: center; + font-size: $font-15px; + line-height: $font-24px; + color: $primary-fg-color; - > span { - margin: auto 0 0 auto; + .mx_AccessibleButton { + padding: 2px 8px; + font-weight: normal; + + & + .mx_AccessibleButton { + margin-left: 16px; } } + + > span { + margin-left: auto; + } + } + + .mx_SpaceRoomDirectory_error { + position: relative; + font-weight: $font-semi-bold; + color: $notice-primary-color; + font-size: $font-15px; + line-height: $font-18px; + margin: 20px auto 12px; + padding-left: 24px; + width: max-content; + + &::before { + content: ""; + position: absolute; + height: 16px; + width: 16px; + left: 0; + background-image: url("$(res)/img/element-icons/warning-badge.svg"); + } } } .mx_SpaceRoomDirectory_list { - margin-top: 8px; + margin-top: 16px; + padding-bottom: 40px; .mx_SpaceRoomDirectory_roomCount { > h3 { @@ -106,113 +142,141 @@ limitations under the License. } .mx_SpaceRoomDirectory_subspace { - margin-top: 8px; - - .mx_SpaceRoomDirectory_subspace_info { - display: flex; - flex-direction: row; - align-items: center; - margin-bottom: 8px; - color: $secondary-fg-color; - font-weight: $font-semi-bold; - font-size: $font-12px; - line-height: $font-15px; - - .mx_BaseAvatar { - margin-right: 12px; - vertical-align: middle; - } - - .mx_BaseAvatar_image { - border-radius: 8px; - } - - .mx_SpaceRoomDirectory_actions { - text-align: right; - height: min-content; - margin-left: auto; - margin-right: 16px; - } - } - - .mx_SpaceRoomDirectory_subspace_children { - margin-left: 12px; - border-left: 2px solid $space-button-outline-color; - padding-left: 24px; + .mx_BaseAvatar_image { + border-radius: 8px; } } - .mx_SpaceRoomDirectory_roomTile { - padding: 16px; - border-radius: 8px; - border: 1px solid $space-button-outline-color; - margin: 8px 0 16px; - display: flex; - min-height: 76px; - box-sizing: border-box; + .mx_SpaceRoomDirectory_subspace_toggle { + position: absolute; + left: -1px; + top: 10px; + height: 16px; + width: 16px; + border-radius: 4px; + background-color: $primary-bg-color; - &.mx_AccessibleButton:hover { - background-color: rgba(141, 151, 165, 0.1); + &::before { + content: ''; + position: absolute; + top: 0; + left: 0; + height: 16px; + width: 16px; + mask-repeat: no-repeat; + mask-position: center; + background-color: $tertiary-fg-color; + mask-size: 16px; + transform: rotate(270deg); + mask-image: url('$(res)/img/feather-customised/chevron-down.svg'); } + &.mx_SpaceRoomDirectory_subspace_toggle_shown::before { + transform: rotate(0deg); + } + } + + .mx_SpaceRoomDirectory_subspace_children { + position: relative; + padding-left: 12px; + } + + .mx_SpaceRoomDirectory_roomTile { + position: relative; + padding: 8px 16px; + border-radius: 8px; + min-height: 56px; + box-sizing: border-box; + + display: grid; + grid-template-columns: 20px auto max-content; + grid-column-gap: 8px; + grid-row-gap: 6px; + align-items: center; + .mx_BaseAvatar { - margin-right: 16px; - margin-top: 6px; + grid-row: 1; + grid-column: 1; + } + + .mx_SpaceRoomDirectory_roomTile_name { + font-weight: $font-semi-bold; + font-size: $font-15px; + line-height: $font-18px; + grid-row: 1; + grid-column: 2; + + .mx_InfoTooltip { + display: inline; + margin-left: 12px; + color: $tertiary-fg-color; + font-size: $font-12px; + line-height: $font-15px; + + .mx_InfoTooltip_icon { + margin-right: 4px; + position: relative; + vertical-align: text-top; + + &::before { + position: absolute; + top: 0; + left: 0; + } + } + } } .mx_SpaceRoomDirectory_roomTile_info { - display: inline-block; - font-size: $font-15px; - flex-grow: 1; - height: min-content; - margin: auto 0; - - .mx_SpaceRoomDirectory_roomTile_name { - font-weight: $font-semi-bold; - line-height: $font-18px; - } - .mx_SpaceRoomDirectory_roomTile_topic { - line-height: $font-24px; - color: $secondary-fg-color; - } - } - - .mx_SpaceRoomDirectory_roomTile_memberCount { - position: relative; - margin: auto 0 auto 24px; - padding: 0 0 0 28px; - line-height: $font-24px; - display: inline-block; - width: 32px; - - &::before { - position: absolute; - content: ''; - width: 24px; - height: 24px; - top: 0; - left: 0; - mask-position: center; - mask-repeat: no-repeat; - mask-size: contain; - background-color: $secondary-fg-color; - mask-image: url('$(res)/img/element-icons/community-members.svg'); - } + font-size: $font-14px; + line-height: $font-18px; + color: $secondary-fg-color; + grid-row: 2; + grid-column: 1/3; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + overflow: hidden; } .mx_SpaceRoomDirectory_actions { - width: 180px; text-align: right; - height: min-content; - margin: auto 0 auto 28px; + margin-left: 20px; + grid-column: 3; + grid-row: 1/3; .mx_AccessibleButton { - vertical-align: middle; - - & + .mx_AccessibleButton { - margin-left: 24px; - } + padding: 8px 18px; + display: inline-block; + visibility: hidden; } + + .mx_Checkbox { + display: inline-flex; + vertical-align: middle; + margin-left: 12px; + } + } + + &:hover { + background-color: $groupFilterPanel-bg-color; + + .mx_AccessibleButton { + visibility: visible; + } + } + } + + .mx_SpaceRoomDirectory_roomTile, + .mx_SpaceRoomDirectory_subspace_children { + &::before { + content: ""; + position: absolute; + background-color: $groupFilterPanel-bg-color; + width: 1px; + height: 100%; + left: 6px; + top: 0; } } @@ -223,9 +287,18 @@ limitations under the License. line-height: $font-15px; color: $secondary-fg-color; } + } - .mx_Checkbox { - display: inline-block; - } + > hr { + border: none; + height: 1px; + background-color: rgba(141, 151, 165, 0.2); + margin: 20px 0; + } + + .mx_SpaceRoomDirectory_createRoom { + display: block; + margin: 16px auto 0; + width: max-content; } } diff --git a/res/css/structures/_SpaceRoomView.scss b/res/css/structures/_SpaceRoomView.scss index 38310d39a9..553919d862 100644 --- a/res/css/structures/_SpaceRoomView.scss +++ b/res/css/structures/_SpaceRoomView.scss @@ -16,10 +16,57 @@ limitations under the License. $SpaceRoomViewInnerWidth: 428px; +@define-mixin SpacePillButton { + position: relative; + padding: 16px 32px 16px 72px; + width: 432px; + box-sizing: border-box; + border-radius: 8px; + border: 1px solid $input-border-color; + font-size: $font-15px; + margin: 20px 0; + + > h3 { + font-weight: $font-semi-bold; + margin: 0 0 4px; + } + + > span { + color: $secondary-fg-color; + } + + &::before { + position: absolute; + content: ''; + width: 32px; + height: 32px; + top: 24px; + left: 20px; + mask-position: center; + mask-repeat: no-repeat; + mask-size: 24px; + background-color: $tertiary-fg-color; + } + + &:hover { + border-color: $accent-color; + + &::before { + background-color: $accent-color; + } + + > span { + color: $primary-fg-color; + } + } +} + .mx_SpaceRoomView { .mx_MainSplit > div:first-child { padding: 80px 60px; flex-grow: 1; + max-height: 100%; + overflow-y: auto; h1 { margin: 0; @@ -34,6 +81,16 @@ $SpaceRoomViewInnerWidth: 428px; color: $secondary-fg-color; margin-top: 12px; margin-bottom: 24px; + max-width: $SpaceRoomViewInnerWidth; + } + + .mx_AddExistingToSpace { + max-width: $SpaceRoomViewInnerWidth; + + .mx_AddExistingToSpace_content { + height: calc(100vh - 360px); + max-height: 400px; + } } .mx_SpaceRoomView_buttons { @@ -42,7 +99,7 @@ $SpaceRoomViewInnerWidth: 428px; width: $SpaceRoomViewInnerWidth; text-align: right; // button alignment right - .mx_FormButton { + .mx_AccessibleButton_hasKind { padding: 8px 22px; margin-left: 16px; } @@ -69,9 +126,68 @@ $SpaceRoomViewInnerWidth: 428px; } } - .mx_SpaceRoomView_landing { - overflow-y: auto; + .mx_SpaceRoomView_preview { + padding: 32px 24px !important; // override default padding from above + margin: auto; + max-width: 480px; + box-sizing: border-box; + box-shadow: 2px 15px 30px $dialog-shadow-color; + border-radius: 8px; + .mx_SpaceRoomView_preview_inviter { + display: flex; + align-items: center; + margin-bottom: 20px; + font-size: $font-15px; + + > div { + margin-left: 8px; + + .mx_SpaceRoomView_preview_inviter_name { + line-height: $font-18px; + } + + .mx_SpaceRoomView_preview_inviter_mxid { + line-height: $font-24px; + color: $secondary-fg-color; + } + } + } + + > .mx_BaseAvatar_image, + > .mx_BaseAvatar > .mx_BaseAvatar_image { + border-radius: 12px; + } + + h1.mx_SpaceRoomView_preview_name { + margin: 20px 0 !important; // override default margin from above + } + + .mx_SpaceRoomView_preview_topic { + font-size: $font-14px; + line-height: $font-22px; + color: $secondary-fg-color; + margin: 20px 0; + max-height: 160px; + overflow-y: auto; + } + + .mx_SpaceRoomView_preview_joinButtons { + margin-top: 20px; + + .mx_AccessibleButton { + width: 200px; + box-sizing: border-box; + padding: 14px 0; + + & + .mx_AccessibleButton { + margin-left: 20px; + } + } + } + } + + .mx_SpaceRoomView_landing { > .mx_BaseAvatar_image, > .mx_BaseAvatar > .mx_BaseAvatar_image { border-radius: 12px; @@ -100,44 +216,60 @@ $SpaceRoomViewInnerWidth: 428px; vertical-align: middle; } } + } - .mx_SpaceRoomView_landing_memberCount { + .mx_SpaceRoomView_landing_info { + display: flex; + align-items: center; + + .mx_SpaceRoomView_info { + display: inline-block; + margin: 0 auto 0 0; + } + + .mx_FacePile { + display: inline-block; + margin-right: 12px; + + .mx_FacePile_faces { + cursor: pointer; + } + } + + .mx_SpaceRoomView_landing_inviteButton { position: relative; - margin-left: 24px; - padding: 0 0 0 28px; - line-height: $font-24px; - vertical-align: text-bottom; + padding-left: 40px; + height: min-content; &::before { position: absolute; - content: ''; - width: 24px; - height: 24px; - top: 0; - left: 0; + content: ""; + left: 8px; + height: 16px; + width: 16px; + background: #ffffff; // white icon fill mask-position: center; + mask-size: 16px; mask-repeat: no-repeat; - mask-size: contain; - background-color: $accent-color; - mask-image: url('$(res)/img/element-icons/community-members.svg'); + mask-image: url('$(res)/img/element-icons/room/invite.svg'); } } } .mx_SpaceRoomView_landing_topic { font-size: $font-15px; + margin-top: 12px; + margin-bottom: 16px; } - .mx_SpaceRoomView_landing_joinButtons { - margin-top: 24px; - - .mx_FormButton { - padding: 8px 22px; - } + > hr { + border: none; + height: 1px; + background-color: $groupFilterPanel-bg-color; } .mx_SpaceRoomView_landing_adminButtons { - margin-top: 32px; + margin-top: 24px; .mx_AccessibleButton { position: relative; @@ -146,9 +278,9 @@ $SpaceRoomViewInnerWidth: 428px; box-sizing: border-box; padding: 72px 16px 0; border-radius: 12px; - border: 1px solid $space-button-outline-color; + border: 1px solid $input-border-color; margin-right: 28px; - margin-bottom: 28px; + margin-bottom: 20px; font-size: $font-14px; display: inline-block; vertical-align: bottom; @@ -178,16 +310,6 @@ $SpaceRoomViewInnerWidth: 428px; background: #ffffff; // white icon fill } - &.mx_SpaceRoomView_landing_inviteButton { - &::before { - background-color: $accent-color; - } - - &::after { - mask-image: url('$(res)/img/element-icons/room/invite.svg'); - } - } - &.mx_SpaceRoomView_landing_addButton { &::before { background-color: #ac3ba8; @@ -220,74 +342,14 @@ $SpaceRoomViewInnerWidth: 428px; } } - .mx_SpaceRoomDirectory_list { - max-width: 600px; - - .mx_SpaceRoomDirectory_roomTile_actions { - display: none; - } + .mx_SearchBox { + margin: 0 0 20px; } } .mx_SpaceRoomView_privateScope { - .mx_RadioButton { - width: $SpaceRoomViewInnerWidth; - border-radius: 8px; - border: 1px solid $space-button-outline-color; - padding: 16px 16px 16px 72px; - margin-top: 36px; - cursor: pointer; - box-sizing: border-box; - position: relative; - - > div:first-of-type { - // hide radio dot - display: none; - } - - .mx_RadioButton_content { - margin: 0; - - > h3 { - margin: 0 0 4px; - font-size: $font-15px; - font-weight: $font-semi-bold; - line-height: $font-18px; - } - - > div { - color: $secondary-fg-color; - font-size: $font-15px; - line-height: $font-24px; - } - } - - &::before { - content: ""; - position: absolute; - height: 32px; - width: 32px; - top: 24px; - left: 20px; - background-color: $secondary-fg-color; - mask-repeat: no-repeat; - mask-position: center; - mask-size: contain; - } - } - - .mx_RadioButton_checked { - border-color: $accent-color; - - .mx_RadioButton_content { - > div { - color: $primary-fg-color; - } - } - - &::before { - background-color: $accent-color; - } + .mx_AccessibleButton { + @mixin SpacePillButton; } .mx_SpaceRoomView_privateScope_justMeButton::before { @@ -334,3 +396,50 @@ $SpaceRoomViewInnerWidth: 428px; } } } + +.mx_SpaceRoomView_info { + color: $secondary-fg-color; + font-size: $font-15px; + line-height: $font-24px; + margin: 20px 0; + + .mx_SpaceRoomView_info_public, + .mx_SpaceRoomView_info_private { + padding-left: 20px; + position: relative; + + &::before { + position: absolute; + content: ""; + width: 20px; + height: 20px; + top: 0; + left: -2px; + mask-position: center; + mask-repeat: no-repeat; + background-color: $tertiary-fg-color; + } + } + + .mx_SpaceRoomView_info_public::before { + mask-size: 12px; + mask-image: url("$(res)/img/globe.svg"); + } + + .mx_SpaceRoomView_info_private::before { + mask-size: 14px; + mask-image: url("$(res)/img/element-icons/lock.svg"); + } + + .mx_AccessibleButton_kind_link { + color: inherit; + position: relative; + padding-left: 16px; + + &::before { + content: "·"; // visual separator + position: absolute; + left: 6px; + } + } +} diff --git a/res/css/structures/_ToastContainer.scss b/res/css/structures/_ToastContainer.scss index c381668a6a..09f834a6e3 100644 --- a/res/css/structures/_ToastContainer.scss +++ b/res/css/structures/_ToastContainer.scss @@ -1,5 +1,5 @@ /* -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2019-2021 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -158,6 +158,10 @@ limitations under the License. } } + .mx_Toast_detail { + color: $secondary-fg-color; + } + .mx_Toast_deviceID { font-size: $font-10px; } diff --git a/res/css/structures/_UploadBar.scss b/res/css/structures/_UploadBar.scss index d76c81668c..7c62516b47 100644 --- a/res/css/structures/_UploadBar.scss +++ b/res/css/structures/_UploadBar.scss @@ -1,5 +1,5 @@ /* -Copyright 2015, 2016 OpenMarket Ltd +Copyright 2015, 2016, 2021 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -15,47 +15,45 @@ limitations under the License. */ .mx_UploadBar { + padding-left: 65px; // line up with the shield area in the composer position: relative; + + .mx_ProgressBar { + width: calc(100% - 40px); // cheating at a right margin + } } -.mx_UploadBar_uploadProgressOuter { - height: 5px; - margin-left: 63px; - margin-top: -1px; - padding-bottom: 5px; -} - -.mx_UploadBar_uploadProgressInner { - background-color: $accent-color; - height: 5px; -} - -.mx_UploadBar_uploadFilename { +.mx_UploadBar_filename { margin-top: 5px; - margin-left: 65px; - opacity: 0.5; - color: $primary-fg-color; -} - -.mx_UploadBar_uploadIcon { - float: left; - margin-top: 5px; - margin-left: 14px; -} - -.mx_UploadBar_uploadCancel { - float: right; - margin-top: 5px; - margin-right: 10px; + color: $muted-fg-color; position: relative; - opacity: 0.6; - cursor: pointer; - z-index: 1; + padding-left: 22px; // 18px for icon, 4px for padding + font-size: $font-15px; + vertical-align: middle; + + &::before { + content: ""; + height: 18px; + width: 18px; + position: absolute; + top: 0; + left: 0; + mask-repeat: no-repeat; + mask-position: center; + background-color: $muted-fg-color; + mask-image: url('$(res)/img/element-icons/upload.svg'); + } } -.mx_UploadBar_uploadBytes { - float: right; - margin-top: 5px; - margin-right: 30px; - color: $accent-color; +.mx_UploadBar_cancel { + position: absolute; + top: 0; + right: 0; + height: 16px; + width: 16px; + margin-right: 16px; // align over rightmost button in composer + mask-repeat: no-repeat; + mask-position: center; + background-color: $muted-fg-color; + mask-image: url('$(res)/img/icons-close.svg'); } diff --git a/res/css/structures/_UserMenu.scss b/res/css/structures/_UserMenu.scss index 3badb0850c..17e6ad75df 100644 --- a/res/css/structures/_UserMenu.scss +++ b/res/css/structures/_UserMenu.scss @@ -117,6 +117,32 @@ limitations under the License. .mx_UserMenu_headerButtons { // No special styles: the rest of the layout happens to make it work. } + + .mx_UserMenu_dnd { + width: 24px; + height: 24px; + margin-right: 8px; + position: relative; + + &::before { + content: ''; + position: absolute; + width: 24px; + height: 24px; + mask-position: center; + mask-size: contain; + mask-repeat: no-repeat; + background: $muted-fg-color; + } + + &.mx_UserMenu_dnd_noisy::before { + mask-image: url('$(res)/img/element-icons/notifications.svg'); + } + + &.mx_UserMenu_dnd_muted::before { + mask-image: url('$(res)/img/element-icons/roomlist/notifications-off.svg'); + } + } } &.mx_UserMenu_minimized { diff --git a/res/css/structures/_ViewSource.scss b/res/css/structures/_ViewSource.scss index 421d1f03cd..248eab5d88 100644 --- a/res/css/structures/_ViewSource.scss +++ b/res/css/structures/_ViewSource.scss @@ -14,17 +14,18 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_ViewSource_label_left { - float: left; -} - -.mx_ViewSource_label_right { - float: right; -} - -.mx_ViewSource_label_bottom { +.mx_ViewSource_separator { clear: both; border-bottom: 1px solid #e5e5e5; + padding-top: 0.7em; + padding-bottom: 0.7em; +} + +.mx_ViewSource_heading { + font-size: $font-17px; + font-weight: 400; + color: $primary-fg-color; + margin-top: 0.7em; } .mx_ViewSource pre { @@ -34,3 +35,7 @@ limitations under the License. word-wrap: break-word; white-space: pre-wrap; } + +.mx_ViewSource_details { + margin-top: 0.8em; +} diff --git a/res/css/structures/auth/_CompleteSecurity.scss b/res/css/structures/auth/_CompleteSecurity.scss index f742be70e4..80e7aaada0 100644 --- a/res/css/structures/auth/_CompleteSecurity.scss +++ b/res/css/structures/auth/_CompleteSecurity.scss @@ -26,50 +26,6 @@ limitations under the License. position: relative; } -.mx_CompleteSecurity_clients { - width: max-content; - margin: 36px auto 0; - - .mx_CompleteSecurity_clients_desktop, .mx_CompleteSecurity_clients_mobile { - position: relative; - width: 160px; - text-align: center; - padding-top: 64px; - display: inline-block; - - &::before { - content: ''; - position: absolute; - height: 48px; - width: 48px; - left: 56px; - top: 0; - background-color: $muted-fg-color; - mask-repeat: no-repeat; - mask-size: contain; - } - } - - .mx_CompleteSecurity_clients_desktop { - margin-right: 56px; - } - - .mx_CompleteSecurity_clients_desktop::before { - mask-image: url('$(res)/img/feather-customised/monitor.svg'); - } - - .mx_CompleteSecurity_clients_mobile::before { - mask-image: url('$(res)/img/feather-customised/smartphone.svg'); - } - - p { - margin-top: 16px; - font-size: $font-12px; - color: $muted-fg-color; - text-align: center; - } -} - .mx_CompleteSecurity_heroIcon { width: 128px; height: 128px; diff --git a/res/css/views/avatars/_DecoratedRoomAvatar.scss b/res/css/views/avatars/_DecoratedRoomAvatar.scss index e0afd9de66..2631cbfb40 100644 --- a/res/css/views/avatars/_DecoratedRoomAvatar.scss +++ b/res/css/views/avatars/_DecoratedRoomAvatar.scss @@ -14,8 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -// XXX: We shouldn't be using TemporaryTile anywhere - delete it. -.mx_DecoratedRoomAvatar, .mx_TemporaryTile { +.mx_DecoratedRoomAvatar, .mx_ExtraTile { position: relative; &.mx_DecoratedRoomAvatar_cutout .mx_BaseAvatar { diff --git a/res/css/views/dialogs/_AddExistingToSpaceDialog.scss b/res/css/views/dialogs/_AddExistingToSpaceDialog.scss index 0c9d8e3840..84f965e6bd 100644 --- a/res/css/views/dialogs/_AddExistingToSpaceDialog.scss +++ b/res/css/views/dialogs/_AddExistingToSpaceDialog.scss @@ -21,6 +21,66 @@ limitations under the License. } } +.mx_AddExistingToSpace { + .mx_SearchBox { + // To match the space around the title + margin: 0 0 15px 0; + flex-grow: 0; + } + + .mx_AddExistingToSpace_content { + flex-grow: 1; + } + + .mx_AddExistingToSpace_noResults { + display: block; + margin-top: 24px; + } + + .mx_AddExistingToSpace_section { + &:not(:first-child) { + margin-top: 24px; + } + + > h3 { + margin: 0; + color: $secondary-fg-color; + font-size: $font-12px; + font-weight: $font-semi-bold; + line-height: $font-15px; + } + + .mx_AddExistingToSpace_entry { + display: flex; + margin-top: 12px; + + .mx_BaseAvatar { + margin-right: 12px; + } + + .mx_AddExistingToSpace_entry_name { + font-size: $font-15px; + line-height: 30px; + flex-grow: 1; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + margin-right: 12px; + } + + .mx_Checkbox { + align-items: center; + } + } + } + + .mx_AddExistingToSpace_section_spaces { + .mx_BaseAvatar_image { + border-radius: 8px; + } + } +} + .mx_AddExistingToSpaceDialog { width: 480px; color: $primary-fg-color; @@ -28,22 +88,23 @@ limitations under the License. flex-direction: column; flex-wrap: nowrap; min-height: 0; + height: 80vh; .mx_Dialog_title { display: flex; - .mx_BaseAvatar { - display: inline-flex; - margin: 5px 16px 5px 5px; - vertical-align: middle; - } - .mx_BaseAvatar_image { border-radius: 8px; margin: 0; vertical-align: unset; } + .mx_BaseAvatar { + display: inline-flex; + margin: 5px 16px 5px 5px; + vertical-align: middle; + } + > div { > h1 { font-weight: $font-semi-bold; @@ -99,10 +160,6 @@ limitations under the License. } } - .mx_SearchBox { - margin: 0; - } - .mx_AddExistingToSpaceDialog_errorText { font-weight: $font-semi-bold; font-size: $font-12px; @@ -111,49 +168,8 @@ limitations under the License. margin-bottom: 28px; } - .mx_AddExistingToSpaceDialog_content { - .mx_AddExistingToSpaceDialog_noResults { - margin-top: 24px; - } - } - - .mx_AddExistingToSpaceDialog_section { - margin-top: 24px; - - > h3 { - margin: 0; - color: $secondary-fg-color; - font-size: $font-12px; - font-weight: $font-semi-bold; - line-height: $font-15px; - } - - .mx_AddExistingToSpaceDialog_entry { - display: flex; - margin-top: 12px; - - .mx_BaseAvatar { - margin-right: 12px; - } - - .mx_AddExistingToSpaceDialog_entry_name { - font-size: $font-15px; - line-height: 30px; - flex-grow: 1; - } - - .mx_FormButton { - min-width: 92px; - font-weight: normal; - box-sizing: border-box; - } - } - } - - .mx_AddExistingToSpaceDialog_section_spaces { - .mx_BaseAvatar_image { - border-radius: 8px; - } + .mx_AddExistingToSpace { + display: contents; } .mx_AddExistingToSpaceDialog_footer { @@ -162,8 +178,14 @@ limitations under the License. > span { flex-grow: 1; - font-size: $font-12px; + font-size: $font-14px; line-height: $font-15px; + font-weight: $font-semi-bold; + + .mx_AccessibleButton { + font-size: inherit; + display: inline-block; + } > * { vertical-align: middle; @@ -178,8 +200,4 @@ limitations under the License. padding: 0; } } - - .mx_FormButton { - padding: 8px 22px; - } } diff --git a/res/css/views/dialogs/_SpaceSettingsDialog.scss b/res/css/views/dialogs/_SpaceSettingsDialog.scss index c1fa539e9b..6e5fd9c8c8 100644 --- a/res/css/views/dialogs/_SpaceSettingsDialog.scss +++ b/res/css/views/dialogs/_SpaceSettingsDialog.scss @@ -49,7 +49,7 @@ limitations under the License. } } - .mx_FormButton { + .mx_AccessibleButton_hasKind { padding: 8px 22px; } } diff --git a/res/css/views/dialogs/security/_AccessSecretStorageDialog.scss b/res/css/views/dialogs/security/_AccessSecretStorageDialog.scss index 63d0ca555d..30b79c1a9a 100644 --- a/res/css/views/dialogs/security/_AccessSecretStorageDialog.scss +++ b/res/css/views/dialogs/security/_AccessSecretStorageDialog.scss @@ -1,6 +1,5 @@ /* -Copyright 2018 New Vector Ltd -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2018, 2019, 2021 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -15,6 +14,27 @@ See the License for the specific language governing permissions and limitations under the License. */ +.mx_AccessSecretStorageDialog_reset { + position: relative; + padding-left: 24px; // 16px icon + 8px padding + margin-top: 7px; // vertical alignment to buttons + + &::before { + content: ""; + display: inline-block; + position: absolute; + height: 16px; + width: 16px; + left: 0; + top: 2px; // alignment + background-image: url("$(res)/img/element-icons/warning-badge.svg"); + } + + .mx_AccessSecretStorageDialog_reset_link { + color: $warning-color; + } +} + .mx_AccessSecretStorageDialog_titleWithIcon::before { content: ''; display: inline-block; @@ -26,6 +46,13 @@ limitations under the License. background-color: $primary-fg-color; } +.mx_AccessSecretStorageDialog_resetBadge::before { + // The image isn't capable of masking, so we use a background instead. + background-image: url("$(res)/img/element-icons/warning-badge.svg"); + background-size: 24px; + background-color: transparent; +} + .mx_AccessSecretStorageDialog_secureBackupTitle::before { mask-image: url('$(res)/img/feather-customised/secure-backup.svg'); } diff --git a/res/css/views/elements/_AccessibleButton.scss b/res/css/views/elements/_AccessibleButton.scss index 9c26f8f120..0075dcb511 100644 --- a/res/css/views/elements/_AccessibleButton.scss +++ b/res/css/views/elements/_AccessibleButton.scss @@ -26,7 +26,9 @@ limitations under the License. padding: 7px 18px; text-align: center; border-radius: 8px; - display: inline-block; + display: inline-flex; + align-items: center; + justify-content: center; font-size: $font-14px; } diff --git a/res/css/views/elements/_FacePile.scss b/res/css/views/elements/_FacePile.scss new file mode 100644 index 0000000000..c691baffb5 --- /dev/null +++ b/res/css/views/elements/_FacePile.scss @@ -0,0 +1,65 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_FacePile { + .mx_FacePile_faces { + display: inline-flex; + flex-direction: row-reverse; + vertical-align: middle; + + > .mx_FacePile_face + .mx_FacePile_face { + margin-right: -8px; + } + + .mx_BaseAvatar_image { + border: 1px solid $primary-bg-color; + } + + .mx_BaseAvatar_initial { + margin: 1px; // to offset the border on the image + } + + .mx_FacePile_more { + position: relative; + border-radius: 100%; + width: 30px; + height: 30px; + background-color: $groupFilterPanel-bg-color; + + &::before { + content: ""; + z-index: 1; + position: absolute; + top: 0; + left: 0; + height: inherit; + width: inherit; + background: $tertiary-fg-color; + mask-position: center; + mask-size: 20px; + mask-repeat: no-repeat; + mask-image: url('$(res)/img/element-icons/room/ellipsis.svg'); + } + } + } + + .mx_FacePile_summary { + margin-left: 12px; + font-size: $font-14px; + line-height: $font-24px; + color: $tertiary-fg-color; + } +} diff --git a/res/css/views/elements/_FormButton.scss b/res/css/views/elements/_FormButton.scss index 7ec01f17e6..eda201ff03 100644 --- a/res/css/views/elements/_FormButton.scss +++ b/res/css/views/elements/_FormButton.scss @@ -33,4 +33,10 @@ limitations under the License. color: $notice-primary-color; background-color: $notice-primary-bg-color; } + + &.mx_AccessibleButton_kind_secondary { + color: $secondary-fg-color; + border: 1px solid $secondary-fg-color; + background-color: unset; + } } diff --git a/res/css/views/elements/_ImageView.scss b/res/css/views/elements/_ImageView.scss index 0a4ed2a194..71035dadc3 100644 --- a/res/css/views/elements/_ImageView.scss +++ b/res/css/views/elements/_ImageView.scss @@ -14,139 +14,107 @@ See the License for the specific language governing permissions and limitations under the License. */ -/* This has got to be the most fragile piece of CSS ever written. - But empirically it works on Chrome/FF/Safari - */ - .mx_ImageView { display: flex; width: 100%; height: 100%; - align-items: center; -} - -.mx_ImageView_lhs { - order: 1; - flex: 1 1 10%; - min-width: 60px; - // background-color: #080; - // height: 20px; -} - -.mx_ImageView_content { - order: 2; - /* min-width hack needed for FF */ - min-width: 0px; - height: 90%; - flex: 15 15 0; - display: flex; - align-items: center; - justify-content: center; -} - -.mx_ImageView_content img { - max-width: 100%; - /* XXX: max-height interacts badly with flex on Chrome and doesn't relayout properly until you refresh */ - max-height: 100%; - /* object-fit hack needed for Chrome due to Chrome not re-laying-out until you refresh */ - object-fit: contain; - /* background-image: url('$(res)/img/trans.png'); */ - pointer-events: all; -} - -.mx_ImageView_labelWrapper { - position: absolute; - top: 0px; - right: 0px; - height: 100%; - overflow: auto; - pointer-events: all; -} - -.mx_ImageView_label { - text-align: left; - display: flex; - justify-content: center; flex-direction: column; - padding-left: 30px; - padding-right: 30px; - min-height: 100%; - max-width: 240px; +} + +.mx_ImageView_image_wrapper { + display: flex; + justify-content: center; + align-items: center; + height: 100%; + overflow: hidden; +} + +.mx_ImageView_image { + pointer-events: all; + flex-shrink: 0; +} + +.mx_ImageView_panel { + width: 100%; + height: 68px; + display: flex; + justify-content: space-between; + align-items: center; +} + +.mx_ImageView_info_wrapper { + pointer-events: all; + padding-left: 32px; + display: flex; + flex-direction: row; + align-items: center; color: $lightbox-fg-color; } -.mx_ImageView_cancel { - position: absolute; - // hack for mx_Dialog having a top padding of 40px - top: 40px; - right: 0px; - padding-top: 35px; - padding-right: 35px; - cursor: pointer; +.mx_ImageView_info { + padding-left: 12px; + display: flex; + flex-direction: column; } -.mx_ImageView_rotateClockwise { - position: absolute; - top: 40px; - right: 70px; - padding-top: 35px; - cursor: pointer; +.mx_ImageView_info_sender { + font-weight: bold; } -.mx_ImageView_rotateCounterClockwise { - position: absolute; - top: 40px; - right: 105px; - padding-top: 35px; - cursor: pointer; -} - -.mx_ImageView_name { - font-size: $font-18px; - margin-bottom: 6px; - word-wrap: break-word; -} - -.mx_ImageView_metadata { - font-size: $font-15px; - opacity: 0.5; -} - -.mx_ImageView_download { - display: table; - margin-top: 24px; - margin-bottom: 6px; - border-radius: 5px; - background-color: $lightbox-bg-color; - font-size: $font-14px; - padding: 9px; - border: 1px solid $lightbox-border-color; -} - -.mx_ImageView_size { - font-size: $font-11px; -} - -.mx_ImageView_link { - color: $lightbox-fg-color !important; - text-decoration: none !important; +.mx_ImageView_toolbar { + padding-right: 16px; + pointer-events: all; + display: flex; + align-items: center; } .mx_ImageView_button { - font-size: $font-15px; - opacity: 0.5; - margin-top: 18px; - cursor: pointer; + margin-left: 24px; + display: block; + + &::before { + content: ''; + height: 22px; + width: 22px; + mask-repeat: no-repeat; + mask-size: contain; + mask-position: center; + display: block; + background-color: $icon-button-color; + } } -.mx_ImageView_shim { - height: 30px; +.mx_ImageView_button_rotateCW::before { + mask-image: url('$(res)/img/image-view/rotate-cw.svg'); } -.mx_ImageView_rhs { - order: 3; - flex: 1 1 10%; - min-width: 300px; - // background-color: #800; - // height: 20px; +.mx_ImageView_button_rotateCCW::before { + mask-image: url('$(res)/img/image-view/rotate-ccw.svg'); +} + +.mx_ImageView_button_zoomOut::before { + mask-image: url('$(res)/img/image-view/zoom-out.svg'); +} + +.mx_ImageView_button_zoomIn::before { + mask-image: url('$(res)/img/image-view/zoom-in.svg'); +} + +.mx_ImageView_button_download::before { + mask-image: url('$(res)/img/image-view/download.svg'); +} + +.mx_ImageView_button_more::before { + mask-image: url('$(res)/img/image-view/more.svg'); +} + +.mx_ImageView_button_close { + border-radius: 100%; + background: #21262c; // same on all themes + &::before { + width: 32px; + height: 32px; + mask-image: url('$(res)/img/image-view/close.svg'); + mask-size: 40%; + } } diff --git a/res/css/views/elements/_InviteReason.scss b/res/css/views/elements/_InviteReason.scss new file mode 100644 index 0000000000..2c2e5687e6 --- /dev/null +++ b/res/css/views/elements/_InviteReason.scss @@ -0,0 +1,57 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_InviteReason { + position: relative; + margin-bottom: 1em; + + .mx_InviteReason_reason { + visibility: visible; + } + + .mx_InviteReason_view { + display: none; + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + justify-content: center; + align-items: center; + cursor: pointer; + color: $secondary-fg-color; + + &::before { + content: ""; + margin-right: 8px; + background-color: $secondary-fg-color; + mask-image: url('$(res)/img/feather-customised/eye.svg'); + display: inline-block; + width: 18px; + height: 14px; + } + } +} + +.mx_InviteReason_hidden { + .mx_InviteReason_reason { + visibility: hidden; + } + + .mx_InviteReason_view { + display: flex; + } +} diff --git a/res/css/views/elements/_ProgressBar.scss b/res/css/views/elements/_ProgressBar.scss index e49d85af04..770978e921 100644 --- a/res/css/views/elements/_ProgressBar.scss +++ b/res/css/views/elements/_ProgressBar.scss @@ -1,5 +1,5 @@ /* -Copyright 2020 The Matrix.org Foundation C.I.C. +Copyright 2020, 2021 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -15,15 +15,15 @@ limitations under the License. */ progress.mx_ProgressBar { - height: 4px; + height: 6px; width: 60px; - border-radius: 10px; overflow: hidden; appearance: none; - border: 0; + border: none; - @mixin ProgressBarBorderRadius "10px"; - @mixin ProgressBarColour $accent-color; + @mixin ProgressBarBorderRadius "6px"; + @mixin ProgressBarColour $progressbar-fg-color; + @mixin ProgressBarBgColour $progressbar-bg-color; ::-webkit-progress-value { transition: width 1s; } diff --git a/res/css/views/messages/_MFileBody.scss b/res/css/views/messages/_MFileBody.scss index 6cbce68745..b45126acf8 100644 --- a/res/css/views/messages/_MFileBody.scss +++ b/res/css/views/messages/_MFileBody.scss @@ -1,5 +1,5 @@ /* -Copyright 2015, 2016 OpenMarket Ltd +Copyright 2015, 2016, 2021 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -16,6 +16,19 @@ limitations under the License. .mx_MFileBody_download { color: $accent-color; + + .mx_MFileBody_download_icon { + // 12px instead of 14px to better match surrounding font size + width: 12px; + height: 12px; + mask-size: 12px; + + mask-position: center; + mask-repeat: no-repeat; + mask-image: url("$(res)/img/download.svg"); + background-color: $accent-color; + display: inline-block; + } } .mx_MFileBody_download a { @@ -45,3 +58,46 @@ limitations under the License. * big the content of the iframe is. */ height: 1.5em; } + +.mx_MFileBody_info { + background-color: $message-body-panel-bg-color; + border-radius: 4px; + width: 270px; + padding: 8px; + color: $message-body-panel-fg-color; + + .mx_MFileBody_info_icon { + background-color: $message-body-panel-icon-bg-color; + border-radius: 20px; + display: inline-block; + width: 32px; + height: 32px; + position: relative; + vertical-align: middle; + margin-right: 12px; + + &::before { + content: ''; + mask-repeat: no-repeat; + mask-position: center; + mask-size: cover; + mask-image: url('$(res)/img/element-icons/room/composer/attach.svg'); + background-color: $message-body-panel-fg-color; + width: 13px; + height: 15px; + + position: absolute; + top: 8px; + left: 9px; + } + } + + .mx_MFileBody_info_filename { + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + display: inline-block; + width: calc(100% - 32px - 12px); // 32px icon, 12px margin on the icon + vertical-align: middle; + } +} diff --git a/res/css/views/messages/_MessageActionBar.scss b/res/css/views/messages/_MessageActionBar.scss index 1254b496b5..3ecbef0d1f 100644 --- a/res/css/views/messages/_MessageActionBar.scss +++ b/res/css/views/messages/_MessageActionBar.scss @@ -105,3 +105,11 @@ limitations under the License. .mx_MessageActionBar_optionsButton::after { mask-image: url('$(res)/img/element-icons/context-menu.svg'); } + +.mx_MessageActionBar_resendButton::after { + mask-image: url('$(res)/img/element-icons/retry.svg'); +} + +.mx_MessageActionBar_cancelButton::after { + mask-image: url('$(res)/img/element-icons/trashcan.svg'); +} diff --git a/res/css/views/right_panel/_EncryptionInfo.scss b/res/css/views/right_panel/_EncryptionInfo.scss index e13b1b6802..b3d4275f60 100644 --- a/res/css/views/right_panel/_EncryptionInfo.scss +++ b/res/css/views/right_panel/_EncryptionInfo.scss @@ -14,13 +14,11 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_UserInfo { - .mx_EncryptionInfo_spinner { - .mx_Spinner { - margin-top: 25px; - margin-bottom: 15px; - } - - text-align: center; +.mx_EncryptionInfo_spinner { + .mx_Spinner { + margin-top: 25px; + margin-bottom: 15px; } + + text-align: center; } diff --git a/res/css/views/rooms/_AppsDrawer.scss b/res/css/views/rooms/_AppsDrawer.scss index 492ed95973..fd80836237 100644 --- a/res/css/views/rooms/_AppsDrawer.scss +++ b/res/css/views/rooms/_AppsDrawer.scss @@ -370,11 +370,6 @@ $MinWidth: 240px; display: none; } -/* Avoid apptile iframes capturing mouse event focus when resizing */ -.mx_AppsDrawer_resizing iframe { - pointer-events: none; -} - .mx_AppsDrawer_resizing .mx_AppTile_persistedWrapper { z-index: 1; } diff --git a/res/css/views/rooms/_BasicMessageComposer.scss b/res/css/views/rooms/_BasicMessageComposer.scss index e126e523a6..e1ba468204 100644 --- a/res/css/views/rooms/_BasicMessageComposer.scss +++ b/res/css/views/rooms/_BasicMessageComposer.scss @@ -66,6 +66,11 @@ limitations under the License. } } } + + &.mx_BasicMessageComposer_input_disabled { + // Ignore all user input to avoid accidentally triggering the composer + pointer-events: none; + } } .mx_BasicMessageComposer_AutoCompleteWrapper { diff --git a/res/css/views/rooms/_EventTile.scss b/res/css/views/rooms/_EventTile.scss index 5841cf2853..5d1dd04383 100644 --- a/res/css/views/rooms/_EventTile.scss +++ b/res/css/views/rooms/_EventTile.scss @@ -159,6 +159,7 @@ $left-gutter: 64px; .mx_EventTile.focus-visible:focus-within > div > a > .mx_MessageTimestamp, .mx_IRCLayout .mx_EventTile_last > a > .mx_MessageTimestamp, .mx_IRCLayout .mx_EventTile:hover > a > .mx_MessageTimestamp, +.mx_IRCLayout .mx_ReplyThread .mx_EventTile > a > .mx_MessageTimestamp, .mx_IRCLayout .mx_EventTile.mx_EventTile_actionBarFocused > a > .mx_MessageTimestamp, .mx_IRCLayout .mx_EventTile.focus-visible:focus-within > a > .mx_MessageTimestamp { visibility: visible; @@ -213,21 +214,30 @@ $left-gutter: 64px; color: $accent-fg-color; } -.mx_EventTile_encrypting { - color: $event-encrypting-color !important; -} +.mx_EventTile_receiptSent, +.mx_EventTile_receiptSending { + // We don't use `position: relative` on the element because then it won't line + // up with the other read receipts -.mx_EventTile_sending { - color: $event-sending-color; + &::before { + background-color: $tertiary-fg-color; + mask-repeat: no-repeat; + mask-position: center; + mask-size: 14px; + width: 14px; + height: 14px; + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + } } - -.mx_EventTile_sending .mx_UserPill, -.mx_EventTile_sending .mx_RoomPill { - opacity: 0.5; +.mx_EventTile_receiptSent::before { + mask-image: url('$(res)/img/element-icons/circle-sent.svg'); } - -.mx_EventTile_notSent { - color: $event-notsent-color; +.mx_EventTile_receiptSending::before { + mask-image: url('$(res)/img/element-icons/circle-sending.svg'); } .mx_EventTile_contextual { @@ -269,6 +279,10 @@ $left-gutter: 64px; display: inline-block; height: $font-14px; width: $font-14px; + + transition: + left var(--transition-short) ease-out, + top var(--transition-standard) ease-out; } .mx_EventTile_readAvatarRemainder { diff --git a/res/css/views/rooms/_GroupLayout.scss b/res/css/views/rooms/_GroupLayout.scss index 903fabc8fd..818509785b 100644 --- a/res/css/views/rooms/_GroupLayout.scss +++ b/res/css/views/rooms/_GroupLayout.scss @@ -21,7 +21,7 @@ $left-gutter: 64px; .mx_EventTile { > .mx_SenderProfile { line-height: $font-20px; - padding-left: $left-gutter; + margin-left: $left-gutter; } > .mx_EventTile_line { diff --git a/res/css/views/rooms/_IRCLayout.scss b/res/css/views/rooms/_IRCLayout.scss index 792c2f1f58..b6b901757c 100644 --- a/res/css/views/rooms/_IRCLayout.scss +++ b/res/css/views/rooms/_IRCLayout.scss @@ -181,8 +181,7 @@ $irc-line-height: $font-18px; > span { display: flex; - > .mx_SenderProfile_name, - > .mx_SenderProfile_aux { + > .mx_SenderProfile_name { overflow: hidden; text-overflow: ellipsis; min-width: var(--name-width); @@ -212,12 +211,30 @@ $irc-line-height: $font-18px; background: transparent; > span { - > .mx_SenderProfile_name, - > .mx_SenderProfile_aux { + > .mx_SenderProfile_name { min-width: inherit; } } } + + .mx_EventTile_emote { + > .mx_EventTile_avatar { + margin-left: initial; + } + } + + .mx_MessageTimestamp { + width: initial; + } + + /** + * adding the icon back in the document flow + * if it's not present, there's no unwanted wasted space + */ + .mx_EventTile_e2eIcon { + position: relative; + order: -1; + } } .mx_ProfileResizer { diff --git a/res/css/views/rooms/_MessageComposer.scss b/res/css/views/rooms/_MessageComposer.scss index dea1b58741..e6c0cc3f46 100644 --- a/res/css/views/rooms/_MessageComposer.scss +++ b/res/css/views/rooms/_MessageComposer.scss @@ -227,6 +227,10 @@ limitations under the License. mask-image: url('$(res)/img/element-icons/room/composer/attach.svg'); } +.mx_MessageComposer_voiceMessage::before { + mask-image: url('$(res)/img/voip/mic-on-mask.svg'); +} + .mx_MessageComposer_emoji::before { mask-image: url('$(res)/img/element-icons/room/composer/emoji.svg'); } diff --git a/res/css/views/rooms/_MessageComposerFormatBar.scss b/res/css/views/rooms/_MessageComposerFormatBar.scss index d97c49630a..b305e91db0 100644 --- a/res/css/views/rooms/_MessageComposerFormatBar.scss +++ b/res/css/views/rooms/_MessageComposerFormatBar.scss @@ -60,6 +60,8 @@ limitations under the License. width: 27px; height: 24px; box-sizing: border-box; + background: none; + vertical-align: middle; } .mx_MessageComposerFormatBar_button::after { diff --git a/res/css/views/rooms/_NewRoomIntro.scss b/res/css/views/rooms/_NewRoomIntro.scss index 4322ba341c..9c2a428cb3 100644 --- a/res/css/views/rooms/_NewRoomIntro.scss +++ b/res/css/views/rooms/_NewRoomIntro.scss @@ -33,8 +33,13 @@ limitations under the License. .mx_AccessibleButton { line-height: $font-24px; + display: inline-block; - &::before { + & + .mx_AccessibleButton { + margin-left: 12px; + } + + &:not(.mx_AccessibleButton_kind_primary_outline)::before { content: ''; display: inline-block; background-color: $button-fg-color; diff --git a/res/css/views/rooms/_RoomList.scss b/res/css/views/rooms/_RoomList.scss index d49ed4b736..8eda25d0c9 100644 --- a/res/css/views/rooms/_RoomList.scss +++ b/res/css/views/rooms/_RoomList.scss @@ -27,6 +27,9 @@ limitations under the License. .mx_RoomList_iconExplore::before { mask-image: url('$(res)/img/element-icons/roomlist/explore.svg'); } +.mx_RoomList_iconBrowse::before { + mask-image: url('$(res)/img/element-icons/roomlist/browse.svg'); +} .mx_RoomList_iconDialpad::before { mask-image: url('$(res)/img/element-icons/roomlist/dialpad.svg'); } @@ -34,29 +37,33 @@ limitations under the License. .mx_RoomList_explorePrompt { margin: 4px 12px 4px; padding-top: 12px; - border-top: 1px solid $tertiary-fg-color; - font-size: $font-13px; + border-top: 1px solid $input-border-color; + font-size: $font-14px; div:first-child { font-weight: $font-semi-bold; + line-height: $font-18px; + color: $primary-fg-color; } .mx_AccessibleButton { - color: $secondary-fg-color; + color: $primary-fg-color; position: relative; - padding: 0 0 0 24px; + padding: 8px 8px 8px 32px; font-size: inherit; - margin-top: 8px; + margin-top: 12px; display: block; text-align: start; + background-color: $roomlist-button-bg-color; + border-radius: 4px; &::before { content: ''; width: 16px; height: 16px; position: absolute; - top: 0; - left: 0; + top: 8px; + left: 8px; background: $secondary-fg-color; mask-position: center; mask-size: contain; @@ -70,5 +77,13 @@ limitations under the License. &.mx_RoomList_explorePrompt_explore::before { mask-image: url('$(res)/img/element-icons/roomlist/explore.svg'); } + + &.mx_RoomList_explorePrompt_spaceInvite::before { + mask-image: url('$(res)/img/element-icons/room/invite.svg'); + } + + &.mx_RoomList_explorePrompt_spaceExplore::before { + mask-image: url('$(res)/img/element-icons/roomlist/browse.svg'); + } } } diff --git a/res/css/views/rooms/_RoomSublist.scss b/res/css/views/rooms/_RoomSublist.scss index 92a475694e..9d52e40819 100644 --- a/res/css/views/rooms/_RoomSublist.scss +++ b/res/css/views/rooms/_RoomSublist.scss @@ -18,6 +18,10 @@ limitations under the License. margin-left: 8px; margin-bottom: 4px; + &.mx_RoomSublist_hidden { + display: none; + } + .mx_RoomSublist_headerContainer { // Create a flexbox to make alignment easy display: flex; @@ -37,7 +41,9 @@ limitations under the License. // The combined height must be set in the LeftPanel component for sticky headers // to work correctly. padding-bottom: 8px; - height: 24px; + // Allow the container to collapse on itself if its children + // are not in the normal document flow + max-height: 24px; color: $roomlist-header-color; .mx_RoomSublist_stickable { diff --git a/res/css/views/rooms/_RoomTile.scss b/res/css/views/rooms/_RoomTile.scss index 8eca3f1efa..72d29dfd4c 100644 --- a/res/css/views/rooms/_RoomTile.scss +++ b/res/css/views/rooms/_RoomTile.scss @@ -189,6 +189,10 @@ limitations under the License. mask-image: url('$(res)/img/element-icons/settings.svg'); } + .mx_RoomTile_iconInvite::before { + mask-image: url('$(res)/img/element-icons/room/invite.svg'); + } + .mx_RoomTile_iconSignOut::before { mask-image: url('$(res)/img/element-icons/leave.svg'); } diff --git a/res/css/views/rooms/_VoiceRecordComposerTile.scss b/res/css/views/rooms/_VoiceRecordComposerTile.scss new file mode 100644 index 0000000000..e99e1a00e1 --- /dev/null +++ b/res/css/views/rooms/_VoiceRecordComposerTile.scss @@ -0,0 +1,96 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_VoiceRecordComposerTile_stop { + // 28px plus a 2px border makes this a 32px square (as intended) + width: 28px; + height: 28px; + border: 2px solid $voice-record-stop-border-color; + border-radius: 32px; + margin-right: 16px; // between us and the send button + position: relative; + + &::after { + content: ''; + width: 14px; + height: 14px; + position: absolute; + top: 7px; + left: 7px; + border-radius: 2px; + background-color: $voice-record-stop-symbol-color; + } +} + +.mx_VoiceRecordComposerTile_delete { + width: 14px; // w&h are size of icon + height: 18px; + vertical-align: middle; + margin-right: 7px; // distance from left edge of waveform container (container has some margin too) + background-color: $muted-fg-color; + mask-repeat: no-repeat; + mask-size: contain; + mask-image: url('$(res)/img/element-icons/trashcan.svg'); +} + +.mx_VoiceMessagePrimaryContainer { + // Note: remaining class properties are in the PlayerContainer CSS. + + margin: 6px; // force the composer area to put a gutter around us + margin-right: 12px; // isolate from stop button + + position: relative; // important for the live circle + + &.mx_VoiceRecordComposerTile_recording { + padding-left: 16px; // +10px for the live circle, +6px for regular padding + + &::before { + animation: recording-pulse 2s infinite; + + content: ''; + background-color: $voice-record-live-circle-color; + width: 10px; + height: 10px; + position: absolute; + left: 8px; + top: 16px; // vertically center + border-radius: 10px; + } + } +} + +// The keyframes are slightly weird here to help make a ramping/punch effect +// for the recording dot. We start and end at 100% opacity to help make the +// dot feel a bit like a real lamp that is blinking: the animation ends up +// spending a lot of its time showing a steady state without a fade effect. +// This lamp effect extends into why the 0% opacity keyframe is not in the +// midpoint: lamps take longer to turn off than they do to turn on, and the +// extra frames give it a bit of a realistic punch for when the animation is +// ramping back up to 100% opacity. +// +// Target animation timings: steady for 1.5s, fade out for 0.3s, fade in for 0.2s +// (intended to be used in a loop for 2s animation speed) +@keyframes recording-pulse { + 0% { + opacity: 1; + } + 35% { + opacity: 0; + } + 65% { + opacity: 1; + } +} diff --git a/res/css/views/spaces/_SpaceCreateMenu.scss b/res/css/views/spaces/_SpaceCreateMenu.scss index 2a11ec9f23..ef3fea351b 100644 --- a/res/css/views/spaces/_SpaceCreateMenu.scss +++ b/res/css/views/spaces/_SpaceCreateMenu.scss @@ -14,10 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -// TODO: the space panel currently does not have a fixed width, -// just the headers at each level have a max-width of 150px -// so this will look slightly off for now. We should probably use css grid for the whole main layout... -$spacePanelWidth: 200px; +$spacePanelWidth: 71px; .mx_SpaceCreateMenu_wrapper { // background blur everything except SpacePanel @@ -48,53 +45,11 @@ $spacePanelWidth: 200px; } .mx_SpaceCreateMenuType { - position: relative; - padding: 16px 32px 16px 72px; - width: 432px; - box-sizing: border-box; - border-radius: 8px; - border: 1px solid $input-darker-bg-color; - font-size: $font-15px; - margin: 20px 0; - - > h3 { - font-weight: $font-semi-bold; - margin: 0 0 4px; - } - - > span { - color: $secondary-fg-color; - } - - &::before { - position: absolute; - content: ''; - width: 32px; - height: 32px; - top: 24px; - left: 20px; - mask-position: center; - mask-repeat: no-repeat; - mask-size: 32px; - background-color: $tertiary-fg-color; - } - - &:hover { - border-color: $accent-color; - - &::before { - background-color: $accent-color; - } - - > span { - color: $primary-fg-color; - } - } + @mixin SpacePillButton; } .mx_SpaceCreateMenuType_public::before { mask-image: url('$(res)/img/globe.svg'); - mask-size: 26px; } .mx_SpaceCreateMenuType_private::before { mask-image: url('$(res)/img/element-icons/lock.svg'); @@ -124,7 +79,7 @@ $spacePanelWidth: 200px; } } - .mx_FormButton { + .mx_AccessibleButton_kind_primary { padding: 8px 22px; margin-left: auto; display: block; diff --git a/res/css/views/spaces/_SpacePublicShare.scss b/res/css/views/spaces/_SpacePublicShare.scss index 9ba0549ae3..373fa94e00 100644 --- a/res/css/views/spaces/_SpacePublicShare.scss +++ b/res/css/views/spaces/_SpacePublicShare.scss @@ -16,38 +16,7 @@ limitations under the License. .mx_SpacePublicShare { .mx_AccessibleButton { - border: 1px solid $space-button-outline-color; - box-sizing: border-box; - border-radius: 8px; - padding: 12px 24px 12px 52px; - margin-top: 16px; - width: $SpaceRoomViewInnerWidth; - font-size: $font-15px; - line-height: $font-24px; - position: relative; - display: flex; - - > span { - color: #368bd6; - margin-left: auto; - } - - &:hover { - background-color: rgba(141, 151, 165, 0.1); - } - - &::before { - content: ""; - position: absolute; - width: 30px; - height: 30px; - mask-repeat: no-repeat; - mask-size: contain; - mask-position: center; - background: $muted-fg-color; - left: 12px; - top: 9px; - } + @mixin SpacePillButton; &.mx_SpacePublicShare_shareButton::before { mask-image: url('$(res)/img/element-icons/link.svg'); diff --git a/res/css/views/voice_messages/_PlayPauseButton.scss b/res/css/views/voice_messages/_PlayPauseButton.scss new file mode 100644 index 0000000000..c8ab162694 --- /dev/null +++ b/res/css/views/voice_messages/_PlayPauseButton.scss @@ -0,0 +1,51 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_PlayPauseButton { + position: relative; + width: 32px; + height: 32px; + border-radius: 32px; + background-color: $primary-bg-color; + + &::before { + content: ''; + position: absolute; // sizing varies by icon + background-color: $muted-fg-color; + mask-repeat: no-repeat; + mask-size: contain; + } + + &.mx_PlayPauseButton_disabled::before { + opacity: 0.5; + } + + &.mx_PlayPauseButton_play::before { + width: 13px; + height: 16px; + top: 8px; // center + left: 12px; // center + mask-image: url('$(res)/img/element-icons/play.svg'); + } + + &.mx_PlayPauseButton_pause::before { + width: 10px; + height: 12px; + top: 10px; // center + left: 11px; // center + mask-image: url('$(res)/img/element-icons/pause.svg'); + } +} diff --git a/res/css/views/voice_messages/_PlaybackContainer.scss b/res/css/views/voice_messages/_PlaybackContainer.scss new file mode 100644 index 0000000000..49bd81ef81 --- /dev/null +++ b/res/css/views/voice_messages/_PlaybackContainer.scss @@ -0,0 +1,54 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Dev note: there's no actual component called . These classes +// are shared amongst multiple voice message components. + +// Container for live recording and playback controls +.mx_VoiceMessagePrimaryContainer { + padding: 6px; // makes us 4px taller than the send/stop button + padding-right: 5px; // there's 1px from the waveform itself, so account for that + background-color: $voice-record-waveform-bg-color; + border-radius: 12px; + + // Cheat at alignment a bit + display: flex; + align-items: center; + + color: $voice-record-waveform-fg-color; + font-size: $font-14px; + + .mx_Waveform { + // We want the bars to be 2px shorter than the play/pause button in the waveform control + height: 28px; // default is 30px, so we're subtracting the 2px border off the bars + + .mx_Waveform_bar { + background-color: $voice-record-waveform-incomplete-fg-color; + + &.mx_Waveform_bar_100pct { + // Small animation to remove the mechanical feel of progress + transition: background-color 250ms ease; + background-color: $voice-record-waveform-fg-color; + } + } + } + + .mx_Clock { + padding-right: 4px; // isolate from waveform + padding-left: 8px; // isolate from live circle + width: 40px; // we're not using a monospace font, so fake it + } +} diff --git a/res/css/views/voice_messages/_Waveform.scss b/res/css/views/voice_messages/_Waveform.scss new file mode 100644 index 0000000000..cf03c84601 --- /dev/null +++ b/res/css/views/voice_messages/_Waveform.scss @@ -0,0 +1,40 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_Waveform { + position: relative; + height: 30px; // tallest bar can only be 30px + top: 1px; // because of our border trick (see below), we're off by 1px of aligntment + + display: flex; + align-items: center; // so the bars grow from the middle + + overflow: hidden; // this is cheaper than a `max-height: calc(100% - 4px)` in the bar's CSS. + + // A bar is meant to be a 2x2 circle when at zero height, and otherwise a 2px wide line + // with rounded caps. + .mx_Waveform_bar { + width: 0; // 0px width means we'll end up using the border as our width + border: 1px solid transparent; // transparent means we'll use the background colour + border-radius: 2px; // rounded end caps, based on the border + min-height: 0; // like the width, we'll rely on the border to give us height + max-height: 100%; // this makes the `height: 42%` work on the element + margin-left: 1px; // we want 2px between each bar, so 1px on either side for balance + margin-right: 1px; + + // background color is handled by the parent components + } +} diff --git a/res/css/views/voip/_CallView.scss b/res/css/views/voip/_CallView.scss index 7eb329594a..7292e325df 100644 --- a/res/css/views/voip/_CallView.scss +++ b/res/css/views/voip/_CallView.scss @@ -17,7 +17,7 @@ limitations under the License. .mx_CallView { border-radius: 8px; - background-color: $voipcall-plinth-color; + background-color: $dark-panel-bg-color; padding-left: 8px; padding-right: 8px; // XXX: CallContainer sets pointer-events: none - should probably be set back in a better place @@ -27,9 +27,12 @@ limitations under the License. .mx_CallView_large { padding-bottom: 10px; margin: 5px 5px 5px 18px; + display: flex; + flex-direction: column; + flex: 1; .mx_CallView_voice { - height: 360px; + flex: 1; } } @@ -37,7 +40,8 @@ limitations under the License. width: 320px; padding-bottom: 8px; margin-top: 10px; - box-shadow: 0px 14px 24px rgba(0, 0, 0, 0.08); + background-color: $voipcall-plinth-color; + box-shadow: 0px 4px 20px rgba(0, 0, 0, 0.20); border-radius: 8px; .mx_CallView_voice { @@ -55,7 +59,7 @@ limitations under the License. } } - .mx_CallView_voice_holdText { + .mx_CallView_holdTransferContent { padding-top: 10px; padding-bottom: 25px; } @@ -82,7 +86,7 @@ limitations under the License. } } -.mx_CallView_voice_hold { +.mx_CallView_voice .mx_CallView_holdTransferContent { // This masks the avatar image so when it's blurred, the edge is still crisp .mx_CallView_voice_avatarContainer { border-radius: 2000px; @@ -91,7 +95,7 @@ limitations under the License. } } -.mx_CallView_voice_holdText { +.mx_CallView_holdTransferContent { height: 20px; padding-top: 20px; padding-bottom: 15px; @@ -104,6 +108,7 @@ limitations under the License. .mx_CallView_video { width: 100%; + height: 100%; position: relative; z-index: 30; border-radius: 8px; @@ -142,7 +147,7 @@ limitations under the License. } } -.mx_CallView_video_holdContent { +.mx_CallView_video .mx_CallView_holdTransferContent { position: absolute; top: 50%; left: 50%; @@ -177,6 +182,7 @@ limitations under the License. flex-direction: row; align-items: center; justify-content: left; + flex-shrink: 0; } .mx_CallView_header_callType { diff --git a/res/css/views/voip/_CallViewForRoom.scss b/res/css/views/voip/_CallViewForRoom.scss new file mode 100644 index 0000000000..769e00338e --- /dev/null +++ b/res/css/views/voip/_CallViewForRoom.scss @@ -0,0 +1,46 @@ +/* +Copyright 2021 Šimon Brandner + +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_CallViewForRoom { + overflow: hidden; + + .mx_CallViewForRoom_ResizeWrapper { + display: flex; + margin-bottom: 8px; + + &:hover .mx_CallViewForRoom_ResizeHandle { + // Need to use important to override element style attributes + // set by re-resizable + width: 100% !important; + + display: flex; + justify-content: center; + + &::after { + content: ''; + margin-top: 3px; + + border-radius: 4px; + + height: 4px; + width: 100%; + max-width: 64px; + + background-color: $primary-fg-color; + } + } + } +} diff --git a/res/css/views/voip/_VideoFeed.scss b/res/css/views/voip/_VideoFeed.scss index 3e473a80b2..8ead8bba3e 100644 --- a/res/css/views/voip/_VideoFeed.scss +++ b/res/css/views/voip/_VideoFeed.scss @@ -16,7 +16,7 @@ limitations under the License. .mx_VideoFeed_remote { width: 100%; - max-height: 100%; + height: 100%; background-color: #000; z-index: 50; } diff --git a/res/fonts/Inter/Inter-Bold.woff b/res/fonts/Inter/Inter-Bold.woff index 61e1c25e64..2ec7ac3d21 100644 Binary files a/res/fonts/Inter/Inter-Bold.woff and b/res/fonts/Inter/Inter-Bold.woff differ diff --git a/res/fonts/Inter/Inter-Bold.woff2 b/res/fonts/Inter/Inter-Bold.woff2 index 6c401bb09b..6989c99229 100644 Binary files a/res/fonts/Inter/Inter-Bold.woff2 and b/res/fonts/Inter/Inter-Bold.woff2 differ diff --git a/res/fonts/Inter/Inter-BoldItalic.woff b/res/fonts/Inter/Inter-BoldItalic.woff index 2de403edd1..aa35b79745 100644 Binary files a/res/fonts/Inter/Inter-BoldItalic.woff and b/res/fonts/Inter/Inter-BoldItalic.woff differ diff --git a/res/fonts/Inter/Inter-BoldItalic.woff2 b/res/fonts/Inter/Inter-BoldItalic.woff2 index 80efd4848d..18b4c1ce5e 100644 Binary files a/res/fonts/Inter/Inter-BoldItalic.woff2 and b/res/fonts/Inter/Inter-BoldItalic.woff2 differ diff --git a/res/fonts/Inter/Inter-Italic.woff b/res/fonts/Inter/Inter-Italic.woff index e7da6663fe..4b765bd592 100644 Binary files a/res/fonts/Inter/Inter-Italic.woff and b/res/fonts/Inter/Inter-Italic.woff differ diff --git a/res/fonts/Inter/Inter-Italic.woff2 b/res/fonts/Inter/Inter-Italic.woff2 index 8559dfde38..bd5f255a98 100644 Binary files a/res/fonts/Inter/Inter-Italic.woff2 and b/res/fonts/Inter/Inter-Italic.woff2 differ diff --git a/res/fonts/Inter/Inter-Medium.woff b/res/fonts/Inter/Inter-Medium.woff index 8c36a6345e..7d55f34cca 100644 Binary files a/res/fonts/Inter/Inter-Medium.woff and b/res/fonts/Inter/Inter-Medium.woff differ diff --git a/res/fonts/Inter/Inter-Medium.woff2 b/res/fonts/Inter/Inter-Medium.woff2 index 3b31d3350a..a916b47fc8 100644 Binary files a/res/fonts/Inter/Inter-Medium.woff2 and b/res/fonts/Inter/Inter-Medium.woff2 differ diff --git a/res/fonts/Inter/Inter-MediumItalic.woff b/res/fonts/Inter/Inter-MediumItalic.woff index fb79e91ff4..422ab0576a 100644 Binary files a/res/fonts/Inter/Inter-MediumItalic.woff and b/res/fonts/Inter/Inter-MediumItalic.woff differ diff --git a/res/fonts/Inter/Inter-MediumItalic.woff2 b/res/fonts/Inter/Inter-MediumItalic.woff2 index d32c111f9c..f623924aea 100644 Binary files a/res/fonts/Inter/Inter-MediumItalic.woff2 and b/res/fonts/Inter/Inter-MediumItalic.woff2 differ diff --git a/res/fonts/Inter/Inter-Regular.woff b/res/fonts/Inter/Inter-Regular.woff index 7d587c40bf..7ff51b7d8f 100644 Binary files a/res/fonts/Inter/Inter-Regular.woff and b/res/fonts/Inter/Inter-Regular.woff differ diff --git a/res/fonts/Inter/Inter-Regular.woff2 b/res/fonts/Inter/Inter-Regular.woff2 index d5ffd2a1f1..554aed6612 100644 Binary files a/res/fonts/Inter/Inter-Regular.woff2 and b/res/fonts/Inter/Inter-Regular.woff2 differ diff --git a/res/fonts/Inter/Inter-SemiBold.woff b/res/fonts/Inter/Inter-SemiBold.woff index 99df06cbee..76e507a515 100644 Binary files a/res/fonts/Inter/Inter-SemiBold.woff and b/res/fonts/Inter/Inter-SemiBold.woff differ diff --git a/res/fonts/Inter/Inter-SemiBold.woff2 b/res/fonts/Inter/Inter-SemiBold.woff2 index df746af999..9307998993 100644 Binary files a/res/fonts/Inter/Inter-SemiBold.woff2 and b/res/fonts/Inter/Inter-SemiBold.woff2 differ diff --git a/res/fonts/Inter/Inter-SemiBoldItalic.woff b/res/fonts/Inter/Inter-SemiBoldItalic.woff index 91e192b9f1..382181212d 100644 Binary files a/res/fonts/Inter/Inter-SemiBoldItalic.woff and b/res/fonts/Inter/Inter-SemiBoldItalic.woff differ diff --git a/res/fonts/Inter/Inter-SemiBoldItalic.woff2 b/res/fonts/Inter/Inter-SemiBoldItalic.woff2 index ff8774ccb4..f19f5505ec 100644 Binary files a/res/fonts/Inter/Inter-SemiBoldItalic.woff2 and b/res/fonts/Inter/Inter-SemiBoldItalic.woff2 differ diff --git a/res/img/cancel-white.svg b/res/img/cancel-white.svg deleted file mode 100644 index 65e14c2fbc..0000000000 --- a/res/img/cancel-white.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - Slice 1 - Created with Sketch. - - - - - \ No newline at end of file diff --git a/res/img/element-icons/circle-sending.svg b/res/img/element-icons/circle-sending.svg new file mode 100644 index 0000000000..2d15a0f716 --- /dev/null +++ b/res/img/element-icons/circle-sending.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/img/element-icons/circle-sent.svg b/res/img/element-icons/circle-sent.svg new file mode 100644 index 0000000000..04a00ceff7 --- /dev/null +++ b/res/img/element-icons/circle-sent.svg @@ -0,0 +1,4 @@ + + + + diff --git a/res/img/element-icons/pause.svg b/res/img/element-icons/pause.svg new file mode 100644 index 0000000000..293c0a10d8 --- /dev/null +++ b/res/img/element-icons/pause.svg @@ -0,0 +1,4 @@ + + + + diff --git a/res/img/element-icons/play.svg b/res/img/element-icons/play.svg new file mode 100644 index 0000000000..339e20b729 --- /dev/null +++ b/res/img/element-icons/play.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/img/element-icons/retry.svg b/res/img/element-icons/retry.svg new file mode 100644 index 0000000000..09448d6458 --- /dev/null +++ b/res/img/element-icons/retry.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/img/element-icons/roomlist/browse.svg b/res/img/element-icons/roomlist/browse.svg new file mode 100644 index 0000000000..04714e2881 --- /dev/null +++ b/res/img/element-icons/roomlist/browse.svg @@ -0,0 +1,4 @@ + + + + diff --git a/res/img/element-icons/trashcan.svg b/res/img/element-icons/trashcan.svg new file mode 100644 index 0000000000..f8fb8b5c46 --- /dev/null +++ b/res/img/element-icons/trashcan.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/img/element-icons/upload.svg b/res/img/element-icons/upload.svg new file mode 100644 index 0000000000..71ad7ba1cf --- /dev/null +++ b/res/img/element-icons/upload.svg @@ -0,0 +1,4 @@ + + + + diff --git a/res/img/element-icons/warning-badge.svg b/res/img/element-icons/warning-badge.svg index ac5991f221..1ae4e40ffe 100644 --- a/res/img/element-icons/warning-badge.svg +++ b/res/img/element-icons/warning-badge.svg @@ -1,5 +1,5 @@ - - - + + + diff --git a/res/img/feather-customised/monitor.svg b/res/img/feather-customised/monitor.svg deleted file mode 100644 index 231811d5a6..0000000000 --- a/res/img/feather-customised/monitor.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/res/img/feather-customised/smartphone.svg b/res/img/feather-customised/smartphone.svg deleted file mode 100644 index fde78c82e2..0000000000 --- a/res/img/feather-customised/smartphone.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/res/img/image-view/close.svg b/res/img/image-view/close.svg new file mode 100644 index 0000000000..d603b7f5cc --- /dev/null +++ b/res/img/image-view/close.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/img/image-view/download.svg b/res/img/image-view/download.svg new file mode 100644 index 0000000000..c51deed876 --- /dev/null +++ b/res/img/image-view/download.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/img/image-view/more.svg b/res/img/image-view/more.svg new file mode 100644 index 0000000000..4f5fa6f9b9 --- /dev/null +++ b/res/img/image-view/more.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/img/image-view/rotate-ccw.svg b/res/img/image-view/rotate-ccw.svg new file mode 100644 index 0000000000..85ea3198de --- /dev/null +++ b/res/img/image-view/rotate-ccw.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/img/image-view/rotate-cw.svg b/res/img/image-view/rotate-cw.svg new file mode 100644 index 0000000000..e337f3420e --- /dev/null +++ b/res/img/image-view/rotate-cw.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/img/image-view/zoom-in.svg b/res/img/image-view/zoom-in.svg new file mode 100644 index 0000000000..c0816d489e --- /dev/null +++ b/res/img/image-view/zoom-in.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/img/image-view/zoom-out.svg b/res/img/image-view/zoom-out.svg new file mode 100644 index 0000000000..0539e8c81a --- /dev/null +++ b/res/img/image-view/zoom-out.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/img/rotate-ccw.svg b/res/img/rotate-ccw.svg deleted file mode 100644 index 3924eca040..0000000000 --- a/res/img/rotate-ccw.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/res/img/rotate-cw.svg b/res/img/rotate-cw.svg deleted file mode 100644 index 91021c96d8..0000000000 --- a/res/img/rotate-cw.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/res/img/voip/mic-on-mask.svg b/res/img/voip/mic-on-mask.svg new file mode 100644 index 0000000000..418316b164 --- /dev/null +++ b/res/img/voip/mic-on-mask.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/themes/dark/css/_dark.scss b/res/themes/dark/css/_dark.scss index 0de5e69782..f9983dce9e 100644 --- a/res/themes/dark/css/_dark.scss +++ b/res/themes/dark/css/_dark.scss @@ -63,6 +63,8 @@ $input-invalid-border-color: $warning-color; $field-focused-label-bg-color: $bg-color; +$resend-button-divider-color: #b9bec64a; // muted-text with a 4A opacity. + // scrollbars $scrollbar-thumb-color: rgba(255, 255, 255, 0.2); $scrollbar-track-color: transparent; @@ -85,6 +87,7 @@ $dialog-close-fg-color: #9fa9ba; $dialog-background-bg-color: $header-panel-bg-color; $lightbox-background-bg-color: #000; +$lightbox-background-bg-opacity: 0.85; $settings-grey-fg-color: #a2a2a2; $settings-profile-placeholder-bg-color: #21262c; @@ -109,7 +112,7 @@ $header-divider-color: $header-panel-text-primary-color; $composer-e2e-icon-color: $header-panel-text-primary-color; // this probably shouldn't have it's own colour -$voipcall-plinth-color: #21262c; +$voipcall-plinth-color: #394049; // ******************** @@ -123,7 +126,6 @@ $roomsublist-divider-color: $primary-fg-color; $roomsublist-skeleton-ui-bg: linear-gradient(180deg, #3e444c 0%, #3e444c00 100%); $groupFilterPanel-divider-color: $roomlist-header-color; -$space-button-outline-color: rgba(141, 151, 165, 0.2); $roomtile-preview-color: $secondary-fg-color; $roomtile-default-badge-bg-color: #61708b; @@ -138,9 +140,6 @@ $panel-divider-color: transparent; $widget-menu-bar-bg-color: $header-panel-bg-color; $widget-body-bg-color: rgba(141, 151, 165, 0.2); -// event tile lifecycle -$event-sending-color: $text-secondary-color; - // event redaction $event-redacted-fg-color: #606060; $event-redacted-border-color: #000000; @@ -173,6 +172,9 @@ $button-link-bg-color: transparent; // Toggle switch $togglesw-off-color: $room-highlight-color; +$progressbar-fg-color: $accent-color; +$progressbar-bg-color: #21262c; + $visual-bell-bg-color: #800; $room-warning-bg-color: $header-panel-bg-color; @@ -203,6 +205,10 @@ $breadcrumb-placeholder-bg-color: #272c35; $user-tile-hover-bg-color: $header-panel-bg-color; +$message-body-panel-bg-color: #21262c82; +$message-body-panel-icon-bg-color: #8e99a4; +$message-body-panel-fg-color: $primary-fg-color; + // Appearance tab colors $appearance-tab-border-color: $room-highlight-color; @@ -239,7 +245,7 @@ $composer-shadow-color: rgba(0, 0, 0, 0.28); @define-mixin mx_DialogButton_secondary { // flip colours for the secondary ones font-weight: 600; - border: 1px solid $accent-color ! important; + border: 1px solid $accent-color !important; color: $accent-color; background-color: $button-secondary-bg-color; } diff --git a/res/themes/legacy-dark/css/_legacy-dark.scss b/res/themes/legacy-dark/css/_legacy-dark.scss index 8c5f20178b..194e89e548 100644 --- a/res/themes/legacy-dark/css/_legacy-dark.scss +++ b/res/themes/legacy-dark/css/_legacy-dark.scss @@ -61,6 +61,8 @@ $input-invalid-border-color: $warning-color; $field-focused-label-bg-color: $bg-color; +$resend-button-divider-color: $muted-fg-color; + // scrollbars $scrollbar-thumb-color: rgba(255, 255, 255, 0.2); $scrollbar-track-color: transparent; @@ -83,6 +85,7 @@ $dialog-close-fg-color: #9fa9ba; $dialog-background-bg-color: $header-panel-bg-color; $lightbox-background-bg-color: #000; +$lightbox-background-bg-opacity: 0.85; $settings-grey-fg-color: #a2a2a2; $settings-profile-placeholder-bg-color: #e7e7e7; @@ -106,7 +109,7 @@ $header-divider-color: $header-panel-text-primary-color; $composer-e2e-icon-color: $header-panel-text-primary-color; // this probably shouldn't have it's own colour -$voipcall-plinth-color: #f2f5f8; +$voipcall-plinth-color: #394049; // ******************** @@ -120,7 +123,6 @@ $roomsublist-divider-color: $primary-fg-color; $roomsublist-skeleton-ui-bg: linear-gradient(180deg, #3e444c 0%, #3e444c00 100%); $groupFilterPanel-divider-color: $roomlist-header-color; -$space-button-outline-color: rgba(141, 151, 165, 0.2); $roomtile-preview-color: #9e9e9e; $roomtile-default-badge-bg-color: #61708b; @@ -133,9 +135,6 @@ $panel-divider-color: $header-panel-border-color; $widget-menu-bar-bg-color: $header-panel-bg-color; $widget-body-bg-color: #1A1D23; -// event tile lifecycle -$event-sending-color: $text-secondary-color; - // event redaction $event-redacted-fg-color: #606060; $event-redacted-border-color: #000000; @@ -168,6 +167,9 @@ $button-link-bg-color: transparent; // Toggle switch $togglesw-off-color: $room-highlight-color; +$progressbar-fg-color: $accent-color; +$progressbar-bg-color: #21262c; + $visual-bell-bg-color: #800; $room-warning-bg-color: $header-panel-bg-color; @@ -198,6 +200,10 @@ $breadcrumb-placeholder-bg-color: #272c35; $user-tile-hover-bg-color: $header-panel-bg-color; +$message-body-panel-bg-color: #21262c82; +$message-body-panel-icon-bg-color: #8e99a4; +$message-body-panel-fg-color: $primary-fg-color; + // Appearance tab colors $appearance-tab-border-color: $room-highlight-color; diff --git a/res/themes/legacy-light/css/_legacy-light.scss b/res/themes/legacy-light/css/_legacy-light.scss index 3ba10a68ea..d7352e5684 100644 --- a/res/themes/legacy-light/css/_legacy-light.scss +++ b/res/themes/legacy-light/css/_legacy-light.scss @@ -97,6 +97,8 @@ $input-invalid-border-color: $warning-color; $field-focused-label-bg-color: #ffffff; +$resend-button-divider-color: $input-darker-bg-color; + $button-bg-color: $accent-color; $button-fg-color: white; @@ -127,6 +129,7 @@ $dialog-close-fg-color: #c1c1c1; $dialog-background-bg-color: #e9e9e9; $lightbox-background-bg-color: #000; +$lightbox-background-bg-opacity: 0.95; $imagebody-giflabel: rgba(0, 0, 0, 0.7); $imagebody-giflabel-border: rgba(0, 0, 0, 0.2); @@ -173,7 +176,7 @@ $composer-e2e-icon-color: #91a1c0; $header-divider-color: #91a1c0; // this probably shouldn't have it's own colour -$voipcall-plinth-color: #f2f5f8; +$voipcall-plinth-color: #F4F6FA; // ******************** @@ -187,7 +190,14 @@ $roomsublist-divider-color: $primary-fg-color; $roomsublist-skeleton-ui-bg: linear-gradient(180deg, #ffffff 0%, #ffffff00 100%); $groupFilterPanel-divider-color: $roomlist-header-color; -$space-button-outline-color: #E3E8F0; + +// See non-legacy _light for variable information +$voice-record-stop-border-color: #E3E8F0; +$voice-record-stop-symbol-color: #ff4b55; +$voice-record-waveform-bg-color: #E3E8F0; +$voice-record-waveform-fg-color: $muted-fg-color; +$voice-record-waveform-incomplete-fg-color: #C1C6CD; +$voice-record-live-circle-color: #ff4b55; $roomtile-preview-color: #9e9e9e; $roomtile-default-badge-bg-color: #61708b; @@ -223,8 +233,6 @@ $widget-body-bg-color: #fff; $yellow-background: #fff8e3; // event tile lifecycle -$event-encrypting-color: #abddbc; -$event-sending-color: #ddd; $event-notsent-color: #f44; $event-highlight-fg-color: $warning-color; @@ -282,7 +290,8 @@ $togglesw-ball-color: #fff; $slider-selection-color: $accent-color; $slider-background-color: #c1c9d6; -$progressbar-color: #000; +$progressbar-fg-color: $accent-color; +$progressbar-bg-color: rgba(141, 151, 165, 0.2); $room-warning-bg-color: $yellow-background; @@ -322,6 +331,10 @@ $breadcrumb-placeholder-bg-color: #e8eef5; $user-tile-hover-bg-color: $header-panel-bg-color; +$message-body-panel-bg-color: #e3e8f082; +$message-body-panel-icon-bg-color: #ffffff; +$message-body-panel-fg-color: $muted-fg-color; + // FontSlider colors $appearance-tab-border-color: $input-darker-bg-color; diff --git a/res/themes/light/css/_fonts.scss b/res/themes/light/css/_fonts.scss index ba64830f15..68d9496276 100644 --- a/res/themes/light/css/_fonts.scss +++ b/res/themes/light/css/_fonts.scss @@ -15,8 +15,8 @@ $inter-unicode-range: U+0000-20e2,U+20e4-23ce,U+23d0-24c1,U+24c3-259f,U+25c2-266 font-weight: 400; font-display: swap; unicode-range: $inter-unicode-range; - src: url("$(res)/fonts/Inter/Inter-Regular.woff2?v=3.13") format("woff2"), - url("$(res)/fonts/Inter/Inter-Regular.woff?v=3.13") format("woff"); + src: url("$(res)/fonts/Inter/Inter-Regular.woff2?v=3.18") format("woff2"), + url("$(res)/fonts/Inter/Inter-Regular.woff?v=3.18") format("woff"); } @font-face { font-family: 'Inter'; @@ -24,8 +24,8 @@ $inter-unicode-range: U+0000-20e2,U+20e4-23ce,U+23d0-24c1,U+24c3-259f,U+25c2-266 font-weight: 400; font-display: swap; unicode-range: $inter-unicode-range; - src: url("$(res)/fonts/Inter/Inter-Italic.woff2?v=3.13") format("woff2"), - url("$(res)/fonts/Inter/Inter-Italic.woff?v=3.13") format("woff"); + src: url("$(res)/fonts/Inter/Inter-Italic.woff2?v=3.18") format("woff2"), + url("$(res)/fonts/Inter/Inter-Italic.woff?v=3.18") format("woff"); } @font-face { @@ -34,8 +34,8 @@ $inter-unicode-range: U+0000-20e2,U+20e4-23ce,U+23d0-24c1,U+24c3-259f,U+25c2-266 font-weight: 500; font-display: swap; unicode-range: $inter-unicode-range; - src: url("$(res)/fonts/Inter/Inter-Medium.woff2?v=3.13") format("woff2"), - url("$(res)/fonts/Inter/Inter-Medium.woff?v=3.13") format("woff"); + src: url("$(res)/fonts/Inter/Inter-Medium.woff2?v=3.18") format("woff2"), + url("$(res)/fonts/Inter/Inter-Medium.woff?v=3.18") format("woff"); } @font-face { font-family: 'Inter'; @@ -43,8 +43,8 @@ $inter-unicode-range: U+0000-20e2,U+20e4-23ce,U+23d0-24c1,U+24c3-259f,U+25c2-266 font-weight: 500; font-display: swap; unicode-range: $inter-unicode-range; - src: url("$(res)/fonts/Inter/Inter-MediumItalic.woff2?v=3.13") format("woff2"), - url("$(res)/fonts/Inter/Inter-MediumItalic.woff?v=3.13") format("woff"); + src: url("$(res)/fonts/Inter/Inter-MediumItalic.woff2?v=3.18") format("woff2"), + url("$(res)/fonts/Inter/Inter-MediumItalic.woff?v=3.18") format("woff"); } @font-face { @@ -53,8 +53,8 @@ $inter-unicode-range: U+0000-20e2,U+20e4-23ce,U+23d0-24c1,U+24c3-259f,U+25c2-266 font-weight: 600; font-display: swap; unicode-range: $inter-unicode-range; - src: url("$(res)/fonts/Inter/Inter-SemiBold.woff2?v=3.13") format("woff2"), - url("$(res)/fonts/Inter/Inter-SemiBold.woff?v=3.13") format("woff"); + src: url("$(res)/fonts/Inter/Inter-SemiBold.woff2?v=3.18") format("woff2"), + url("$(res)/fonts/Inter/Inter-SemiBold.woff?v=3.18") format("woff"); } @font-face { font-family: 'Inter'; @@ -62,8 +62,8 @@ $inter-unicode-range: U+0000-20e2,U+20e4-23ce,U+23d0-24c1,U+24c3-259f,U+25c2-266 font-weight: 600; font-display: swap; unicode-range: $inter-unicode-range; - src: url("$(res)/fonts/Inter/Inter-SemiBoldItalic.woff2?v=3.13") format("woff2"), - url("$(res)/fonts/Inter/Inter-SemiBoldItalic.woff?v=3.13") format("woff"); + src: url("$(res)/fonts/Inter/Inter-SemiBoldItalic.woff2?v=3.18") format("woff2"), + url("$(res)/fonts/Inter/Inter-SemiBoldItalic.woff?v=3.18") format("woff"); } @font-face { @@ -72,8 +72,8 @@ $inter-unicode-range: U+0000-20e2,U+20e4-23ce,U+23d0-24c1,U+24c3-259f,U+25c2-266 font-weight: 700; font-display: swap; unicode-range: $inter-unicode-range; - src: url("$(res)/fonts/Inter/Inter-Bold.woff2?v=3.13") format("woff2"), - url("$(res)/fonts/Inter/Inter-Bold.woff?v=3.13") format("woff"); + src: url("$(res)/fonts/Inter/Inter-Bold.woff2?v=3.18") format("woff2"), + url("$(res)/fonts/Inter/Inter-Bold.woff?v=3.18") format("woff"); } @font-face { font-family: 'Inter'; @@ -81,8 +81,8 @@ $inter-unicode-range: U+0000-20e2,U+20e4-23ce,U+23d0-24c1,U+24c3-259f,U+25c2-266 font-weight: 700; font-display: swap; unicode-range: $inter-unicode-range; - src: url("$(res)/fonts/Inter/Inter-BoldItalic.woff2?v=3.13") format("woff2"), - url("$(res)/fonts/Inter/Inter-BoldItalic.woff?v=3.13") format("woff"); + src: url("$(res)/fonts/Inter/Inter-BoldItalic.woff2?v=3.18") format("woff2"), + url("$(res)/fonts/Inter/Inter-BoldItalic.woff?v=3.18") format("woff"); } /* latin-ext */ diff --git a/res/themes/light/css/_light.scss b/res/themes/light/css/_light.scss index b6906d16be..20ccc2ee41 100644 --- a/res/themes/light/css/_light.scss +++ b/res/themes/light/css/_light.scss @@ -91,6 +91,8 @@ $field-focused-label-bg-color: #ffffff; $button-bg-color: $accent-color; $button-fg-color: white; +$resend-button-divider-color: $input-darker-bg-color; + // apart from login forms, which have stronger border $strong-input-border-color: #c7c7c7; @@ -118,6 +120,7 @@ $dialog-close-fg-color: #c1c1c1; $dialog-background-bg-color: #e9e9e9; $lightbox-background-bg-color: #000; +$lightbox-background-bg-opacity: 0.95; $imagebody-giflabel: rgba(0, 0, 0, 0.7); $imagebody-giflabel-border: rgba(0, 0, 0, 0.2); @@ -164,7 +167,7 @@ $composer-e2e-icon-color: #91A1C0; $header-divider-color: #91A1C0; // this probably shouldn't have it's own colour -$voipcall-plinth-color: #f2f5f8; +$voipcall-plinth-color: #F4F6FA; // ******************** @@ -178,7 +181,13 @@ $roomsublist-divider-color: $primary-fg-color; $roomsublist-skeleton-ui-bg: linear-gradient(180deg, #ffffff 0%, #ffffff00 100%); $groupFilterPanel-divider-color: $roomlist-header-color; -$space-button-outline-color: #E3E8F0; + +$voice-record-stop-border-color: #E3E8F0; +$voice-record-stop-symbol-color: #ff4b55; // $warning-color, but without letting people change it in themes +$voice-record-waveform-bg-color: #E3E8F0; +$voice-record-waveform-fg-color: $muted-fg-color; +$voice-record-waveform-incomplete-fg-color: #C1C6CD; +$voice-record-live-circle-color: #ff4b55; // $warning-color, but without letting people change it in themes $roomtile-preview-color: $secondary-fg-color; $roomtile-default-badge-bg-color: #61708b; @@ -220,8 +229,6 @@ $widget-body-bg-color: #FFF; $yellow-background: #fff8e3; // event tile lifecycle -$event-encrypting-color: #abddbc; -$event-sending-color: #ddd; $event-notsent-color: #f44; $event-highlight-fg-color: $warning-color; @@ -279,7 +286,8 @@ $togglesw-ball-color: #fff; $slider-selection-color: $accent-color; $slider-background-color: #c1c9d6; -$progressbar-color: #000; +$progressbar-fg-color: $accent-color; +$progressbar-bg-color: rgba(141, 151, 165, 0.2); $room-warning-bg-color: $yellow-background; @@ -320,6 +328,10 @@ $breadcrumb-placeholder-bg-color: #e8eef5; $user-tile-hover-bg-color: $header-panel-bg-color; +$message-body-panel-bg-color: #e3e8f082; +$message-body-panel-icon-bg-color: #ffffff; +$message-body-panel-fg-color: $muted-fg-color; + // FontSlider colors $appearance-tab-border-color: $input-darker-bg-color; diff --git a/scripts/compare-file.js b/scripts/compare-file.js deleted file mode 100644 index f53275ebfa..0000000000 --- a/scripts/compare-file.js +++ /dev/null @@ -1,10 +0,0 @@ -const fs = require("fs"); - -if (process.argv.length < 4) throw new Error("Missing source and target file arguments"); - -const sourceFile = fs.readFileSync(process.argv[2], 'utf8'); -const targetFile = fs.readFileSync(process.argv[3], 'utf8'); - -if (sourceFile !== targetFile) { - throw new Error("Files do not match"); -} diff --git a/scripts/fetchdep.sh b/scripts/fetchdep.sh index 850eef25ec..fe1f49c361 100755 --- a/scripts/fetchdep.sh +++ b/scripts/fetchdep.sh @@ -22,15 +22,26 @@ clone() { } # Try the PR author's branch in case it exists on the deps as well. -# If BUILDKITE_BRANCH is set, it will contain either: +# First we check if BUILDKITE_BRANCH is defined, +# if it isn't we can assume this is a Netlify build +if [ -z ${BUILDKITE_BRANCH+x} ]; then + # Netlify doesn't give us info about the fork so we have to get it from GitHub API + apiEndpoint="https://api.github.com/repos/matrix-org/matrix-react-sdk/pulls/" + apiEndpoint+=$REVIEW_ID + head=$(curl $apiEndpoint | jq -r '.head.label') +else + head=$BUILDKITE_BRANCH +fi + +# If head 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 +# * "fork:branch" when the author's branch is in their fork or if this is a Netlify build # We can split on `:` into an array to check. -BUILDKITE_BRANCH_ARRAY=(${BUILDKITE_BRANCH//:/ }) -if [[ "${#BUILDKITE_BRANCH_ARRAY[@]}" == "1" ]]; then +BRANCH_ARRAY=(${head//:/ }) +if [[ "${#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]} +elif [[ "${#BRANCH_ARRAY[@]}" == "2" ]]; then + clone ${BRANCH_ARRAY[0]} $defrepo ${BRANCH_ARRAY[1]} fi # Try the target branch of the push or PR. clone $deforg $defrepo $BUILDKITE_PULL_REQUEST_BASE_BRANCH diff --git a/scripts/gen-i18n.js b/scripts/gen-i18n.js deleted file mode 100755 index 91733469f7..0000000000 --- a/scripts/gen-i18n.js +++ /dev/null @@ -1,304 +0,0 @@ -#!/usr/bin/env node - -/* -Copyright 2017 New Vector Ltd - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -/** - * Regenerates the translations en_EN file by walking the source tree and - * parsing each file with the appropriate parser. Emits a JSON file with the - * translatable strings mapped to themselves in the order they appeared - * in the files and grouped by the file they appeared in. - * - * Usage: node scripts/gen-i18n.js - */ -const fs = require('fs'); -const path = require('path'); - -const walk = require('walk'); - -const parser = require("@babel/parser"); -const traverse = require("@babel/traverse"); - -const TRANSLATIONS_FUNCS = ['_t', '_td']; - -const INPUT_TRANSLATIONS_FILE = 'src/i18n/strings/en_EN.json'; -const OUTPUT_FILE = 'src/i18n/strings/en_EN.json'; - -// NB. The sync version of walk is broken for single files so we walk -// all of res rather than just res/home.html. -// https://git.daplie.com/Daplie/node-walk/merge_requests/1 fixes it, -// or if we get bored waiting for it to be merged, we could switch -// to a project that's actively maintained. -const SEARCH_PATHS = ['src', 'res']; - -function getObjectValue(obj, key) { - for (const prop of obj.properties) { - if (prop.key.type === 'Identifier' && prop.key.name === key) { - return prop.value; - } - } - return null; -} - -function getTKey(arg) { - if (arg.type === 'Literal' || arg.type === "StringLiteral") { - return arg.value; - } else if (arg.type === 'BinaryExpression' && arg.operator === '+') { - return getTKey(arg.left) + getTKey(arg.right); - } else if (arg.type === 'TemplateLiteral') { - return arg.quasis.map((q) => { - return q.value.raw; - }).join(''); - } - return null; -} - -function getFormatStrings(str) { - // Match anything that starts with % - // We could make a regex that matched the full placeholder, but this - // would just not match invalid placeholders and so wouldn't help us - // detect the invalid ones. - // Also note that for simplicity, this just matches a % character and then - // anything up to the next % character (or a single %, or end of string). - const formatStringRe = /%([^%]+|%|$)/g; - const formatStrings = new Set(); - - let match; - while ( (match = formatStringRe.exec(str)) !== null ) { - const placeholder = match[1]; // Minus the leading '%' - if (placeholder === '%') continue; // Literal % is %% - - const placeholderMatch = placeholder.match(/^\((.*?)\)(.)/); - if (placeholderMatch === null) { - throw new Error("Invalid format specifier: '"+match[0]+"'"); - } - if (placeholderMatch.length < 3) { - throw new Error("Malformed format specifier"); - } - const placeholderName = placeholderMatch[1]; - const placeholderFormat = placeholderMatch[2]; - - if (placeholderFormat !== 's') { - throw new Error(`'${placeholderFormat}' used as format character: you probably meant 's'`); - } - - formatStrings.add(placeholderName); - } - - return formatStrings; -} - -function getTranslationsJs(file) { - const contents = fs.readFileSync(file, { encoding: 'utf8' }); - - const trs = new Set(); - - try { - const plugins = [ - // https://babeljs.io/docs/en/babel-parser#plugins - "classProperties", - "objectRestSpread", - "throwExpressions", - "exportDefaultFrom", - "decorators-legacy", - ]; - - if (file.endsWith(".js") || file.endsWith(".jsx")) { - // all JS is assumed to be flow or react - plugins.push("flow", "jsx"); - } else if (file.endsWith(".ts")) { - // TS can't use JSX unless it's a TSX file (otherwise angle casts fail) - plugins.push("typescript"); - } else if (file.endsWith(".tsx")) { - // When the file is a TSX file though, enable JSX parsing - plugins.push("typescript", "jsx"); - } - - const babelParsed = parser.parse(contents, { - allowImportExportEverywhere: true, - errorRecovery: true, - sourceFilename: file, - tokens: true, - plugins, - }); - traverse.default(babelParsed, { - enter: (p) => { - const node = p.node; - if (p.isCallExpression() && node.callee && TRANSLATIONS_FUNCS.includes(node.callee.name)) { - const tKey = getTKey(node.arguments[0]); - - // This happens whenever we call _t with non-literals (ie. whenever we've - // had to use a _td to compensate) so is expected. - if (tKey === null) return; - - // check the format string against the args - // We only check _t: _td has no args - if (node.callee.name === '_t') { - try { - const placeholders = getFormatStrings(tKey); - for (const placeholder of placeholders) { - if (node.arguments.length < 2) { - throw new Error(`Placeholder found ('${placeholder}') but no substitutions given`); - } - const value = getObjectValue(node.arguments[1], placeholder); - if (value === null) { - throw new Error(`No value found for placeholder '${placeholder}'`); - } - } - - // Validate tag replacements - if (node.arguments.length > 2) { - const tagMap = node.arguments[2]; - for (const prop of tagMap.properties || []) { - if (prop.key.type === 'Literal') { - const tag = prop.key.value; - // RegExp same as in src/languageHandler.js - const regexp = new RegExp(`(<${tag}>(.*?)<\\/${tag}>|<${tag}>|<${tag}\\s*\\/>)`); - if (!tKey.match(regexp)) { - throw new Error(`No match for ${regexp} in ${tKey}`); - } - } - } - } - - } catch (e) { - console.log(); - console.error(`ERROR: ${file}:${node.loc.start.line} ${tKey}`); - console.error(e); - process.exit(1); - } - } - - let isPlural = false; - if (node.arguments.length > 1 && node.arguments[1].type === 'ObjectExpression') { - const countVal = getObjectValue(node.arguments[1], 'count'); - if (countVal) { - isPlural = true; - } - } - - if (isPlural) { - trs.add(tKey + "|other"); - const plurals = enPlurals[tKey]; - if (plurals) { - for (const pluralType of Object.keys(plurals)) { - trs.add(tKey + "|" + pluralType); - } - } - } else { - trs.add(tKey); - } - } - }, - }); - } catch (e) { - console.error(e); - process.exit(1); - } - - return trs; -} - -function getTranslationsOther(file) { - const contents = fs.readFileSync(file, { encoding: 'utf8' }); - - const trs = new Set(); - - // Taken from element-web src/components/structures/HomePage.js - const translationsRegex = /_t\(['"]([\s\S]*?)['"]\)/mg; - let matches; - while (matches = translationsRegex.exec(contents)) { - trs.add(matches[1]); - } - return trs; -} - -// gather en_EN plural strings from the input translations file: -// the en_EN strings are all in the source with the exception of -// pluralised strings, which we need to pull in from elsewhere. -const inputTranslationsRaw = JSON.parse(fs.readFileSync(INPUT_TRANSLATIONS_FILE, { encoding: 'utf8' })); -const enPlurals = {}; - -for (const key of Object.keys(inputTranslationsRaw)) { - const parts = key.split("|"); - if (parts.length > 1) { - const plurals = enPlurals[parts[0]] || {}; - plurals[parts[1]] = inputTranslationsRaw[key]; - enPlurals[parts[0]] = plurals; - } -} - -const translatables = new Set(); - -const walkOpts = { - listeners: { - names: function(root, nodeNamesArray) { - // Sort the names case insensitively and alphabetically to - // maintain some sense of order between the different strings. - nodeNamesArray.sort((a, b) => { - a = a.toLowerCase(); - b = b.toLowerCase(); - if (a > b) return 1; - if (a < b) return -1; - return 0; - }); - }, - file: function(root, fileStats, next) { - const fullPath = path.join(root, fileStats.name); - - let trs; - if (fileStats.name.endsWith('.js') || fileStats.name.endsWith('.ts') || fileStats.name.endsWith('.tsx')) { - trs = getTranslationsJs(fullPath); - } else if (fileStats.name.endsWith('.html')) { - trs = getTranslationsOther(fullPath); - } else { - return; - } - console.log(`${fullPath} (${trs.size} strings)`); - for (const tr of trs.values()) { - // Convert DOS line endings to unix - translatables.add(tr.replace(/\r\n/g, "\n")); - } - }, - } -}; - -for (const path of SEARCH_PATHS) { - if (fs.existsSync(path)) { - walk.walkSync(path, walkOpts); - } -} - -const trObj = {}; -for (const tr of translatables) { - if (tr.includes("|")) { - if (inputTranslationsRaw[tr]) { - trObj[tr] = inputTranslationsRaw[tr]; - } else { - trObj[tr] = tr.split("|")[0]; - } - } else { - trObj[tr] = tr; - } -} - -fs.writeFileSync( - OUTPUT_FILE, - JSON.stringify(trObj, translatables.values(), 4) + "\n" -); - -console.log(); -console.log(`Wrote ${translatables.size} strings to ${OUTPUT_FILE}`); diff --git a/scripts/prune-i18n.js b/scripts/prune-i18n.js deleted file mode 100755 index b4fe8d69f5..0000000000 --- a/scripts/prune-i18n.js +++ /dev/null @@ -1,68 +0,0 @@ -#!/usr/bin/env node - -/* -Copyright 2017 New Vector Ltd - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -/* - * Looks through all the translation files and removes any strings - * which don't appear in en_EN.json. - * Use this if you remove a translation, but merge any outstanding changes - * from weblate first or you'll need to resolve the conflict in weblate. - */ - -const fs = require('fs'); -const path = require('path'); - -const I18NDIR = 'src/i18n/strings'; - -const enStringsRaw = JSON.parse(fs.readFileSync(path.join(I18NDIR, 'en_EN.json'))); - -const enStrings = new Set(); -for (const str of Object.keys(enStringsRaw)) { - const parts = str.split('|'); - if (parts.length > 1) { - enStrings.add(parts[0]); - } else { - enStrings.add(str); - } -} - -for (const filename of fs.readdirSync(I18NDIR)) { - if (filename === 'en_EN.json') continue; - if (filename === 'basefile.json') continue; - if (!filename.endsWith('.json')) continue; - - const trs = JSON.parse(fs.readFileSync(path.join(I18NDIR, filename))); - const oldLen = Object.keys(trs).length; - for (const tr of Object.keys(trs)) { - const parts = tr.split('|'); - const trKey = parts.length > 1 ? parts[0] : tr; - if (!enStrings.has(trKey)) { - delete trs[tr]; - } - } - - const removed = oldLen - Object.keys(trs).length; - if (removed > 0) { - console.log(`${filename}: removed ${removed} translations`); - // XXX: This is totally relying on the impl serialising the JSON object in the - // same order as they were parsed from the file. JSON.stringify() has a specific argument - // that can be used to control the order, but JSON.parse() lacks any kind of equivalent. - // Empirically this does maintain the order on my system, so I'm going to leave it like - // this for now. - fs.writeFileSync(path.join(I18NDIR, filename), JSON.stringify(trs, undefined, 4) + "\n"); - } -} diff --git a/scripts/reskindex.js b/scripts/reskindex.js index 12310b77c1..5eaec4d1d5 100755 --- a/scripts/reskindex.js +++ b/scripts/reskindex.js @@ -1,5 +1,6 @@ #!/usr/bin/env node const fs = require('fs'); +const { promises: fsp } = fs; const path = require('path'); const glob = require('glob'); const util = require('util'); @@ -25,6 +26,8 @@ async function reskindex() { const header = args.h || args.header; const strm = fs.createWriteStream(componentIndexTmp); + // Wait for the open event to ensure the file descriptor is set + await new Promise(resolve => strm.once("open", resolve)); if (header) { strm.write(fs.readFileSync(header)); @@ -53,14 +56,9 @@ async function reskindex() { strm.write("export {components};\n"); // Ensure the file has been fully written to disk before proceeding + await util.promisify(fs.fsync)(strm.fd); await util.promisify(strm.end); - fs.rename(componentIndexTmp, componentIndex, function(err) { - if (err) { - console.error("Error moving new index into place: " + err); - } else { - console.log('Reskindex: completed'); - } - }); + await fsp.rename(componentIndexTmp, componentIndex); } // Expects both arrays of file names to be sorted @@ -77,9 +75,17 @@ function filesHaveChanged(files, prevFiles) { return false; } +// Wrapper since await at the top level is not well supported yet +function run() { + (async function() { + await reskindex(); + console.log("Reskindex completed"); + })(); +} + // -w indicates watch mode where any FS events will trigger reskindex if (!args.w) { - reskindex(); + run(); return; } @@ -87,5 +93,5 @@ let watchDebouncer = null; chokidar.watch(path.join(componentsDir, componentJsGlob)).on('all', (event, path) => { if (path === componentIndex) return; if (watchDebouncer) clearTimeout(watchDebouncer); - watchDebouncer = setTimeout(reskindex, 1000); + watchDebouncer = setTimeout(run, 1000); }); diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts index 4aa6df5488..0ab26ef943 100644 --- a/src/@types/global.d.ts +++ b/src/@types/global.d.ts @@ -1,5 +1,5 @@ /* -Copyright 2020 The Matrix.org Foundation C.I.C. +Copyright 2020-2021 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -39,6 +39,9 @@ import {ModalWidgetStore} from "../stores/ModalWidgetStore"; import { WidgetLayoutStore } from "../stores/widgets/WidgetLayoutStore"; import VoipUserMapper from "../VoipUserMapper"; import {SpaceStoreClass} from "../stores/SpaceStore"; +import TypingStore from "../stores/TypingStore"; +import { EventIndexPeg } from "../indexing/EventIndexPeg"; +import {VoiceRecordingStore} from "../stores/VoiceRecordingStore"; declare global { interface Window { @@ -70,11 +73,16 @@ declare global { mxModalWidgetStore: ModalWidgetStore; mxVoipUserMapper: VoipUserMapper; mxSpaceStore: SpaceStoreClass; + mxVoiceRecordingStore: VoiceRecordingStore; + mxTypingStore: TypingStore; + mxEventIndexPeg: EventIndexPeg; } interface Document { // https://developer.mozilla.org/en-US/docs/Web/API/Document/hasStorageAccess hasStorageAccess?: () => Promise; + // https://developer.mozilla.org/en-US/docs/Web/API/Document/requestStorageAccess + requestStorageAccess?: () => Promise; // Safari & IE11 only have this prefixed: we used prefixed versions // previously so let's continue to support them for now @@ -127,4 +135,30 @@ declare global { // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/columnNumber columnNumber?: number; } + + // https://github.com/microsoft/TypeScript/issues/28308#issuecomment-650802278 + interface AudioWorkletProcessor { + readonly port: MessagePort; + process( + inputs: Float32Array[][], + outputs: Float32Array[][], + parameters: Record + ): boolean; + } + + // https://github.com/microsoft/TypeScript/issues/28308#issuecomment-650802278 + const AudioWorkletProcessor: { + prototype: AudioWorkletProcessor; + new (options?: AudioWorkletNodeOptions): AudioWorkletProcessor; + }; + + // https://github.com/microsoft/TypeScript/issues/28308#issuecomment-650802278 + function registerProcessor( + name: string, + processorCtor: (new ( + options?: AudioWorkletNodeOptions + ) => AudioWorkletProcessor) & { + parameterDescriptors?: AudioParamDescriptor[]; + } + ); } diff --git a/src/Avatar.ts b/src/Avatar.ts index e2557e21a8..d218ae8b46 100644 --- a/src/Avatar.ts +++ b/src/Avatar.ts @@ -14,28 +14,20 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {getHttpUriForMxc} from "matrix-js-sdk/src/content-repo"; import {RoomMember} from "matrix-js-sdk/src/models/room-member"; import {User} from "matrix-js-sdk/src/models/user"; import {Room} from "matrix-js-sdk/src/models/room"; -import {MatrixClientPeg} from './MatrixClientPeg'; import DMRoomMap from './utils/DMRoomMap'; +import {mediaFromMxc} from "./customisations/Media"; export type ResizeMethod = "crop" | "scale"; // Not to be used for BaseAvatar urls as that has similar default avatar fallback already export function avatarUrlForMember(member: RoomMember, width: number, height: number, resizeMethod: ResizeMethod) { let url: string; - if (member && member.getAvatarUrl) { - url = member.getAvatarUrl( - MatrixClientPeg.get().getHomeserverUrl(), - Math.floor(width * window.devicePixelRatio), - Math.floor(height * window.devicePixelRatio), - resizeMethod, - false, - false, - ); + if (member?.getMxcAvatarUrl()) { + url = mediaFromMxc(member.getMxcAvatarUrl()).getThumbnailOfSourceHttp(width, height, resizeMethod); } if (!url) { // member can be null here currently since on invites, the JS SDK @@ -47,16 +39,8 @@ export function avatarUrlForMember(member: RoomMember, width: number, height: nu } export function avatarUrlForUser(user: User, width: number, height: number, resizeMethod?: ResizeMethod) { - const url = getHttpUriForMxc( - MatrixClientPeg.get().getHomeserverUrl(), user.avatarUrl, - Math.floor(width * window.devicePixelRatio), - Math.floor(height * window.devicePixelRatio), - resizeMethod, - ); - if (!url || url.length === 0) { - return null; - } - return url; + if (!user.avatarUrl) return null; + return mediaFromMxc(user.avatarUrl).getThumbnailOfSourceHttp(width, height, resizeMethod); } function isValidHexColor(color: string): boolean { @@ -154,15 +138,8 @@ export function getInitialLetter(name: string): string { export function avatarUrlForRoom(room: Room, width: number, height: number, resizeMethod?: ResizeMethod) { if (!room) return null; // null-guard - const explicitRoomAvatar = room.getAvatarUrl( - MatrixClientPeg.get().getHomeserverUrl(), - width, - height, - resizeMethod, - false, - ); - if (explicitRoomAvatar) { - return explicitRoomAvatar; + if (room.getMxcAvatarUrl()) { + return mediaFromMxc(room.getMxcAvatarUrl()).getThumbnailOfSourceHttp(width, height, resizeMethod); } // space rooms cannot be DMs so skip the rest @@ -177,14 +154,8 @@ export function avatarUrlForRoom(room: Room, width: number, height: number, resi // then still try to show any avatar (pref. other member) otherMember = room.getAvatarFallbackMember(); } - if (otherMember) { - return otherMember.getAvatarUrl( - MatrixClientPeg.get().getHomeserverUrl(), - width, - height, - resizeMethod, - false, - ); + if (otherMember?.getMxcAvatarUrl()) { + return mediaFromMxc(otherMember.getMxcAvatarUrl()).getThumbnailOfSourceHttp(width, height, resizeMethod); } return null; } diff --git a/src/BasePlatform.ts b/src/BasePlatform.ts index 9d7077097b..5483ea6874 100644 --- a/src/BasePlatform.ts +++ b/src/BasePlatform.ts @@ -212,6 +212,18 @@ export default abstract class BasePlatform { throw new Error("Unimplemented"); } + supportsWarnBeforeExit(): boolean { + return false; + } + + async shouldWarnBeforeExit(): Promise { + return false; + } + + async setWarnBeforeExit(enabled: boolean): Promise { + throw new Error("Unimplemented"); + } + supportsAutoHideMenuBar(): boolean { return false; } @@ -246,7 +258,7 @@ export default abstract class BasePlatform { return null; } - setLanguage(preferredLangs: string[]) {} + async setLanguage(preferredLangs: string[]) {} setSpellCheckLanguages(preferredLangs: string[]) {} diff --git a/src/CallHandler.tsx b/src/CallHandler.tsx index 8621f441de..91fd7e4c7d 100644 --- a/src/CallHandler.tsx +++ b/src/CallHandler.tsx @@ -59,7 +59,6 @@ import {MatrixClientPeg} from './MatrixClientPeg'; import PlatformPeg from './PlatformPeg'; import Modal from './Modal'; import { _t } from './languageHandler'; -import { createNewMatrixCall } from 'matrix-js-sdk/src/webrtc/call'; import dis from './dispatcher/dispatcher'; import WidgetUtils from './utils/WidgetUtils'; import WidgetEchoStore from './stores/WidgetEchoStore'; @@ -86,6 +85,8 @@ import { Action } from './dispatcher/actions'; import VoipUserMapper from './VoipUserMapper'; import { addManagedHybridWidget, isManagedHybridWidgetEnabled } from './widgets/ManagedHybrid'; import { randomUppercaseString, randomLowercaseString } from "matrix-js-sdk/src/randomstring"; +import SdkConfig from './SdkConfig'; +import { ensureDMExists, findDMForUser } from './createRoom'; export const PROTOCOL_PSTN = 'm.protocol.pstn'; export const PROTOCOL_PSTN_PREFIXED = 'im.vector.protocol.pstn'; @@ -154,6 +155,9 @@ function getRemoteAudioElement(): HTMLAudioElement { export default class CallHandler { private calls = new Map(); // roomId -> call + // Calls started as an attended transfer, ie. with the intention of transferring another + // call with a different party to this one. + private transferees = new Map(); // callId (target) -> call (transferee) private audioPromises = new Map>(); private dispatcherRef: string = null; private supportsPstnProtocol = null; @@ -164,6 +168,11 @@ export default class CallHandler { private invitedRoomsAreVirtual = new Map(); private invitedRoomCheckInProgress = false; + // Map of the asserted identity users after we've looked them up using the API. + // We need to be be able to determine the mapped room synchronously, so we + // do the async lookup when we get new information and then store these mappings here + private assertedIdentityNativeUsers = new Map(); + static sharedInstance() { if (!window.mxCallHandler) { window.mxCallHandler = new CallHandler() @@ -176,8 +185,19 @@ export default class CallHandler { * Gets the user-facing room associated with a call (call.roomId may be the call "virtual room" * if a voip_mxid_translate_pattern is set in the config) */ - public static roomIdForCall(call: MatrixCall): string { + public roomIdForCall(call: MatrixCall): string { if (!call) return null; + + const voipConfig = SdkConfig.get()['voip']; + + if (voipConfig && voipConfig.obeyAssertedIdentity) { + const nativeUser = this.assertedIdentityNativeUsers[call.callId]; + if (nativeUser) { + const room = findDMForUser(MatrixClientPeg.get(), nativeUser); + if (room) return room.roomId + } + } + return VoipUserMapper.sharedInstance().nativeRoomForVirtualRoom(call.roomId) || call.roomId; } @@ -325,6 +345,10 @@ export default class CallHandler { return callsNotInThatRoom; } + getTransfereeForCallId(callId: string): MatrixCall { + return this.transferees[callId]; + } + play(audioId: AudioID) { // TODO: Attach an invisible element for this instead // which listens? @@ -372,14 +396,14 @@ export default class CallHandler { // We don't allow placing more than one call per room, but that doesn't mean there // can't be more than one, eg. in a glare situation. This checks that the given call // is the call we consider 'the' call for its room. - const mappedRoomId = CallHandler.roomIdForCall(call); + const mappedRoomId = this.roomIdForCall(call); const callForThisRoom = this.getCallForRoom(mappedRoomId); return callForThisRoom && call.callId === callForThisRoom.callId; } private setCallListeners(call: MatrixCall) { - const mappedRoomId = CallHandler.roomIdForCall(call); + let mappedRoomId = CallHandler.sharedInstance().roomIdForCall(call); call.on(CallEvent.Error, (err: CallError) => { if (!this.matchesCallForThisRoom(call)) return; @@ -493,6 +517,42 @@ export default class CallHandler { this.setCallListeners(newCall); this.setCallState(newCall, newCall.state); }); + call.on(CallEvent.AssertedIdentityChanged, async () => { + if (!this.matchesCallForThisRoom(call)) return; + + console.log(`Call ID ${call.callId} got new asserted identity:`, call.getRemoteAssertedIdentity()); + + const newAssertedIdentity = call.getRemoteAssertedIdentity().id; + let newNativeAssertedIdentity = newAssertedIdentity; + if (newAssertedIdentity) { + const response = await this.sipNativeLookup(newAssertedIdentity); + if (response.length) newNativeAssertedIdentity = response[0].userid; + } + console.log(`Asserted identity ${newAssertedIdentity} mapped to ${newNativeAssertedIdentity}`); + + if (newNativeAssertedIdentity) { + this.assertedIdentityNativeUsers[call.callId] = newNativeAssertedIdentity; + + // If we don't already have a room with this user, make one. This will be slightly odd + // if they called us because we'll be inviting them, but there's not much we can do about + // this if we want the actual, native room to exist (which we do). This is why it's + // important to only obey asserted identity in trusted environments, since anyone you're + // on a call with can cause you to send a room invite to someone. + await ensureDMExists(MatrixClientPeg.get(), newNativeAssertedIdentity); + + const newMappedRoomId = this.roomIdForCall(call); + console.log(`Old room ID: ${mappedRoomId}, new room ID: ${newMappedRoomId}`); + if (newMappedRoomId !== mappedRoomId) { + this.removeCallForRoom(mappedRoomId); + mappedRoomId = newMappedRoomId; + this.calls.set(mappedRoomId, call); + dis.dispatch({ + action: Action.CallChangeRoom, + call, + }); + } + } + }); } private async logCallStats(call: MatrixCall, mappedRoomId: string) { @@ -544,7 +604,7 @@ export default class CallHandler { } private setCallState(call: MatrixCall, status: CallState) { - const mappedRoomId = CallHandler.roomIdForCall(call); + const mappedRoomId = CallHandler.sharedInstance().roomIdForCall(call); console.log( `Call state in ${mappedRoomId} changed to ${status}`, @@ -622,6 +682,7 @@ export default class CallHandler { private async placeCall( roomId: string, type: PlaceCallType, localElement: HTMLVideoElement, remoteElement: HTMLVideoElement, + transferee: MatrixCall, ) { Analytics.trackEvent('voip', 'placeCall', 'type', type); CountlyAnalytics.instance.trackStartCall(roomId, type === PlaceCallType.Video, false); @@ -630,10 +691,13 @@ export default class CallHandler { logger.debug("Mapped real room " + roomId + " to room ID " + mappedRoomId); const timeUntilTurnCresExpire = MatrixClientPeg.get().getTurnServersExpiry() - Date.now(); - console.log("Current turn creds expire in " + timeUntilTurnCresExpire + " seconds"); - const call = createNewMatrixCall(MatrixClientPeg.get(), mappedRoomId); + console.log("Current turn creds expire in " + timeUntilTurnCresExpire + " ms"); + const call = MatrixClientPeg.get().createCall(mappedRoomId); this.calls.set(roomId, call); + if (transferee) { + this.transferees[call.callId] = transferee; + } this.setCallListeners(call); this.setCallAudioElement(call); @@ -662,7 +726,7 @@ export default class CallHandler { call.placeScreenSharingCall( remoteElement, localElement, - async () : Promise => { + async (): Promise => { const {finished} = Modal.createDialog(DesktopCapturerSourcePicker); const [source] = await finished; return source; @@ -723,7 +787,10 @@ export default class CallHandler { } else if (members.length === 2) { console.info(`Place ${payload.type} call in ${payload.room_id}`); - this.placeCall(payload.room_id, payload.type, payload.local_element, payload.remote_element); + this.placeCall( + payload.room_id, payload.type, payload.local_element, payload.remote_element, + payload.transferee, + ); } else { // > 2 dis.dispatch({ action: "place_conference_call", @@ -758,7 +825,7 @@ export default class CallHandler { const call = payload.call as MatrixCall; - const mappedRoomId = CallHandler.roomIdForCall(call); + const mappedRoomId = CallHandler.sharedInstance().roomIdForCall(call); if (this.getCallForRoom(mappedRoomId)) { // ignore multiple incoming calls to the same room return; @@ -788,6 +855,11 @@ export default class CallHandler { // don't remove the call yet: let the hangup event handler do it (otherwise it will throw // the hangup event away) break; + case 'hangup_all': + for (const call of this.calls.values()) { + call.hangup(CallErrorCode.UserHangup, false); + } + break; case 'answer': { if (!this.calls.has(payload.room_id)) { return; // no call to answer diff --git a/src/CallMediaHandler.js b/src/CallMediaHandler.js index 8d56467c57..7c7940cab5 100644 --- a/src/CallMediaHandler.js +++ b/src/CallMediaHandler.js @@ -14,9 +14,9 @@ limitations under the License. */ -import * as Matrix from 'matrix-js-sdk'; import SettingsStore from "./settings/SettingsStore"; import {SettingLevel} from "./settings/SettingLevel"; +import {setMatrixCallAudioInput, setMatrixCallAudioOutput, setMatrixCallVideoInput} from "matrix-js-sdk/src/matrix"; export default { hasAnyLabeledDevices: async function() { @@ -54,24 +54,24 @@ export default { const audioDeviceId = SettingsStore.getValue("webrtc_audioinput"); const videoDeviceId = SettingsStore.getValue("webrtc_videoinput"); - Matrix.setMatrixCallAudioOutput(audioOutDeviceId); - Matrix.setMatrixCallAudioInput(audioDeviceId); - Matrix.setMatrixCallVideoInput(videoDeviceId); + setMatrixCallAudioOutput(audioOutDeviceId); + setMatrixCallAudioInput(audioDeviceId); + setMatrixCallVideoInput(videoDeviceId); }, setAudioOutput: function(deviceId) { SettingsStore.setValue("webrtc_audiooutput", null, SettingLevel.DEVICE, deviceId); - Matrix.setMatrixCallAudioOutput(deviceId); + setMatrixCallAudioOutput(deviceId); }, setAudioInput: function(deviceId) { SettingsStore.setValue("webrtc_audioinput", null, SettingLevel.DEVICE, deviceId); - Matrix.setMatrixCallAudioInput(deviceId); + setMatrixCallAudioInput(deviceId); }, setVideoInput: function(deviceId) { SettingsStore.setValue("webrtc_videoinput", null, SettingLevel.DEVICE, deviceId); - Matrix.setMatrixCallVideoInput(deviceId); + setMatrixCallVideoInput(deviceId); }, getAudioOutput: function() { diff --git a/src/ContentMessages.tsx b/src/ContentMessages.tsx index bec36d49f6..95b45cce4a 100644 --- a/src/ContentMessages.tsx +++ b/src/ContentMessages.tsx @@ -32,6 +32,14 @@ import Spinner from "./components/views/elements/Spinner"; import "blueimp-canvas-to-blob"; import { Action } from "./dispatcher/actions"; import CountlyAnalytics from "./CountlyAnalytics"; +import { + UploadCanceledPayload, + UploadErrorPayload, + UploadFinishedPayload, + UploadProgressPayload, + UploadStartedPayload, +} from "./dispatcher/payloads/UploadPayload"; +import {IUpload} from "./models/IUpload"; const MAX_WIDTH = 800; const MAX_HEIGHT = 600; @@ -44,15 +52,6 @@ export class UploadCanceledError extends Error {} type ThumbnailableElement = HTMLImageElement | HTMLVideoElement; -interface IUpload { - fileName: string; - roomId: string; - total: number; - loaded: number; - promise: Promise; - canceled?: boolean; -} - interface IMediaConfig { "m.upload.size"?: number; } @@ -478,7 +477,7 @@ export default class ContentMessages { if (upload) { upload.canceled = true; MatrixClientPeg.get().cancelUpload(upload.promise); - dis.dispatch({action: 'upload_canceled', upload}); + dis.dispatch({action: Action.UploadCanceled, upload}); } } @@ -539,7 +538,7 @@ export default class ContentMessages { promise: prom, }; this.inprogress.push(upload); - dis.dispatch({action: 'upload_started'}); + dis.dispatch({action: Action.UploadStarted, upload}); // Focus the composer view dis.fire(Action.FocusComposer); @@ -547,7 +546,7 @@ export default class ContentMessages { function onProgress(ev) { upload.total = ev.total; upload.loaded = ev.loaded; - dis.dispatch({action: 'upload_progress', upload: upload}); + dis.dispatch({action: Action.UploadProgress, upload}); } let error; @@ -601,9 +600,9 @@ export default class ContentMessages { if (error && error.http_status === 413) { this.mediaConfig = null; } - dis.dispatch({action: 'upload_failed', upload, error}); + dis.dispatch({action: Action.UploadFailed, upload, error}); } else { - dis.dispatch({action: 'upload_finished', upload}); + dis.dispatch({action: Action.UploadFinished, upload}); dis.dispatch({action: 'message_sent'}); } }); diff --git a/src/DateUtils.ts b/src/DateUtils.ts index 9b1edf0775..e4a1175d88 100644 --- a/src/DateUtils.ts +++ b/src/DateUtils.ts @@ -97,7 +97,7 @@ export function formatFullDateNoTime(date: Date): string { }); } -export function formatFullDate(date: Date, showTwelveHour = false): string { +export function formatFullDate(date: Date, showTwelveHour = false, showSeconds = true): string { const days = getDaysArray(); const months = getMonthsArray(); return _t('%(weekDayName)s, %(monthName)s %(day)s %(fullYear)s %(time)s', { @@ -105,7 +105,7 @@ export function formatFullDate(date: Date, showTwelveHour = false): string { monthName: months[date.getMonth()], day: date.getDate(), fullYear: date.getFullYear(), - time: formatFullTime(date, showTwelveHour), + time: showSeconds ? formatFullTime(date, showTwelveHour) : formatTime(date, showTwelveHour), }); } diff --git a/src/HtmlUtils.tsx b/src/HtmlUtils.tsx index 7d6b049914..6b2568d68c 100644 --- a/src/HtmlUtils.tsx +++ b/src/HtmlUtils.tsx @@ -32,10 +32,10 @@ import { AllHtmlEntities } from 'html-entities'; import SettingsStore from './settings/SettingsStore'; import cheerio from 'cheerio'; -import {MatrixClientPeg} from './MatrixClientPeg'; import {tryTransformPermalinkToLocalHref} from "./utils/permalinks/Permalinks"; import {SHORTCODE_TO_EMOJI, getEmojiFromUnicode} from "./emoji"; import ReplyThread from "./components/views/elements/ReplyThread"; +import {mediaFromMxc} from "./customisations/Media"; linkifyMatrix(linkify); @@ -130,11 +130,14 @@ export function sanitizedHtmlNode(insaneHtml: string) { return
; } -export function sanitizedHtmlNodeInnerText(insaneHtml: string) { - const saneHtml = sanitizeHtml(insaneHtml, sanitizeHtmlParams); - const contentDiv = document.createElement("div"); - contentDiv.innerHTML = saneHtml; - return contentDiv.innerText; +export function getHtmlText(insaneHtml: string) { + return sanitizeHtml(insaneHtml, { + allowedTags: [], + allowedAttributes: {}, + selfClosing: [], + allowedSchemes: [], + disallowedTagsMode: 'discard', + }) } /** @@ -181,11 +184,9 @@ const transformTags: IExtendedSanitizeOptions["transformTags"] = { // custom to if (!attribs.src || !attribs.src.startsWith('mxc://') || !SettingsStore.getValue("showImages")) { return { tagName, attribs: {}}; } - attribs.src = MatrixClientPeg.get().mxcUrlToHttp( - attribs.src, - attribs.width || 800, - attribs.height || 600, - ); + const width = Number(attribs.width) || 800; + const height = Number(attribs.height) || 600; + attribs.src = mediaFromMxc(attribs.src).getThumbnailOfSourceHttp(width, height); return { tagName, attribs }; }, 'code': function(tagName: string, attribs: sanitizeHtml.Attributes) { @@ -239,6 +240,7 @@ const sanitizeHtmlParams: IExtendedSanitizeOptions = { 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'p', 'a', 'ul', 'ol', 'sup', 'sub', 'nl', 'li', 'b', 'i', 'u', 'strong', 'em', 'strike', 'code', 'hr', 'br', 'div', 'table', 'thead', 'caption', 'tbody', 'tr', 'th', 'td', 'pre', 'span', 'img', + 'details', 'summary', ], allowedAttributes: { // custom ones first: diff --git a/src/IdentityAuthClient.js b/src/IdentityAuthClient.js index d3bfee2380..1687adf13b 100644 --- a/src/IdentityAuthClient.js +++ b/src/IdentityAuthClient.js @@ -14,7 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { createClient, SERVICE_TYPES } from 'matrix-js-sdk'; +import { SERVICE_TYPES } from 'matrix-js-sdk/src/service-types'; +import { createClient } from 'matrix-js-sdk/src/matrix'; import {MatrixClientPeg} from './MatrixClientPeg'; import Modal from './Modal'; diff --git a/src/KeyBindingsDefaults.ts b/src/KeyBindingsDefaults.ts new file mode 100644 index 0000000000..63c4ac0f86 --- /dev/null +++ b/src/KeyBindingsDefaults.ts @@ -0,0 +1,407 @@ +/* +Copyright 2021 Clemens Zeidler + +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 { AutocompleteAction, IKeyBindingsProvider, KeyBinding, MessageComposerAction, NavigationAction, RoomAction, + RoomListAction } from "./KeyBindingsManager"; +import { isMac, Key } from "./Keyboard"; +import SettingsStore from "./settings/SettingsStore"; + +const messageComposerBindings = (): KeyBinding[] => { + const bindings: KeyBinding[] = [ + { + action: MessageComposerAction.SelectPrevSendHistory, + keyCombo: { + key: Key.ARROW_UP, + altKey: true, + ctrlKey: true, + }, + }, + { + action: MessageComposerAction.SelectNextSendHistory, + keyCombo: { + key: Key.ARROW_DOWN, + altKey: true, + ctrlKey: true, + }, + }, + { + action: MessageComposerAction.EditPrevMessage, + keyCombo: { + key: Key.ARROW_UP, + }, + }, + { + action: MessageComposerAction.EditNextMessage, + keyCombo: { + key: Key.ARROW_DOWN, + }, + }, + { + action: MessageComposerAction.CancelEditing, + keyCombo: { + key: Key.ESCAPE, + }, + }, + { + action: MessageComposerAction.FormatBold, + keyCombo: { + key: Key.B, + ctrlOrCmd: true, + }, + }, + { + action: MessageComposerAction.FormatItalics, + keyCombo: { + key: Key.I, + ctrlOrCmd: true, + }, + }, + { + action: MessageComposerAction.FormatQuote, + keyCombo: { + key: Key.GREATER_THAN, + ctrlOrCmd: true, + shiftKey: true, + }, + }, + { + action: MessageComposerAction.EditUndo, + keyCombo: { + key: Key.Z, + ctrlOrCmd: true, + }, + }, + { + action: MessageComposerAction.MoveCursorToStart, + keyCombo: { + key: Key.HOME, + ctrlOrCmd: true, + }, + }, + { + action: MessageComposerAction.MoveCursorToEnd, + keyCombo: { + key: Key.END, + ctrlOrCmd: true, + }, + }, + ]; + if (isMac) { + bindings.push({ + action: MessageComposerAction.EditRedo, + keyCombo: { + key: Key.Z, + ctrlOrCmd: true, + shiftKey: true, + }, + }); + } else { + bindings.push({ + action: MessageComposerAction.EditRedo, + keyCombo: { + key: Key.Y, + ctrlOrCmd: true, + }, + }); + } + if (SettingsStore.getValue('MessageComposerInput.ctrlEnterToSend')) { + bindings.push({ + action: MessageComposerAction.Send, + keyCombo: { + key: Key.ENTER, + ctrlOrCmd: true, + }, + }); + bindings.push({ + action: MessageComposerAction.NewLine, + keyCombo: { + key: Key.ENTER, + }, + }); + } else { + bindings.push({ + action: MessageComposerAction.Send, + keyCombo: { + key: Key.ENTER, + }, + }); + bindings.push({ + action: MessageComposerAction.NewLine, + keyCombo: { + key: Key.ENTER, + shiftKey: true, + }, + }); + if (isMac) { + bindings.push({ + action: MessageComposerAction.NewLine, + keyCombo: { + key: Key.ENTER, + altKey: true, + }, + }); + } + } + return bindings; +} + +const autocompleteBindings = (): KeyBinding[] => { + return [ + { + action: AutocompleteAction.CompleteOrNextSelection, + keyCombo: { + key: Key.TAB, + }, + }, + { + action: AutocompleteAction.CompleteOrNextSelection, + keyCombo: { + key: Key.TAB, + ctrlKey: true, + }, + }, + { + action: AutocompleteAction.CompleteOrPrevSelection, + keyCombo: { + key: Key.TAB, + shiftKey: true, + }, + }, + { + action: AutocompleteAction.CompleteOrPrevSelection, + keyCombo: { + key: Key.TAB, + ctrlKey: true, + shiftKey: true, + }, + }, + { + action: AutocompleteAction.Cancel, + keyCombo: { + key: Key.ESCAPE, + }, + }, + { + action: AutocompleteAction.PrevSelection, + keyCombo: { + key: Key.ARROW_UP, + }, + }, + { + action: AutocompleteAction.NextSelection, + keyCombo: { + key: Key.ARROW_DOWN, + }, + }, + ]; +} + +const roomListBindings = (): KeyBinding[] => { + return [ + { + action: RoomListAction.ClearSearch, + keyCombo: { + key: Key.ESCAPE, + }, + }, + { + action: RoomListAction.PrevRoom, + keyCombo: { + key: Key.ARROW_UP, + }, + }, + { + action: RoomListAction.NextRoom, + keyCombo: { + key: Key.ARROW_DOWN, + }, + }, + { + action: RoomListAction.SelectRoom, + keyCombo: { + key: Key.ENTER, + }, + }, + { + action: RoomListAction.CollapseSection, + keyCombo: { + key: Key.ARROW_LEFT, + }, + }, + { + action: RoomListAction.ExpandSection, + keyCombo: { + key: Key.ARROW_RIGHT, + }, + }, + ]; +} + +const roomBindings = (): KeyBinding[] => { + const bindings: KeyBinding[] = [ + { + action: RoomAction.ScrollUp, + keyCombo: { + key: Key.PAGE_UP, + }, + }, + { + action: RoomAction.RoomScrollDown, + keyCombo: { + key: Key.PAGE_DOWN, + }, + }, + { + action: RoomAction.DismissReadMarker, + keyCombo: { + key: Key.ESCAPE, + }, + }, + { + action: RoomAction.JumpToOldestUnread, + keyCombo: { + key: Key.PAGE_UP, + shiftKey: true, + }, + }, + { + action: RoomAction.UploadFile, + keyCombo: { + key: Key.U, + ctrlOrCmd: true, + shiftKey: true, + }, + }, + { + action: RoomAction.JumpToFirstMessage, + keyCombo: { + key: Key.HOME, + ctrlKey: true, + }, + }, + { + action: RoomAction.JumpToLatestMessage, + keyCombo: { + key: Key.END, + ctrlKey: true, + }, + }, + ]; + + if (SettingsStore.getValue('ctrlFForSearch')) { + bindings.push({ + action: RoomAction.FocusSearch, + keyCombo: { + key: Key.F, + ctrlOrCmd: true, + }, + }); + } + + return bindings; +} + +const navigationBindings = (): KeyBinding[] => { + return [ + { + action: NavigationAction.FocusRoomSearch, + keyCombo: { + key: Key.K, + ctrlOrCmd: true, + }, + }, + { + action: NavigationAction.ToggleRoomSidePanel, + keyCombo: { + key: Key.PERIOD, + ctrlOrCmd: true, + }, + }, + { + action: NavigationAction.ToggleUserMenu, + // Ideally this would be CTRL+P for "Profile", but that's + // taken by the print dialog. CTRL+I for "Information" + // was previously chosen but conflicted with italics in + // composer, so CTRL+` it is + keyCombo: { + key: Key.BACKTICK, + ctrlOrCmd: true, + }, + }, + { + action: NavigationAction.ToggleShortCutDialog, + keyCombo: { + key: Key.SLASH, + ctrlOrCmd: true, + }, + }, + { + action: NavigationAction.ToggleShortCutDialog, + keyCombo: { + key: Key.SLASH, + ctrlOrCmd: true, + shiftKey: true, + }, + }, + { + action: NavigationAction.GoToHome, + keyCombo: { + key: Key.H, + ctrlKey: true, + altKey: !isMac, + shiftKey: isMac, + }, + }, + { + action: NavigationAction.SelectPrevRoom, + keyCombo: { + key: Key.ARROW_UP, + altKey: true, + }, + }, + { + action: NavigationAction.SelectNextRoom, + keyCombo: { + key: Key.ARROW_DOWN, + altKey: true, + }, + }, + { + action: NavigationAction.SelectPrevUnreadRoom, + keyCombo: { + key: Key.ARROW_UP, + altKey: true, + shiftKey: true, + }, + }, + { + action: NavigationAction.SelectNextUnreadRoom, + keyCombo: { + key: Key.ARROW_DOWN, + altKey: true, + shiftKey: true, + }, + }, + ]; +} + +export const defaultBindingsProvider: IKeyBindingsProvider = { + getMessageComposerBindings: messageComposerBindings, + getAutocompleteBindings: autocompleteBindings, + getRoomListBindings: roomListBindings, + getRoomBindings: roomBindings, + getNavigationBindings: navigationBindings, +} diff --git a/src/KeyBindingsManager.ts b/src/KeyBindingsManager.ts new file mode 100644 index 0000000000..aac14bde20 --- /dev/null +++ b/src/KeyBindingsManager.ts @@ -0,0 +1,273 @@ +/* +Copyright 2021 Clemens Zeidler + +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 { defaultBindingsProvider } from './KeyBindingsDefaults'; +import { isMac } from './Keyboard'; + +/** Actions for the chat message composer component */ +export enum MessageComposerAction { + /** Send a message */ + Send = 'Send', + /** Go backwards through the send history and use the message in composer view */ + SelectPrevSendHistory = 'SelectPrevSendHistory', + /** Go forwards through the send history */ + SelectNextSendHistory = 'SelectNextSendHistory', + /** Start editing the user's last sent message */ + EditPrevMessage = 'EditPrevMessage', + /** Start editing the user's next sent message */ + EditNextMessage = 'EditNextMessage', + /** Cancel editing a message or cancel replying to a message */ + CancelEditing = 'CancelEditing', + + /** Set bold format the current selection */ + FormatBold = 'FormatBold', + /** Set italics format the current selection */ + FormatItalics = 'FormatItalics', + /** Format the current selection as quote */ + FormatQuote = 'FormatQuote', + /** Undo the last editing */ + EditUndo = 'EditUndo', + /** Redo editing */ + EditRedo = 'EditRedo', + /** Insert new line */ + NewLine = 'NewLine', + /** Move the cursor to the start of the message */ + MoveCursorToStart = 'MoveCursorToStart', + /** Move the cursor to the end of the message */ + MoveCursorToEnd = 'MoveCursorToEnd', +} + +/** Actions for text editing autocompletion */ +export enum AutocompleteAction { + /** + * Select previous selection or, if the autocompletion window is not shown, open the window and select the first + * selection. + */ + CompleteOrPrevSelection = 'ApplySelection', + /** Select next selection or, if the autocompletion window is not shown, open it and select the first selection */ + CompleteOrNextSelection = 'CompleteOrNextSelection', + /** Move to the previous autocomplete selection */ + PrevSelection = 'PrevSelection', + /** Move to the next autocomplete selection */ + NextSelection = 'NextSelection', + /** Close the autocompletion window */ + Cancel = 'Cancel', +} + +/** Actions for the room list sidebar */ +export enum RoomListAction { + /** Clear room list filter field */ + ClearSearch = 'ClearSearch', + /** Navigate up/down in the room list */ + PrevRoom = 'PrevRoom', + /** Navigate down in the room list */ + NextRoom = 'NextRoom', + /** Select room from the room list */ + SelectRoom = 'SelectRoom', + /** Collapse room list section */ + CollapseSection = 'CollapseSection', + /** Expand room list section, if already expanded, jump to first room in the selection */ + ExpandSection = 'ExpandSection', +} + +/** Actions for the current room view */ +export enum RoomAction { + /** Scroll up in the timeline */ + ScrollUp = 'ScrollUp', + /** Scroll down in the timeline */ + RoomScrollDown = 'RoomScrollDown', + /** Dismiss read marker and jump to bottom */ + DismissReadMarker = 'DismissReadMarker', + /** Jump to oldest unread message */ + JumpToOldestUnread = 'JumpToOldestUnread', + /** Upload a file */ + UploadFile = 'UploadFile', + /** Focus search message in a room (must be enabled) */ + FocusSearch = 'FocusSearch', + /** Jump to the first (downloaded) message in the room */ + JumpToFirstMessage = 'JumpToFirstMessage', + /** Jump to the latest message in the room */ + JumpToLatestMessage = 'JumpToLatestMessage', +} + +/** Actions for navigating do various menus, dialogs or screens */ +export enum NavigationAction { + /** Jump to room search (search for a room) */ + FocusRoomSearch = 'FocusRoomSearch', + /** Toggle the room side panel */ + ToggleRoomSidePanel = 'ToggleRoomSidePanel', + /** Toggle the user menu */ + ToggleUserMenu = 'ToggleUserMenu', + /** Toggle the short cut help dialog */ + ToggleShortCutDialog = 'ToggleShortCutDialog', + /** Got to the Element home screen */ + GoToHome = 'GoToHome', + /** Select prev room */ + SelectPrevRoom = 'SelectPrevRoom', + /** Select next room */ + SelectNextRoom = 'SelectNextRoom', + /** Select prev room with unread messages */ + SelectPrevUnreadRoom = 'SelectPrevUnreadRoom', + /** Select next room with unread messages */ + SelectNextUnreadRoom = 'SelectNextUnreadRoom', +} + +/** + * Represent a key combination. + * + * The combo is evaluated strictly, i.e. the KeyboardEvent must match exactly what is specified in the KeyCombo. + */ +export type KeyCombo = { + key?: string; + + /** On PC: ctrl is pressed; on Mac: meta is pressed */ + ctrlOrCmd?: boolean; + + altKey?: boolean; + ctrlKey?: boolean; + metaKey?: boolean; + shiftKey?: boolean; +} + +export type KeyBinding = { + action: T; + keyCombo: KeyCombo; +} + +/** + * Helper method to check if a KeyboardEvent matches a KeyCombo + * + * Note, this method is only exported for testing. + */ +export function isKeyComboMatch(ev: KeyboardEvent | React.KeyboardEvent, combo: KeyCombo, onMac: boolean): boolean { + if (combo.key !== undefined) { + // When shift is pressed, letters are returned as upper case chars. In this case do a lower case comparison. + // This works for letter combos such as shift + U as well for none letter combos such as shift + Escape. + // If shift is not pressed, the toLowerCase conversion can be avoided. + if (ev.shiftKey) { + if (ev.key.toLowerCase() !== combo.key.toLowerCase()) { + return false; + } + } else if (ev.key !== combo.key) { + return false; + } + } + + const comboCtrl = combo.ctrlKey ?? false; + const comboAlt = combo.altKey ?? false; + const comboShift = combo.shiftKey ?? false; + const comboMeta = combo.metaKey ?? false; + // Tests mock events may keep the modifiers undefined; convert them to booleans + const evCtrl = ev.ctrlKey ?? false; + const evAlt = ev.altKey ?? false; + const evShift = ev.shiftKey ?? false; + const evMeta = ev.metaKey ?? false; + // When ctrlOrCmd is set, the keys need do evaluated differently on PC and Mac + if (combo.ctrlOrCmd) { + if (onMac) { + if (!evMeta + || evCtrl !== comboCtrl + || evAlt !== comboAlt + || evShift !== comboShift) { + return false; + } + } else { + if (!evCtrl + || evMeta !== comboMeta + || evAlt !== comboAlt + || evShift !== comboShift) { + return false; + } + } + return true; + } + + if (evMeta !== comboMeta + || evCtrl !== comboCtrl + || evAlt !== comboAlt + || evShift !== comboShift) { + return false; + } + + return true; +} + +export type KeyBindingGetter = () => KeyBinding[]; + +export interface IKeyBindingsProvider { + getMessageComposerBindings: KeyBindingGetter; + getAutocompleteBindings: KeyBindingGetter; + getRoomListBindings: KeyBindingGetter; + getRoomBindings: KeyBindingGetter; + getNavigationBindings: KeyBindingGetter; +} + +export class KeyBindingsManager { + /** + * List of key bindings providers. + * + * Key bindings from the first provider(s) in the list will have precedence over key bindings from later providers. + * + * To overwrite the default key bindings add a new providers before the default provider, e.g. a provider for + * customized key bindings. + */ + bindingsProviders: IKeyBindingsProvider[] = [ + defaultBindingsProvider, + ]; + + /** + * Finds a matching KeyAction for a given KeyboardEvent + */ + private getAction( + getters: KeyBindingGetter[], + ev: KeyboardEvent | React.KeyboardEvent, + ): T | undefined { + for (const getter of getters) { + const bindings = getter(); + const binding = bindings.find(it => isKeyComboMatch(ev, it.keyCombo, isMac)); + if (binding) { + return binding.action; + } + } + return undefined; + } + + getMessageComposerAction(ev: KeyboardEvent | React.KeyboardEvent): MessageComposerAction | undefined { + return this.getAction(this.bindingsProviders.map(it => it.getMessageComposerBindings), ev); + } + + getAutocompleteAction(ev: KeyboardEvent | React.KeyboardEvent): AutocompleteAction | undefined { + return this.getAction(this.bindingsProviders.map(it => it.getAutocompleteBindings), ev); + } + + getRoomListAction(ev: KeyboardEvent | React.KeyboardEvent): RoomListAction | undefined { + return this.getAction(this.bindingsProviders.map(it => it.getRoomListBindings), ev); + } + + getRoomAction(ev: KeyboardEvent | React.KeyboardEvent): RoomAction | undefined { + return this.getAction(this.bindingsProviders.map(it => it.getRoomBindings), ev); + } + + getNavigationAction(ev: KeyboardEvent | React.KeyboardEvent): NavigationAction | undefined { + return this.getAction(this.bindingsProviders.map(it => it.getNavigationBindings), ev); + } +} + +const manager = new KeyBindingsManager(); + +export function getKeyBindingsManager(): KeyBindingsManager { + return manager; +} diff --git a/src/Lifecycle.ts b/src/Lifecycle.ts index 7780d4c87a..b0a1292ba1 100644 --- a/src/Lifecycle.ts +++ b/src/Lifecycle.ts @@ -17,8 +17,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -// @ts-ignore - XXX: tsc doesn't like this: our js-sdk imports are complex so this isn't surprising -import Matrix from 'matrix-js-sdk'; +import { createClient } from 'matrix-js-sdk/src/matrix'; import { InvalidStoreError } from "matrix-js-sdk/src/errors"; import { MatrixClient } from "matrix-js-sdk/src/client"; import {decryptAES, encryptAES} from "matrix-js-sdk/src/crypto/aes"; @@ -219,7 +218,7 @@ export function attemptTokenLogin( button: _t("Try again"), onFinished: tryAgain => { if (tryAgain) { - const cli = Matrix.createClient({ + const cli = createClient({ baseUrl: homeserver, idBaseUrl: identityServer, }); @@ -276,7 +275,7 @@ function registerAsGuest( console.log(`Doing guest login on ${hsUrl}`); // create a temporary MatrixClient to do the login - const client = Matrix.createClient({ + const client = createClient({ baseUrl: hsUrl, }); diff --git a/src/Livestream.ts b/src/Livestream.ts new file mode 100644 index 0000000000..2389132762 --- /dev/null +++ b/src/Livestream.ts @@ -0,0 +1,55 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { ClientWidgetApi } from "matrix-widget-api"; +import { MatrixClientPeg } from "./MatrixClientPeg"; +import SdkConfig from "./SdkConfig"; +import { ElementWidgetActions } from "./stores/widgets/ElementWidgetActions"; + +export function getConfigLivestreamUrl() { + return SdkConfig.get()["audioStreamUrl"]; +} + +// Dummy rtmp URL used to signal that we want a special audio-only stream +const AUDIOSTREAM_DUMMY_URL = 'rtmp://audiostream.dummy/'; + +async function createLiveStream(roomId: string) { + const openIdToken = await MatrixClientPeg.get().getOpenIdToken(); + + const url = getConfigLivestreamUrl() + "/createStream"; + + const response = await window.fetch(url, { + method: 'POST', + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + room_id: roomId, + openid_token: openIdToken, + }), + }); + + const respBody = await response.json(); + return respBody['stream_id']; +} + +export async function startJitsiAudioLivestream(widgetMessaging: ClientWidgetApi, roomId: string) { + const streamId = await createLiveStream(roomId); + + await widgetMessaging.transport.send(ElementWidgetActions.StartLiveStream, { + rtmpStreamKey: AUDIOSTREAM_DUMMY_URL + streamId, + }); +} diff --git a/src/Login.ts b/src/Login.ts index aecc0493c7..d584df7dfe 100644 --- a/src/Login.ts +++ b/src/Login.ts @@ -1,9 +1,6 @@ /* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2017 Vector Creations Ltd -Copyright 2018 New Vector Ltd +Copyright 2015-2021 The Matrix.org Foundation C.I.C. Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> -Copyright 2020 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -19,7 +16,7 @@ limitations under the License. */ // @ts-ignore - XXX: tsc doesn't like this: our js-sdk imports are complex so this isn't surprising -import Matrix from "matrix-js-sdk"; +import {createClient} from "matrix-js-sdk/src/matrix"; import { MatrixClient } from "matrix-js-sdk/src/client"; import { IMatrixClientCreds } from "./MatrixClientPeg"; import SecurityCustomisations from "./customisations/Security"; @@ -59,7 +56,7 @@ export type LoginFlow = ISSOFlow | IPasswordFlow; // TODO: Move this to JS SDK /* eslint-disable camelcase */ interface ILoginParams { - identifier?: string; + identifier?: object; password?: string; token?: string; device_id?: string; @@ -115,7 +112,7 @@ export default class Login { */ public createTemporaryClient(): MatrixClient { if (this.tempClient) return this.tempClient; // use memoization - return this.tempClient = Matrix.createClient({ + return this.tempClient = createClient({ baseUrl: this.hsUrl, idBaseUrl: this.isUrl, }); @@ -210,7 +207,7 @@ export async function sendLoginRequest( loginType: string, loginParams: ILoginParams, ): Promise { - const client = Matrix.createClient({ + const client = createClient({ baseUrl: hsUrl, idBaseUrl: isUrl, }); diff --git a/src/MatrixClientPeg.ts b/src/MatrixClientPeg.ts index 98ca446532..7db5ed1a4e 100644 --- a/src/MatrixClientPeg.ts +++ b/src/MatrixClientPeg.ts @@ -261,7 +261,7 @@ class _MatrixClientPeg implements IMatrixClientPeg { } public getHomeserverName(): string { - const matches = /^@.+:(.+)$/.exec(this.matrixClient.credentials.userId); + const matches = /^@[^:]+:(.+)$/.exec(this.matrixClient.credentials.userId); if (matches === null || matches.length < 1) { throw new Error("Failed to derive homeserver name from user ID!"); } @@ -296,10 +296,11 @@ class _MatrixClientPeg implements IMatrixClientPeg { // These are always installed regardless of the labs flag so that // cross-signing features can toggle on without reloading and also be // accessed immediately after login. - const customisedCallbacks = { - getDehydrationKey: SecurityCustomisations.getDehydrationKey, - }; - Object.assign(opts.cryptoCallbacks, crossSigningCallbacks, customisedCallbacks); + Object.assign(opts.cryptoCallbacks, crossSigningCallbacks); + if (SecurityCustomisations.getDehydrationKey) { + opts.cryptoCallbacks.getDehydrationKey = + SecurityCustomisations.getDehydrationKey; + } this.matrixClient = createMatrixClient(opts); diff --git a/src/Modal.tsx b/src/Modal.tsx index ab582b9b22..ce11c571b6 100644 --- a/src/Modal.tsx +++ b/src/Modal.tsx @@ -36,6 +36,7 @@ export interface IModal { onBeforeClose?(reason?: string): Promise; onFinished(...args: T): void; close(...args: T): void; + hidden?: boolean; } export interface IHandle { @@ -93,6 +94,12 @@ export class ModalManager { return container; } + public toggleCurrentDialogVisibility() { + const modal = this.getCurrentModal(); + if (!modal) return; + modal.hidden = !modal.hidden; + } + public hasDialogs() { return this.priorityModal || this.staticModal || this.modals.length > 0; } @@ -364,7 +371,7 @@ export class ModalManager { } const modal = this.getCurrentModal(); - if (modal !== this.staticModal) { + if (modal !== this.staticModal && !modal.hidden) { const classes = classNames("mx_Dialog_wrapper", modal.className, { mx_Dialog_wrapperWithStaticUnder: this.staticModal, }); diff --git a/src/Velociraptor.js b/src/NodeAnimator.js similarity index 59% rename from src/Velociraptor.js rename to src/NodeAnimator.js index 2da54babe5..8456e6e9fd 100644 --- a/src/Velociraptor.js +++ b/src/NodeAnimator.js @@ -1,16 +1,15 @@ import React from "react"; import ReactDom from "react-dom"; -import Velocity from "velocity-animate"; import PropTypes from 'prop-types'; /** - * The Velociraptor contains components and animates transitions with velocity. + * The NodeAnimator contains components and animates transitions. * It will only pick up direct changes to properties ('left', currently), and so * will not work for animating positional changes where the position is implicit * from DOM order. This makes it a lot simpler and lighter: if you need fully * automatic positional animation, look at react-shuffle or similar libraries. */ -export default class Velociraptor extends React.Component { +export default class NodeAnimator extends React.Component { static propTypes = { // either a list of child nodes, or a single child. children: PropTypes.any, @@ -20,14 +19,10 @@ export default class Velociraptor extends React.Component { // a list of state objects to apply to each child node in turn startStyles: PropTypes.array, - - // a list of transition options from the corresponding startStyle - enterTransitionOpts: PropTypes.array, }; static defaultProps = { startStyles: [], - enterTransitionOpts: [], }; constructor(props) { @@ -41,6 +36,18 @@ export default class Velociraptor extends React.Component { this._updateChildren(this.props.children); } + /** + * + * @param {HTMLElement} node element to apply styles to + * @param {object} styles a key/value pair of CSS properties + * @returns {void} + */ + _applyStyles(node, styles) { + Object.entries(styles).forEach(([property, value]) => { + node.style[property] = value; + }); + } + _updateChildren(newChildren) { const oldChildren = this.children || {}; this.children = {}; @@ -50,17 +57,8 @@ export default class Velociraptor extends React.Component { const oldNode = ReactDom.findDOMNode(this.nodes[old.key]); if (oldNode && oldNode.style.left !== c.props.style.left) { - Velocity(oldNode, { left: c.props.style.left }, this.props.transition).then(() => { - // special case visibility because it's nonsensical to animate an invisible element - // so we always hidden->visible pre-transition and visible->hidden after - if (oldNode.style.visibility === 'visible' && c.props.style.visibility === 'hidden') { - oldNode.style.visibility = c.props.style.visibility; - } - }); - //console.log("translation: "+oldNode.style.left+" -> "+c.props.style.left); - } - if (oldNode && oldNode.style.visibility === 'hidden' && c.props.style.visibility === 'visible') { - oldNode.style.visibility = c.props.style.visibility; + this._applyStyles(oldNode, { left: c.props.style.left }); + // console.log("translation: "+oldNode.style.left+" -> "+c.props.style.left); } // clone the old element with the props (and children) of the new element // so prop updates are still received by the children. @@ -94,33 +92,22 @@ export default class Velociraptor extends React.Component { this.props.startStyles.length > 0 ) { const startStyles = this.props.startStyles; - const transitionOpts = this.props.enterTransitionOpts; const domNode = ReactDom.findDOMNode(node); // start from startStyle 1: 0 is the one we gave it // to start with, so now we animate 1 etc. - for (var i = 1; i < startStyles.length; ++i) { - Velocity(domNode, startStyles[i], transitionOpts[i-1]); - /* - console.log("start:", - JSON.stringify(transitionOpts[i-1]), - "->", - JSON.stringify(startStyles[i]), - ); - */ + for (let i = 1; i < startStyles.length; ++i) { + this._applyStyles(domNode, startStyles[i]); + // console.log("start:" + // JSON.stringify(startStyles[i]), + // ); } // and then we animate to the resting state - Velocity(domNode, restingStyle, - transitionOpts[i-1]) - .then(() => { - // once we've reached the resting state, hide the element if - // appropriate - domNode.style.visibility = restingStyle.visibility; - }); + setTimeout(() => { + this._applyStyles(domNode, restingStyle); + }, 0); // console.log("enter:", - // JSON.stringify(transitionOpts[i-1]), - // "->", // JSON.stringify(restingStyle)); } this.nodes[k] = node; @@ -128,9 +115,7 @@ export default class Velociraptor extends React.Component { render() { return ( - - { Object.values(this.children) } - + <>{ Object.values(this.children) } ); } } diff --git a/src/Notifier.ts b/src/Notifier.ts index 6460be20ad..3e927cea0c 100644 --- a/src/Notifier.ts +++ b/src/Notifier.ts @@ -36,6 +36,7 @@ import {SettingLevel} from "./settings/SettingLevel"; import {isPushNotifyDisabled} from "./settings/controllers/NotificationControllers"; import RoomViewStore from "./stores/RoomViewStore"; import UserActivity from "./UserActivity"; +import {mediaFromMxc} from "./customisations/Media"; /* * Dispatches: @@ -150,7 +151,7 @@ export const Notifier = { // Ideally in here we could use MSC1310 to detect the type of file, and reject it. return { - url: MatrixClientPeg.get().mxcUrlToHttp(content.url), + url: mediaFromMxc(content.url).srcHttp, name: content.name, type: content.type, size: content.size, @@ -382,6 +383,10 @@ export const Notifier = { // don't bother notifying as user was recently active in this room return; } + if (SettingsStore.getValue("doNotDisturb")) { + // Don't bother the user if they didn't ask to be bothered + return; + } if (this.isEnabled()) { this._displayPopupNotification(ev, room); diff --git a/src/PasswordReset.js b/src/PasswordReset.js index b38a9de960..6fe6ca82cc 100644 --- a/src/PasswordReset.js +++ b/src/PasswordReset.js @@ -15,7 +15,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import * as Matrix from 'matrix-js-sdk'; +import { createClient } from 'matrix-js-sdk/src/matrix'; import { _t } from './languageHandler'; /** @@ -32,7 +32,7 @@ export default class PasswordReset { * @param {string} identityUrl The URL to the IS which has linked the email -> mxid mapping. */ constructor(homeserverUrl, identityUrl) { - this.client = Matrix.createClient({ + this.client = createClient({ baseUrl: homeserverUrl, idBaseUrl: identityUrl, }); diff --git a/src/Presence.ts b/src/Presence.ts index 660bb0ac94..eb56c5714e 100644 --- a/src/Presence.ts +++ b/src/Presence.ts @@ -99,9 +99,9 @@ class Presence { try { await MatrixClientPeg.get().setPresence(this.state); - console.info("Presence: %s", newState); + console.info("Presence:", newState); } catch (err) { - console.error("Failed to set presence: %s", err); + console.error("Failed to set presence:", err); this.state = oldState; } } diff --git a/src/Resend.js b/src/Resend.js index 5638313306..f1e5fb38f5 100644 --- a/src/Resend.js +++ b/src/Resend.js @@ -17,15 +17,15 @@ limitations under the License. import {MatrixClientPeg} from './MatrixClientPeg'; import dis from './dispatcher/dispatcher'; -import { EventStatus } from 'matrix-js-sdk'; +import { EventStatus } from 'matrix-js-sdk/src/models/event'; export default class Resend { static resendUnsentEvents(room) { - room.getPendingEvents().filter(function(ev) { + return Promise.all(room.getPendingEvents().filter(function(ev) { return ev.status === EventStatus.NOT_SENT; - }).forEach(function(event) { - Resend.resend(event); - }); + }).map(function(event) { + return Resend.resend(event); + })); } static cancelUnsentEvents(room) { @@ -38,7 +38,7 @@ export default class Resend { static resend(event) { const room = MatrixClientPeg.get().getRoom(event.getRoomId()); - MatrixClientPeg.get().resendEvent(event, room).then(function(res) { + return MatrixClientPeg.get().resendEvent(event, room).then(function(res) { dis.dispatch({ action: 'message_sent', event: event, diff --git a/src/RoomInvite.js b/src/RoomInvite.js index 503411d2b3..aa758ecbdc 100644 --- a/src/RoomInvite.js +++ b/src/RoomInvite.js @@ -22,7 +22,7 @@ import MultiInviter from './utils/MultiInviter'; import Modal from './Modal'; import * as sdk from './'; import { _t } from './languageHandler'; -import InviteDialog, {KIND_DM, KIND_INVITE, KIND_SPACE_INVITE} from "./components/views/dialogs/InviteDialog"; +import InviteDialog, {KIND_DM, KIND_INVITE} from "./components/views/dialogs/InviteDialog"; import CommunityPrototypeInviteDialog from "./components/views/dialogs/CommunityPrototypeInviteDialog"; import {CommunityPrototypeStore} from "./stores/CommunityPrototypeStore"; @@ -49,12 +49,12 @@ export function showStartChatInviteDialog(initialText) { ); } -export function showRoomInviteDialog(roomId) { - const isSpace = MatrixClientPeg.get()?.getRoom(roomId)?.isSpaceRoom(); +export function showRoomInviteDialog(roomId, initialText = "") { // This dialog handles the room creation internally - we don't need to worry about it. Modal.createTrackedDialog( - "Invite Users", isSpace ? "Space" : "Room", InviteDialog, { - kind: isSpace ? KIND_SPACE_INVITE : KIND_INVITE, + "Invite Users", "", InviteDialog, { + kind: KIND_INVITE, + initialText, roomId, }, /*className=*/null, /*isPriority=*/false, /*isStatic=*/true, diff --git a/src/ScalarAuthClient.js b/src/ScalarAuthClient.ts similarity index 83% rename from src/ScalarAuthClient.js rename to src/ScalarAuthClient.ts index 1ea9d39e2f..a09c3494a8 100644 --- a/src/ScalarAuthClient.js +++ b/src/ScalarAuthClient.ts @@ -1,6 +1,5 @@ /* -Copyright 2016 OpenMarket Ltd -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2016, 2019, 2021 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -17,13 +16,14 @@ limitations under the License. import url from 'url'; import SettingsStore from "./settings/SettingsStore"; -import { Service, startTermsFlow, TermsNotSignedError } from './Terms'; +import { Service, startTermsFlow, TermsInteractionCallback, TermsNotSignedError } from './Terms'; import {MatrixClientPeg} from "./MatrixClientPeg"; import request from "browser-request"; -import * as Matrix from 'matrix-js-sdk'; import SdkConfig from "./SdkConfig"; import {WidgetType} from "./widgets/WidgetType"; +import {SERVICE_TYPES} from "matrix-js-sdk/src/service-types"; +import { Room } from "matrix-js-sdk/src/models/room"; // The version of the integration manager API we're intending to work with const imApiVersion = "1.1"; @@ -31,9 +31,11 @@ const imApiVersion = "1.1"; // TODO: Generify the name of this class and all components within - it's not just for Scalar. export default class ScalarAuthClient { - constructor(apiUrl, uiUrl) { - this.apiUrl = apiUrl; - this.uiUrl = uiUrl; + private scalarToken: string; + private termsInteractionCallback: TermsInteractionCallback; + private isDefaultManager: boolean; + + constructor(private apiUrl: string, private uiUrl: string) { this.scalarToken = null; // `undefined` to allow `startTermsFlow` to fallback to a default // callback if this is unset. @@ -46,7 +48,7 @@ export default class ScalarAuthClient { this.isDefaultManager = apiUrl === configApiUrl && configUiUrl === uiUrl; } - _writeTokenToStore() { + private writeTokenToStore() { window.localStorage.setItem("mx_scalar_token_at_" + this.apiUrl, this.scalarToken); if (this.isDefaultManager) { // We remove the old token from storage to migrate upwards. This is safe @@ -56,7 +58,7 @@ export default class ScalarAuthClient { } } - _readTokenFromStore() { + private readTokenFromStore(): string { let token = window.localStorage.getItem("mx_scalar_token_at_" + this.apiUrl); if (!token && this.isDefaultManager) { token = window.localStorage.getItem("mx_scalar_token"); @@ -64,33 +66,33 @@ export default class ScalarAuthClient { return token; } - _readToken() { + private readToken(): string { if (this.scalarToken) return this.scalarToken; - return this._readTokenFromStore(); + return this.readTokenFromStore(); } setTermsInteractionCallback(callback) { this.termsInteractionCallback = callback; } - connect() { + connect(): Promise { return this.getScalarToken().then((tok) => { this.scalarToken = tok; }); } - hasCredentials() { + hasCredentials(): boolean { return this.scalarToken != null; // undef or null } // Returns a promise that resolves to a scalar_token string - getScalarToken() { - const token = this._readToken(); + getScalarToken(): Promise { + const token = this.readToken(); if (!token) { return this.registerForToken(); } else { - return this._checkToken(token).catch((e) => { + return this.checkToken(token).catch((e) => { if (e instanceof TermsNotSignedError) { // retrying won't help this throw e; @@ -100,7 +102,7 @@ export default class ScalarAuthClient { } } - _getAccountName(token) { + private getAccountName(token: string): Promise { const url = this.apiUrl + "/account"; return new Promise(function(resolve, reject) { @@ -125,8 +127,8 @@ export default class ScalarAuthClient { }); } - _checkToken(token) { - return this._getAccountName(token).then(userId => { + private checkToken(token: string): Promise { + return this.getAccountName(token).then(userId => { const me = MatrixClientPeg.get().getUserId(); if (userId !== me) { throw new Error("Scalar token is owned by someone else: " + me); @@ -153,8 +155,8 @@ export default class ScalarAuthClient { parsedImRestUrl.path = ''; parsedImRestUrl.pathname = ''; return startTermsFlow([new Service( - Matrix.SERVICE_TYPES.IM, - parsedImRestUrl.format(), + SERVICE_TYPES.IM, + url.format(parsedImRestUrl), token, )], this.termsInteractionCallback).then(() => { return token; @@ -165,22 +167,22 @@ export default class ScalarAuthClient { }); } - registerForToken() { + registerForToken(): Promise { // Get openid bearer token from the HS as the first part of our dance return MatrixClientPeg.get().getOpenIdToken().then((tokenObject) => { // Now we can send that to scalar and exchange it for a scalar token return this.exchangeForScalarToken(tokenObject); }).then((token) => { // Validate it (this mostly checks to see if the IM needs us to agree to some terms) - return this._checkToken(token); + return this.checkToken(token); }).then((token) => { this.scalarToken = token; - this._writeTokenToStore(); + this.writeTokenToStore(); return token; }); } - exchangeForScalarToken(openidTokenObject) { + exchangeForScalarToken(openidTokenObject: any): Promise { const scalarRestUrl = this.apiUrl; return new Promise(function(resolve, reject) { @@ -194,7 +196,7 @@ export default class ScalarAuthClient { if (err) { reject(err); } else if (response.statusCode / 100 !== 2) { - reject({statusCode: response.statusCode}); + reject(new Error(`Scalar request failed: ${response.statusCode}`)); } else if (!body || !body.scalar_token) { reject(new Error("Missing scalar_token in response")); } else { @@ -204,7 +206,7 @@ export default class ScalarAuthClient { }); } - getScalarPageTitle(url) { + getScalarPageTitle(url: string): Promise { let scalarPageLookupUrl = this.apiUrl + '/widgets/title_lookup'; scalarPageLookupUrl = this.getStarterLink(scalarPageLookupUrl); scalarPageLookupUrl += '&curl=' + encodeURIComponent(url); @@ -218,7 +220,7 @@ export default class ScalarAuthClient { if (err) { reject(err); } else if (response.statusCode / 100 !== 2) { - reject({statusCode: response.statusCode}); + reject(new Error(`Scalar request failed: ${response.statusCode}`)); } else if (!body) { reject(new Error("Missing page title in response")); } else { @@ -240,10 +242,10 @@ export default class ScalarAuthClient { * @param {string} widgetId The widget ID to disable assets for * @return {Promise} Resolves on completion */ - disableWidgetAssets(widgetType: WidgetType, widgetId) { + disableWidgetAssets(widgetType: WidgetType, widgetId: string): Promise { let url = this.apiUrl + '/widgets/set_assets_state'; url = this.getStarterLink(url); - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { request({ method: 'GET', // XXX: Actions shouldn't be GET requests uri: url, @@ -257,7 +259,7 @@ export default class ScalarAuthClient { if (err) { reject(err); } else if (response.statusCode / 100 !== 2) { - reject({statusCode: response.statusCode}); + reject(new Error(`Scalar request failed: ${response.statusCode}`)); } else if (!body) { reject(new Error("Failed to set widget assets state")); } else { @@ -267,7 +269,7 @@ export default class ScalarAuthClient { }); } - getScalarInterfaceUrlForRoom(room, screen, id) { + getScalarInterfaceUrlForRoom(room: Room, screen: string, id: string): string { const roomId = room.roomId; const roomName = room.name; let url = this.uiUrl; @@ -284,7 +286,7 @@ export default class ScalarAuthClient { return url; } - getStarterLink(starterLinkUrl) { + getStarterLink(starterLinkUrl: string): string { return starterLinkUrl + "?scalar_token=" + encodeURIComponent(this.scalarToken); } } diff --git a/src/ScalarMessaging.js b/src/ScalarMessaging.js index 896e27d92c..3f75b3788c 100644 --- a/src/ScalarMessaging.js +++ b/src/ScalarMessaging.js @@ -237,7 +237,7 @@ Example: */ import {MatrixClientPeg} from './MatrixClientPeg'; -import { MatrixEvent } from 'matrix-js-sdk'; +import { MatrixEvent } from 'matrix-js-sdk/src/models/event'; import dis from './dispatcher/dispatcher'; import WidgetUtils from './utils/WidgetUtils'; import RoomViewStore from './stores/RoomViewStore'; diff --git a/src/SecurityManager.ts b/src/SecurityManager.ts index 03cbe88c22..203830d232 100644 --- a/src/SecurityManager.ts +++ b/src/SecurityManager.ts @@ -395,6 +395,8 @@ export async function accessSecretStorage(func = async () => { }, forceReset = f } catch (e) { SecurityCustomisations.catchAccessSecretStorageError?.(e); console.error(e); + // Re-throw so that higher level logic can abort as needed + throw e; } finally { // Clear secret storage key cache now that work is complete secretStorageBeingAccessed = false; diff --git a/src/Skinner.js b/src/Skinner.js index d17bc1782a..ef340e4052 100644 --- a/src/Skinner.js +++ b/src/Skinner.js @@ -23,7 +23,7 @@ class Skinner { if (!name) throw new Error(`Invalid component name: ${name}`); if (this.components === null) { throw new Error( - "Attempted to get a component before a skin has been loaded."+ + `Attempted to get a component (${name}) before a skin has been loaded.`+ " This is probably because either:"+ " a) Your app has not called sdk.loadSkin(), or"+ " b) A component has called getComponent at the root level", diff --git a/src/SlashCommands.tsx b/src/SlashCommands.tsx index 6b5f261374..6ce1439164 100644 --- a/src/SlashCommands.tsx +++ b/src/SlashCommands.tsx @@ -20,6 +20,7 @@ limitations under the License. import * as React from 'react'; +import * as ContentHelpers from 'matrix-js-sdk/src/content-helpers'; import {MatrixClientPeg} from './MatrixClientPeg'; import dis from './dispatcher/dispatcher'; import * as sdk from './index'; @@ -126,10 +127,10 @@ export class Command { return this.getCommand() + " " + this.args; } - run(roomId: string, args: string, cmd: string) { + run(roomId: string, args: string) { // if it has no runFn then its an ignored/nop command (autocomplete only) e.g `/me` if (!this.runFn) return reject(_t("Command error")); - return this.runFn.bind(this)(roomId, args, cmd); + return this.runFn.bind(this)(roomId, args); } getUsage() { @@ -154,6 +155,18 @@ function success(promise?: Promise) { */ export const Commands = [ + new Command({ + command: 'spoiler', + args: '', + description: _td('Sends the given message as a spoiler'), + runFn: function(roomId, message) { + return success(ContentHelpers.makeHtmlMessage( + message, + `${message}`, + )); + }, + category: CommandCategories.messages, + }), new Command({ command: 'shrug', args: '', @@ -163,7 +176,7 @@ export const Commands = [ if (args) { message = message + ' ' + args; } - return success(MatrixClientPeg.get().sendTextMessage(roomId, message)); + return success(ContentHelpers.makeTextMessage(message)); }, category: CommandCategories.messages, }), @@ -176,7 +189,7 @@ export const Commands = [ if (args) { message = message + ' ' + args; } - return success(MatrixClientPeg.get().sendTextMessage(roomId, message)); + return success(ContentHelpers.makeTextMessage(message)); }, category: CommandCategories.messages, }), @@ -189,7 +202,7 @@ export const Commands = [ if (args) { message = message + ' ' + args; } - return success(MatrixClientPeg.get().sendTextMessage(roomId, message)); + return success(ContentHelpers.makeTextMessage(message)); }, category: CommandCategories.messages, }), @@ -202,7 +215,7 @@ export const Commands = [ if (args) { message = message + ' ' + args; } - return success(MatrixClientPeg.get().sendTextMessage(roomId, message)); + return success(ContentHelpers.makeTextMessage(message)); }, category: CommandCategories.messages, }), @@ -211,7 +224,7 @@ export const Commands = [ args: '', description: _td('Sends a message as plain text, without interpreting it as markdown'), runFn: function(roomId, messages) { - return success(MatrixClientPeg.get().sendTextMessage(roomId, messages)); + return success(ContentHelpers.makeTextMessage(messages)); }, category: CommandCategories.messages, }), @@ -220,7 +233,7 @@ export const Commands = [ args: '', description: _td('Sends a message as html, without interpreting it as markdown'), runFn: function(roomId, messages) { - return success(MatrixClientPeg.get().sendHtmlMessage(roomId, messages, messages)); + return success(ContentHelpers.makeHtmlMessage(messages, messages)); }, category: CommandCategories.messages, }), @@ -441,15 +454,14 @@ export const Commands = [ }), new Command({ command: 'invite', - args: '', + args: ' []', description: _td('Invites user with given id to current room'), runFn: function(roomId, args) { if (args) { - const matches = args.match(/^(\S+)$/); - if (matches) { + const [address, reason] = args.split(/\s+(.+)/); + if (address) { // We use a MultiInviter to re-use the invite logic, even though // we're only inviting one user. - const address = matches[1]; // If we need an identity server but don't have one, things // get a bit more complex here, but we try to show something // meaningful. @@ -490,7 +502,7 @@ export const Commands = [ } const inviter = new MultiInviter(roomId); return success(prom.then(() => { - return inviter.invite([address]); + return inviter.invite([address], reason); }).then(() => { if (inviter.getCompletionState(address) !== "invited") { throw new Error(inviter.getErrorText(address)); @@ -966,7 +978,7 @@ export const Commands = [ args: '', runFn: function(roomId, args) { if (!args) return reject(this.getUserId()); - return success(MatrixClientPeg.get().sendHtmlMessage(roomId, args, textToHtmlRainbow(args))); + return success(ContentHelpers.makeHtmlMessage(args, textToHtmlRainbow(args))); }, category: CommandCategories.messages, }), @@ -976,7 +988,7 @@ export const Commands = [ args: '', runFn: function(roomId, args) { if (!args) return reject(this.getUserId()); - return success(MatrixClientPeg.get().sendHtmlEmote(roomId, args, textToHtmlRainbow(args))); + return success(ContentHelpers.makeHtmlEmote(args, textToHtmlRainbow(args))); }, category: CommandCategories.messages, }), @@ -1201,10 +1213,14 @@ export function parseCommandString(input: string) { * processing the command, or 'promise' if a request was sent out. * Returns null if the input didn't match a command. */ -export function getCommand(roomId: string, input: string) { +export function getCommand(input: string) { const {cmd, args} = parseCommandString(input); if (CommandMap.has(cmd) && CommandMap.get(cmd).isEnabled()) { - return () => CommandMap.get(cmd).run(roomId, args, cmd); + return { + cmd: CommandMap.get(cmd), + args, + }; } + return {}; } diff --git a/src/Terms.js b/src/Terms.ts similarity index 87% rename from src/Terms.js rename to src/Terms.ts index 6ae89f9a2c..1bdff36cbc 100644 --- a/src/Terms.js +++ b/src/Terms.ts @@ -1,5 +1,5 @@ /* -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2019, 2021 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -17,7 +17,7 @@ limitations under the License. import classNames from 'classnames'; import {MatrixClientPeg} from './MatrixClientPeg'; -import * as sdk from './'; +import * as sdk from '.'; import Modal from './Modal'; export class TermsNotSignedError extends Error {} @@ -32,13 +32,30 @@ export class Service { * @param {string} baseUrl The Base URL of the service (ie. before '/_matrix') * @param {string} accessToken The user's access token for the service */ - constructor(serviceType, baseUrl, accessToken) { - this.serviceType = serviceType; - this.baseUrl = baseUrl; - this.accessToken = accessToken; + constructor(public serviceType: string, public baseUrl: string, public accessToken: string) { } } +interface Policy { + // @ts-ignore: No great way to express indexed types together with other keys + version: string; + [lang: string]: { + url: string; + }; +} +type Policies = { + [policy: string]: Policy, +}; + +export type TermsInteractionCallback = ( + policiesAndServicePairs: { + service: Service, + policies: Policies, + }[], + agreedUrls: string[], + extraClassNames?: string, +) => Promise; + /** * Start a flow where the user is presented with terms & conditions for some services * @@ -51,8 +68,8 @@ export class Service { * if they cancel. */ export async function startTermsFlow( - services, - interactionCallback = dialogTermsInteractionCallback, + services: Service[], + interactionCallback: TermsInteractionCallback = dialogTermsInteractionCallback, ) { const termsPromises = services.map( (s) => MatrixClientPeg.get().getTerms(s.serviceType, s.baseUrl), @@ -77,7 +94,7 @@ export async function startTermsFlow( * } */ - const terms = await Promise.all(termsPromises); + const terms: { policies: Policies }[] = await Promise.all(termsPromises); const policiesAndServicePairs = terms.map((t, i) => { return { 'service': services[i], 'policies': t.policies }; }); // fetch the set of agreed policy URLs from account data @@ -158,10 +175,13 @@ export async function startTermsFlow( } export function dialogTermsInteractionCallback( - policiesAndServicePairs, - agreedUrls, - extraClassNames, -) { + policiesAndServicePairs: { + service: Service, + policies: { [policy: string]: Policy }, + }[], + agreedUrls: string[], + extraClassNames?: string, +): Promise { return new Promise((resolve, reject) => { console.log("Terms that need agreement", policiesAndServicePairs); const TermsDialog = sdk.getComponent("views.dialogs.TermsDialog"); diff --git a/src/TextForEvent.js b/src/TextForEvent.js index 3afe41d216..a6787c647d 100644 --- a/src/TextForEvent.js +++ b/src/TextForEvent.js @@ -95,9 +95,10 @@ function textForMemberEvent(ev) { senderName, targetName, }) + ' ' + reason; - } else { - // sender is not target and made the target leave, if not from invite/ban then this is a kick + } else if (prevContent.membership === "join") { return _t('%(senderName)s kicked %(targetName)s.', {senderName, targetName}) + ' ' + reason; + } else { + return ""; } } } diff --git a/src/Unread.js b/src/Unread.js index ddf225ac64..12c15eb6af 100644 --- a/src/Unread.js +++ b/src/Unread.js @@ -45,7 +45,7 @@ export function eventTriggersUnreadCount(ev) { } export function doesRoomHaveUnreadMessages(room) { - const myUserId = MatrixClientPeg.get().credentials.userId; + const myUserId = MatrixClientPeg.get().getUserId(); // get the most recent read receipt sent by our account. // N.B. this is NOT a read marker (RM, aka "read up to marker"), diff --git a/src/VelocityBounce.js b/src/VelocityBounce.js deleted file mode 100644 index ffbf7de829..0000000000 --- a/src/VelocityBounce.js +++ /dev/null @@ -1,17 +0,0 @@ -import Velocity from "velocity-animate"; - -// courtesy of https://github.com/julianshapiro/velocity/issues/283 -// We only use easeOutBounce (easeInBounce is just sort of nonsensical) -function bounce( p ) { - let pow2; - let bounce = 4; - - while ( p < ( ( pow2 = Math.pow( 2, --bounce ) ) - 1 ) / 11 ) { - // just sets pow2 - } - return 1 / Math.pow( 4, 3 - bounce ) - 7.5625 * Math.pow( ( pow2 * 3 - 2 ) / 22 - p, 2 ); -} - -Velocity.Easings.easeOutBounce = function(p) { - return 1 - bounce(1 - p); -}; diff --git a/src/VoipUserMapper.ts b/src/VoipUserMapper.ts index d919615349..e5bed2e812 100644 --- a/src/VoipUserMapper.ts +++ b/src/VoipUserMapper.ts @@ -37,7 +37,7 @@ export default class VoipUserMapper { return results[0].userid; } - public async getOrCreateVirtualRoomForRoom(roomId: string):Promise { + public async getOrCreateVirtualRoomForRoom(roomId: string): Promise { const userId = DMRoomMap.shared().getUserIdForRoomId(roomId); if (!userId) return null; @@ -52,15 +52,19 @@ export default class VoipUserMapper { return virtualRoomId; } - public nativeRoomForVirtualRoom(roomId: string):string { + public nativeRoomForVirtualRoom(roomId: string): string { const virtualRoom = MatrixClientPeg.get().getRoom(roomId); if (!virtualRoom) return null; const virtualRoomEvent = virtualRoom.getAccountData(VIRTUAL_ROOM_EVENT_TYPE); if (!virtualRoomEvent || !virtualRoomEvent.getContent()) return null; - return virtualRoomEvent.getContent()['native_room'] || null; + const nativeRoomID = virtualRoomEvent.getContent()['native_room']; + const nativeRoom = MatrixClientPeg.get().getRoom(nativeRoomID); + if (!nativeRoom || nativeRoom.getMyMembership() !== 'join') return null; + + return nativeRoomID; } - public isVirtualRoom(room: Room):boolean { + public isVirtualRoom(room: Room): boolean { if (this.nativeRoomForVirtualRoom(room.roomId)) return true; if (this.virtualRoomIdCache.has(room.roomId)) return true; @@ -79,6 +83,8 @@ export default class VoipUserMapper { } public async onNewInvitedRoom(invitedRoom: Room) { + if (!CallHandler.sharedInstance().getSupportsVirtualRooms()) return; + const inviterId = invitedRoom.getDMInviter(); console.log(`Checking virtual-ness of room ID ${invitedRoom.roomId}, invited by ${inviterId}`); const result = await CallHandler.sharedInstance().sipNativeLookup(inviterId); diff --git a/src/accessibility/KeyboardShortcuts.tsx b/src/accessibility/KeyboardShortcuts.tsx index 7a0ba58c97..2a3e576e31 100644 --- a/src/accessibility/KeyboardShortcuts.tsx +++ b/src/accessibility/KeyboardShortcuts.tsx @@ -265,7 +265,7 @@ const shortcuts: Record = { description: _td("Toggle this dialog"), }, { keybinds: [{ - modifiers: [CMD_OR_CTRL, Modifiers.ALT], + modifiers: [Modifiers.CONTROL, isMac ? Modifiers.SHIFT : Modifiers.ALT], key: Key.H, }], description: _td("Go to Home View"), diff --git a/src/async-components/views/dialogs/eventindex/ManageEventIndexDialog.js b/src/async-components/views/dialogs/eventindex/ManageEventIndexDialog.tsx similarity index 90% rename from src/async-components/views/dialogs/eventindex/ManageEventIndexDialog.js rename to src/async-components/views/dialogs/eventindex/ManageEventIndexDialog.tsx index be3368b87b..0710c513da 100644 --- a/src/async-components/views/dialogs/eventindex/ManageEventIndexDialog.js +++ b/src/async-components/views/dialogs/eventindex/ManageEventIndexDialog.tsx @@ -1,5 +1,5 @@ /* -Copyright 2020 The Matrix.org Foundation C.I.C. +Copyright 2020-2021 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -16,7 +16,6 @@ limitations under the License. import React from 'react'; import * as sdk from '../../../../index'; -import PropTypes from 'prop-types'; import { _t } from '../../../../languageHandler'; import SdkConfig from '../../../../SdkConfig'; import SettingsStore from "../../../../settings/SettingsStore"; @@ -26,14 +25,23 @@ import {formatBytes, formatCountLong} from "../../../../utils/FormattingUtils"; import EventIndexPeg from "../../../../indexing/EventIndexPeg"; import {SettingLevel} from "../../../../settings/SettingLevel"; +interface IProps { + onFinished: (confirmed: boolean) => void; +} + +interface IState { + eventIndexSize: number; + eventCount: number; + crawlingRoomsCount: number; + roomCount: number; + currentRoom: string; + crawlerSleepTime: number; +} + /* * Allows the user to introspect the event index state and disable it. */ -export default class ManageEventIndexDialog extends React.Component { - static propTypes = { - onFinished: PropTypes.func.isRequired, - }; - +export default class ManageEventIndexDialog extends React.Component { constructor(props) { super(props); @@ -84,7 +92,7 @@ export default class ManageEventIndexDialog extends React.Component { } } - async componentDidMount(): void { + async componentDidMount(): Promise { let eventIndexSize = 0; let crawlingRoomsCount = 0; let roomCount = 0; @@ -123,14 +131,14 @@ export default class ManageEventIndexDialog extends React.Component { }); } - _onDisable = async () => { + private onDisable = async () => { Modal.createTrackedDialogAsync("Disable message search", "Disable message search", import("./DisableEventIndexDialog"), null, null, /* priority = */ false, /* static = */ true, ); }; - _onCrawlerSleepTimeChange = (e) => { + private onCrawlerSleepTimeChange = (e) => { this.setState({crawlerSleepTime: e.target.value}); SettingsStore.setValue("crawlerSleepTime", null, SettingLevel.DEVICE, e.target.value); }; @@ -144,7 +152,7 @@ export default class ManageEventIndexDialog extends React.Component { crawlerState = _t("Not currently indexing messages for any room."); } else { crawlerState = ( - _t("Currently indexing: %(currentRoom)s", { currentRoom: this.state.currentRoom }) + _t("Currently indexing: %(currentRoom)s", { currentRoom: this.state.currentRoom }) ); } @@ -169,7 +177,7 @@ export default class ManageEventIndexDialog extends React.Component { label={_t('Message downloading sleep time(ms)')} type='number' value={this.state.crawlerSleepTime} - onChange={this._onCrawlerSleepTimeChange} /> + onChange={this.onCrawlerSleepTimeChange} />
); @@ -188,7 +196,7 @@ export default class ManageEventIndexDialog extends React.Component { onPrimaryButtonClick={this.props.onFinished} primaryButtonClass="primary" cancelButton={_t("Disable")} - onCancel={this._onDisable} + onCancel={this.onDisable} cancelButtonClass="danger" /> diff --git a/src/async-components/views/dialogs/security/ExportE2eKeysDialog.js b/src/async-components/views/dialogs/security/ExportE2eKeysDialog.js index 4dd296a8f1..eeb68b94bd 100644 --- a/src/async-components/views/dialogs/security/ExportE2eKeysDialog.js +++ b/src/async-components/views/dialogs/security/ExportE2eKeysDialog.js @@ -19,7 +19,7 @@ import React, {createRef} from 'react'; import PropTypes from 'prop-types'; import { _t } from '../../../../languageHandler'; -import { MatrixClient } from 'matrix-js-sdk'; +import { MatrixClient } from 'matrix-js-sdk/src/client'; import * as MegolmExportEncryption from '../../../../utils/MegolmExportEncryption'; import * as sdk from '../../../../index'; diff --git a/src/async-components/views/dialogs/security/ImportE2eKeysDialog.js b/src/async-components/views/dialogs/security/ImportE2eKeysDialog.js index e7bae3578b..670cb28b94 100644 --- a/src/async-components/views/dialogs/security/ImportE2eKeysDialog.js +++ b/src/async-components/views/dialogs/security/ImportE2eKeysDialog.js @@ -17,7 +17,7 @@ limitations under the License. import React, {createRef} from 'react'; import PropTypes from 'prop-types'; -import { MatrixClient } from 'matrix-js-sdk'; +import { MatrixClient } from 'matrix-js-sdk/src/client'; import * as MegolmExportEncryption from '../../../../utils/MegolmExportEncryption'; import * as sdk from '../../../../index'; import { _t } from '../../../../languageHandler'; diff --git a/src/autocomplete/CommunityProvider.tsx b/src/autocomplete/CommunityProvider.tsx index ebf5d536ec..b7a4e0960e 100644 --- a/src/autocomplete/CommunityProvider.tsx +++ b/src/autocomplete/CommunityProvider.tsx @@ -27,6 +27,7 @@ import {sortBy} from "lodash"; import {makeGroupPermalink} from "../utils/permalinks/Permalinks"; import {ICompletion, ISelectionRange} from "./Autocompleter"; import FlairStore from "../stores/FlairStore"; +import {mediaFromMxc} from "../customisations/Media"; const COMMUNITY_REGEX = /\B\+\S*/g; @@ -95,7 +96,7 @@ export default class CommunityProvider extends AutocompleteProvider { name={name || groupId} width={24} height={24} - url={avatarUrl ? cli.mxcUrlToHttp(avatarUrl, 24, 24) : null} /> + url={avatarUrl ? mediaFromMxc(avatarUrl).getSquareThumbnailHttp(24) : null} /> ), range, diff --git a/src/autocomplete/QueryMatcher.ts b/src/autocomplete/QueryMatcher.ts index a07ed29c7e..91fbea4d6a 100644 --- a/src/autocomplete/QueryMatcher.ts +++ b/src/autocomplete/QueryMatcher.ts @@ -23,7 +23,6 @@ interface IOptions { keys: Array; funcs?: Array<(T) => string>; shouldMatchWordsOnly?: boolean; - shouldMatchPrefix?: boolean; // whether to apply unhomoglyph and strip diacritics to fuzz up the search. Defaults to true fuzzy?: boolean; } @@ -56,12 +55,6 @@ export default class QueryMatcher { if (this._options.shouldMatchWordsOnly === undefined) { this._options.shouldMatchWordsOnly = true; } - - // By default, match anywhere in the string being searched. If enabled, only return - // matches that are prefixed with the query. - if (this._options.shouldMatchPrefix === undefined) { - this._options.shouldMatchPrefix = false; - } } setObjects(objects: T[]) { @@ -112,7 +105,7 @@ export default class QueryMatcher { resultKey = resultKey.replace(/[^\w]/g, ''); } const index = resultKey.indexOf(query); - if (index !== -1 && (!this._options.shouldMatchPrefix || index === 0)) { + if (index !== -1) { matches.push( ...candidates.map((candidate) => ({index, ...candidate})), ); diff --git a/src/autocomplete/UserProvider.tsx b/src/autocomplete/UserProvider.tsx index 7fc01daef9..5f0cfc2df1 100644 --- a/src/autocomplete/UserProvider.tsx +++ b/src/autocomplete/UserProvider.tsx @@ -56,7 +56,6 @@ export default class UserProvider extends AutocompleteProvider { this.matcher = new QueryMatcher([], { keys: ['name'], funcs: [obj => obj.userId.slice(1)], // index by user id minus the leading '@' - shouldMatchPrefix: true, shouldMatchWordsOnly: false, }); diff --git a/src/components/structures/ContextMenu.tsx b/src/components/structures/ContextMenu.tsx index 726ff547ff..9d9d57d8a6 100644 --- a/src/components/structures/ContextMenu.tsx +++ b/src/components/structures/ContextMenu.tsx @@ -22,6 +22,7 @@ import classNames from "classnames"; import {Key} from "../../Keyboard"; import {Writeable} from "../../@types/common"; +import {replaceableComponent} from "../../utils/replaceableComponent"; // Shamelessly ripped off Modal.js. There's probably a better way // of doing reusable widgets like dialog boxes & menus where we go and @@ -91,6 +92,7 @@ interface IState { // Generic ContextMenu Portal wrapper // all options inside the menu should be of role=menuitem/menuitemcheckbox/menuitemradiobutton and have tabIndex={-1} // this will allow the ContextMenu to manage its own focus using arrow keys as per the ARIA guidelines. +@replaceableComponent("structures.ContextMenu") export class ContextMenu extends React.PureComponent { private initialFocus: HTMLElement; @@ -467,6 +469,7 @@ export const useContextMenu = (): ContextMenuTuple< return [isOpen, button, open, close, setIsOpen]; }; +@replaceableComponent("structures.LegacyContextMenu") export default class LegacyContextMenu extends ContextMenu { render() { return this.renderMenu(false); diff --git a/src/components/structures/CustomRoomTagPanel.js b/src/components/structures/CustomRoomTagPanel.js index a79bdafeb5..73359f17a5 100644 --- a/src/components/structures/CustomRoomTagPanel.js +++ b/src/components/structures/CustomRoomTagPanel.js @@ -21,7 +21,9 @@ import * as sdk from '../../index'; import dis from '../../dispatcher/dispatcher'; import classNames from 'classnames'; import * as FormattingUtils from '../../utils/FormattingUtils'; +import {replaceableComponent} from "../../utils/replaceableComponent"; +@replaceableComponent("structures.CustomRoomTagPanel") class CustomRoomTagPanel extends React.Component { constructor(props) { super(props); diff --git a/src/components/structures/EmbeddedPage.js b/src/components/structures/EmbeddedPage.js index cbfeff7582..c37ab3df48 100644 --- a/src/components/structures/EmbeddedPage.js +++ b/src/components/structures/EmbeddedPage.js @@ -16,8 +16,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -'use strict'; - import React from 'react'; import PropTypes from 'prop-types'; import request from 'browser-request'; diff --git a/src/components/structures/FilePanel.js b/src/components/structures/FilePanel.js index 0e4df4621d..32db5c251c 100644 --- a/src/components/structures/FilePanel.js +++ b/src/components/structures/FilePanel.js @@ -18,7 +18,7 @@ limitations under the License. import React from 'react'; import PropTypes from 'prop-types'; -import {Filter} from 'matrix-js-sdk'; +import {Filter} from 'matrix-js-sdk/src/filter'; import * as sdk from '../../index'; import {MatrixClientPeg} from '../../MatrixClientPeg'; import EventIndexPeg from "../../indexing/EventIndexPeg"; @@ -26,10 +26,12 @@ import { _t } from '../../languageHandler'; import BaseCard from "../views/right_panel/BaseCard"; import {RightPanelPhases} from "../../stores/RightPanelStorePhases"; import DesktopBuildsNotice, {WarningKind} from "../views/elements/DesktopBuildsNotice"; +import {replaceableComponent} from "../../utils/replaceableComponent"; /* * Component which shows the filtered file using a TimelinePanel */ +@replaceableComponent("structures.FilePanel") class FilePanel extends React.Component { static propTypes = { roomId: PropTypes.string.isRequired, diff --git a/src/components/structures/GenericErrorPage.js b/src/components/structures/GenericErrorPage.js index ab7d4f9311..cfd2016d47 100644 --- a/src/components/structures/GenericErrorPage.js +++ b/src/components/structures/GenericErrorPage.js @@ -16,7 +16,9 @@ limitations under the License. import React from 'react'; import PropTypes from 'prop-types'; +import {replaceableComponent} from "../../utils/replaceableComponent"; +@replaceableComponent("structures.GenericErrorPage") export default class GenericErrorPage extends React.PureComponent { static propTypes = { title: PropTypes.object.isRequired, // jsx for title diff --git a/src/components/structures/GroupFilterPanel.js b/src/components/structures/GroupFilterPanel.js index 96aa1ba728..976b2d81a5 100644 --- a/src/components/structures/GroupFilterPanel.js +++ b/src/components/structures/GroupFilterPanel.js @@ -30,7 +30,9 @@ import MatrixClientContext from "../../contexts/MatrixClientContext"; import AutoHideScrollbar from "./AutoHideScrollbar"; import SettingsStore from "../../settings/SettingsStore"; import UserTagTile from "../views/elements/UserTagTile"; +import {replaceableComponent} from "../../utils/replaceableComponent"; +@replaceableComponent("structures.GroupFilterPanel") class GroupFilterPanel extends React.Component { static contextType = MatrixClientContext; diff --git a/src/components/structures/GroupView.js b/src/components/structures/GroupView.js index bbc4187298..ed6167cbe7 100644 --- a/src/components/structures/GroupView.js +++ b/src/components/structures/GroupView.js @@ -35,10 +35,12 @@ import GroupStore from '../../stores/GroupStore'; import FlairStore from '../../stores/FlairStore'; import { showGroupAddRoomDialog } from '../../GroupAddressPicker'; import {makeGroupPermalink, makeUserPermalink} from "../../utils/permalinks/Permalinks"; -import {Group} from "matrix-js-sdk"; +import {Group} from "matrix-js-sdk/src/models/group"; import {allSettled, sleep} from "../../utils/promise"; import RightPanelStore from "../../stores/RightPanelStore"; import AutoHideScrollbar from "./AutoHideScrollbar"; +import {mediaFromMxc} from "../../customisations/Media"; +import {replaceableComponent} from "../../utils/replaceableComponent"; const LONG_DESC_PLACEHOLDER = _td( `

HTML for your community's page

@@ -367,8 +369,7 @@ class FeaturedUser extends React.Component { const permalink = makeUserPermalink(this.props.summaryInfo.user_id); const userNameNode = { name }; - const httpUrl = MatrixClientPeg.get() - .mxcUrlToHttp(this.props.summaryInfo.avatar_url, 64, 64); + const httpUrl = mediaFromMxc(this.props.summaryInfo.avatar_url).getSquareThumbnailHttp(64); const deleteButton = this.props.editing ? ; } - const httpInviterAvatar = this.state.inviterProfile ? - this._matrixClient.mxcUrlToHttp( - this.state.inviterProfile.avatarUrl, 36, 36, - ) : null; + const httpInviterAvatar = this.state.inviterProfile && this.state.inviterProfile.avatarUrl + ? mediaFromMxc(this.state.inviterProfile.avatarUrl).getSquareThumbnailHttp(36) + : null; const inviter = group.inviter || {}; let inviterName = inviter.userId; diff --git a/src/components/structures/HostSignupAction.tsx b/src/components/structures/HostSignupAction.tsx index 9cf84a9379..769775d549 100644 --- a/src/components/structures/HostSignupAction.tsx +++ b/src/components/structures/HostSignupAction.tsx @@ -22,11 +22,13 @@ import { import { _t } from "../../languageHandler"; import { HostSignupStore } from "../../stores/HostSignupStore"; import SdkConfig from "../../SdkConfig"; +import {replaceableComponent} from "../../utils/replaceableComponent"; interface IProps {} interface IState {} +@replaceableComponent("structures.HostSignupAction") export default class HostSignupAction extends React.PureComponent { private openDialog = async () => { await HostSignupStore.instance.setHostSignupActive(true); diff --git a/src/components/structures/IndicatorScrollbar.js b/src/components/structures/IndicatorScrollbar.js index cd5510de9d..341ab2df71 100644 --- a/src/components/structures/IndicatorScrollbar.js +++ b/src/components/structures/IndicatorScrollbar.js @@ -17,7 +17,9 @@ limitations under the License. import React from "react"; import PropTypes from "prop-types"; import AutoHideScrollbar from "./AutoHideScrollbar"; +import {replaceableComponent} from "../../utils/replaceableComponent"; +@replaceableComponent("structures.IndicatorScrollbar") export default class IndicatorScrollbar extends React.Component { static propTypes = { // If true, the scrollbar will append mx_IndicatorScrollbar_leftOverflowIndicator diff --git a/src/components/structures/InteractiveAuth.js b/src/components/structures/InteractiveAuth.js index ac7049ed88..d419c9de6e 100644 --- a/src/components/structures/InteractiveAuth.js +++ b/src/components/structures/InteractiveAuth.js @@ -15,16 +15,18 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {InteractiveAuth} from "matrix-js-sdk"; +import {InteractiveAuth} from "matrix-js-sdk/src/interactive-auth"; import React, {createRef} from 'react'; import PropTypes from 'prop-types'; import getEntryComponentForLoginType from '../views/auth/InteractiveAuthEntryComponents'; import * as sdk from '../../index'; +import {replaceableComponent} from "../../utils/replaceableComponent"; export const ERROR_USER_CANCELLED = new Error("User cancelled auth session"); +@replaceableComponent("structures.InteractiveAuthComponent") export default class InteractiveAuthComponent extends React.Component { static propTypes = { // matrix client to use for UI auth requests diff --git a/src/components/structures/LeftPanel.tsx b/src/components/structures/LeftPanel.tsx index 82dd9443cc..e4762e35ad 100644 --- a/src/components/structures/LeftPanel.tsx +++ b/src/components/structures/LeftPanel.tsx @@ -16,9 +16,11 @@ limitations under the License. import * as React from "react"; import { createRef } from "react"; +import classNames from "classnames"; +import { Room } from "matrix-js-sdk/src/models/room"; + import GroupFilterPanel from "./GroupFilterPanel"; import CustomRoomTagPanel from "./CustomRoomTagPanel"; -import classNames from "classnames"; import dis from "../../dispatcher/dispatcher"; import { _t } from "../../languageHandler"; import RoomList from "../views/rooms/RoomList"; @@ -32,14 +34,15 @@ import { UPDATE_EVENT } from "../../stores/AsyncStore"; import ResizeNotifier from "../../utils/ResizeNotifier"; import SettingsStore from "../../settings/SettingsStore"; import RoomListStore, { LISTS_UPDATE_EVENT } from "../../stores/room-list/RoomListStore"; -import {Key} from "../../Keyboard"; import IndicatorScrollbar from "../structures/IndicatorScrollbar"; import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton"; import { OwnProfileStore } from "../../stores/OwnProfileStore"; -import { MatrixClientPeg } from "../../MatrixClientPeg"; import RoomListNumResults from "../views/rooms/RoomListNumResults"; import LeftPanelWidget from "./LeftPanelWidget"; -import SpacePanel from "../views/spaces/SpacePanel"; +import {replaceableComponent} from "../../utils/replaceableComponent"; +import {mediaFromMxc} from "../../customisations/Media"; +import SpaceStore, {UPDATE_SELECTED_SPACE} from "../../stores/SpaceStore"; +import { getKeyBindingsManager, RoomListAction } from "../../KeyBindingsManager"; interface IProps { isMinimized: boolean; @@ -49,6 +52,7 @@ interface IProps { interface IState { showBreadcrumbs: boolean; showGroupFilterPanel: boolean; + activeSpace?: Room; } // List of CSS classes which should be included in keyboard navigation within the room list @@ -60,6 +64,7 @@ const cssClasses = [ "mx_RoomSublist_showNButton", ]; +@replaceableComponent("structures.LeftPanel") export default class LeftPanel extends React.Component { private listContainerRef: React.RefObject = createRef(); private groupFilterPanelWatcherRef: string; @@ -73,11 +78,13 @@ export default class LeftPanel extends React.Component { this.state = { showBreadcrumbs: BreadcrumbsStore.instance.visible, showGroupFilterPanel: SettingsStore.getValue('TagPanel.enableTagPanel'), + activeSpace: SpaceStore.instance.activeSpace, }; BreadcrumbsStore.instance.on(UPDATE_EVENT, this.onBreadcrumbsUpdate); RoomListStore.instance.on(LISTS_UPDATE_EVENT, this.onBreadcrumbsUpdate); OwnProfileStore.instance.on(UPDATE_EVENT, this.onBackgroundImageUpdate); + SpaceStore.instance.on(UPDATE_SELECTED_SPACE, this.updateActiveSpace); this.bgImageWatcherRef = SettingsStore.watchSetting( "RoomList.backgroundImage", null, this.onBackgroundImageUpdate); this.groupFilterPanelWatcherRef = SettingsStore.watchSetting("TagPanel.enableTagPanel", null, () => { @@ -95,9 +102,14 @@ export default class LeftPanel extends React.Component { BreadcrumbsStore.instance.off(UPDATE_EVENT, this.onBreadcrumbsUpdate); RoomListStore.instance.off(LISTS_UPDATE_EVENT, this.onBreadcrumbsUpdate); OwnProfileStore.instance.off(UPDATE_EVENT, this.onBackgroundImageUpdate); + SpaceStore.instance.off(UPDATE_SELECTED_SPACE, this.updateActiveSpace); this.props.resizeNotifier.off("middlePanelResizedNoisy", this.onResize); } + private updateActiveSpace = (activeSpace: Room) => { + this.setState({ activeSpace }); + }; + private onExplore = () => { dis.fire(Action.ViewRoomDirectory); }; @@ -119,7 +131,7 @@ export default class LeftPanel extends React.Component { let avatarUrl = OwnProfileStore.instance.getHttpAvatarUrl(avatarSize); const settingBgMxc = SettingsStore.getValue("RoomList.backgroundImage"); if (settingBgMxc) { - avatarUrl = MatrixClientPeg.get().mxcUrlToHttp(settingBgMxc, avatarSize, avatarSize); + avatarUrl = mediaFromMxc(settingBgMxc).getSquareThumbnailHttp(avatarSize); } const avatarUrlProp = `url(${avatarUrl})`; @@ -142,7 +154,7 @@ export default class LeftPanel extends React.Component { private doStickyHeaders(list: HTMLDivElement) { const topEdge = list.scrollTop; const bottomEdge = list.offsetHeight + list.scrollTop; - const sublists = list.querySelectorAll(".mx_RoomSublist"); + const sublists = list.querySelectorAll(".mx_RoomSublist:not(.mx_RoomSublist_hidden)"); const headerRightMargin = 15; // calculated from margins and widths to align with non-sticky tiles const headerStickyWidth = list.clientWidth - headerRightMargin; @@ -285,17 +297,18 @@ export default class LeftPanel extends React.Component { private onKeyDown = (ev: React.KeyboardEvent) => { if (!this.focusedElement) return; - switch (ev.key) { - case Key.ARROW_UP: - case Key.ARROW_DOWN: + const action = getKeyBindingsManager().getRoomListAction(ev); + switch (action) { + case RoomListAction.NextRoom: + case RoomListAction.PrevRoom: ev.stopPropagation(); ev.preventDefault(); - this.onMoveFocus(ev.key === Key.ARROW_UP); + this.onMoveFocus(action === RoomListAction.PrevRoom); break; } }; - private onEnter = () => { + private selectRoom = () => { const firstRoom = this.listContainerRef.current.querySelector(".mx_RoomTile"); if (firstRoom) { firstRoom.click(); @@ -376,11 +389,13 @@ export default class LeftPanel extends React.Component { > @@ -390,11 +405,7 @@ export default class LeftPanel extends React.Component { public render(): React.ReactNode { let leftLeftPanel; - // Currently TagPanel.enableTagPanel is disabled when Legacy Communities are disabled so for now - // ignore it and force the rendering of SpacePanel if that Labs flag is enabled. - if (SettingsStore.getValue("feature_spaces")) { - leftLeftPanel = ; - } else if (this.state.showGroupFilterPanel) { + if (this.state.showGroupFilterPanel) { leftLeftPanel = (
@@ -410,6 +421,7 @@ export default class LeftPanel extends React.Component { onBlur={this.onBlur} isMinimized={this.props.isMinimized} onResize={this.onResize} + activeSpace={this.state.activeSpace} />; const containerClasses = classNames({ diff --git a/src/components/structures/LoggedInView.tsx b/src/components/structures/LoggedInView.tsx index 4e768bd9e5..0255a3bf35 100644 --- a/src/components/structures/LoggedInView.tsx +++ b/src/components/structures/LoggedInView.tsx @@ -21,7 +21,7 @@ import * as PropTypes from 'prop-types'; import { MatrixClient } from 'matrix-js-sdk/src/client'; import { DragDropContext } from 'react-beautiful-dnd'; -import {Key, isOnlyCtrlOrCmdKeyEvent, isOnlyCtrlOrCmdIgnoreShiftKeyEvent, isMac} from '../../Keyboard'; +import {Key} from '../../Keyboard'; import PageTypes from '../../PageTypes'; import CallMediaHandler from '../../CallMediaHandler'; import { fixupColorFonts } from '../../utils/FontManager'; @@ -55,7 +55,10 @@ import { IThreepidInvite } from "../../stores/ThreepidInviteStore"; import Modal from "../../Modal"; import { ICollapseConfig } from "../../resizer/distributors/collapse"; import HostSignupContainer from '../views/host_signup/HostSignupContainer'; +import { getKeyBindingsManager, NavigationAction, RoomAction } from '../../KeyBindingsManager'; import { IOpts } from "../../createRoom"; +import SpacePanel from "../views/spaces/SpacePanel"; +import {replaceableComponent} from "../../utils/replaceableComponent"; // We need to fetch each pinned message individually (if we don't already have it) // so each pinned message may trigger a request. Limit the number per room for sanity. @@ -72,7 +75,6 @@ function canElementReceiveInput(el) { interface IProps { matrixClient: MatrixClient; onRegistered: (credentials: IMatrixClientCreds) => Promise; - viaServers?: string[]; hideToSRUsers: boolean; resizeNotifier: ResizeNotifier; // eslint-disable-next-line camelcase @@ -96,8 +98,10 @@ interface IProps { } interface IUsageLimit { + // "hs_disabled" is NOT a specced string, but is used in Synapse + // This is tracked over at https://github.com/matrix-org/synapse/issues/9237 // eslint-disable-next-line camelcase - limit_type: "monthly_active_user" | string; + limit_type: "monthly_active_user" | "hs_disabled" | string; // eslint-disable-next-line camelcase admin_contact?: string; } @@ -105,6 +109,8 @@ interface IUsageLimit { interface IState { syncErrorData?: { error: { + // This is not specced, but used in Synapse. See + // https://github.com/matrix-org/synapse/issues/9237#issuecomment-768238922 data: IUsageLimit; errcode: string; }; @@ -124,6 +130,7 @@ interface IState { * * Components mounted below us can access the matrix client via the react context. */ +@replaceableComponent("structures.LoggedInView") class LoggedInView extends React.Component { static displayName = 'LoggedInView'; @@ -136,9 +143,6 @@ class LoggedInView extends React.Component { // transitioned to PWLU) onRegistered: PropTypes.func, - // Used by the RoomView to handle joining rooms - viaServers: PropTypes.arrayOf(PropTypes.string), - // and lots and lots of other stuff. }; @@ -223,21 +227,15 @@ class LoggedInView extends React.Component { let size; let collapsed; const collapseConfig: ICollapseConfig = { - // TODO: the space panel currently does not have a fixed width, - // just the headers at each level have a max-width of 150px - // Taking 222px for the space panel for now, - // so this will look slightly off for now, - // depending on the depth of your space tree. - // To fix this, we'll need to turn toggleSize - // into a callback so it can be measured when starting the resize operation - toggleSize: 222 + 68, + // TODO decrease this once Spaces launches as it'll no longer need to include the 56px Community Panel + toggleSize: 206 - 50, onCollapsed: (_collapsed) => { collapsed = _collapsed; if (_collapsed) { - dis.dispatch({action: "hide_left_panel"}, true); + dis.dispatch({action: "hide_left_panel"}); window.localStorage.setItem("mx_lhs_size", '0'); } else { - dis.dispatch({action: "show_left_panel"}, true); + dis.dispatch({action: "show_left_panel"}); } }, onResized: (_size) => { @@ -439,86 +437,55 @@ class LoggedInView extends React.Component { _onKeyDown = (ev) => { let handled = false; - const ctrlCmdOnly = isOnlyCtrlOrCmdKeyEvent(ev); - const hasModifier = ev.altKey || ev.ctrlKey || ev.metaKey || ev.shiftKey; - const isModifier = ev.key === Key.ALT || ev.key === Key.CONTROL || ev.key === Key.META || ev.key === Key.SHIFT; - const modKey = isMac ? ev.metaKey : ev.ctrlKey; - switch (ev.key) { - case Key.PAGE_UP: - case Key.PAGE_DOWN: - if (!hasModifier && !isModifier) { - this._onScrollKeyPressed(ev); - handled = true; - } + const roomAction = getKeyBindingsManager().getRoomAction(ev); + switch (roomAction) { + case RoomAction.ScrollUp: + case RoomAction.RoomScrollDown: + case RoomAction.JumpToFirstMessage: + case RoomAction.JumpToLatestMessage: + // pass the event down to the scroll panel + this._onScrollKeyPressed(ev); + handled = true; break; + case RoomAction.FocusSearch: + dis.dispatch({ + action: 'focus_search', + }); + handled = true; + break; + } + if (handled) { + ev.stopPropagation(); + ev.preventDefault(); + return; + } - case Key.HOME: - case Key.END: - if (ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey) { - this._onScrollKeyPressed(ev); - handled = true; - } + const navAction = getKeyBindingsManager().getNavigationAction(ev); + switch (navAction) { + case NavigationAction.FocusRoomSearch: + dis.dispatch({ + action: 'focus_room_filter', + }); + handled = true; break; - case Key.K: - if (ctrlCmdOnly) { - dis.dispatch({ - action: 'focus_room_filter', - }); - handled = true; - } + case NavigationAction.ToggleUserMenu: + dis.fire(Action.ToggleUserMenu); + handled = true; break; - case Key.F: - if (ctrlCmdOnly && SettingsStore.getValue("ctrlFForSearch")) { - dis.dispatch({ - action: 'focus_search', - }); - handled = true; - } + case NavigationAction.ToggleShortCutDialog: + KeyboardShortcuts.toggleDialog(); + handled = true; break; - case Key.BACKTICK: - // Ideally this would be CTRL+P for "Profile", but that's - // taken by the print dialog. CTRL+I for "Information" - // was previously chosen but conflicted with italics in - // composer, so CTRL+` it is - - if (ctrlCmdOnly) { - dis.fire(Action.ToggleUserMenu); - handled = true; - } + case NavigationAction.GoToHome: + dis.dispatch({ + action: 'view_home_page', + }); + Modal.closeCurrentModal("homeKeyboardShortcut"); + handled = true; break; - - case Key.SLASH: - if (isOnlyCtrlOrCmdIgnoreShiftKeyEvent(ev)) { - KeyboardShortcuts.toggleDialog(); - handled = true; - } - break; - - case Key.H: - if (ev.altKey && modKey) { - dis.dispatch({ - action: 'view_home_page', - }); - Modal.closeCurrentModal("homeKeyboardShortcut"); - handled = true; - } - break; - - case Key.ARROW_UP: - case Key.ARROW_DOWN: - if (ev.altKey && !ev.ctrlKey && !ev.metaKey) { - dis.dispatch({ - action: Action.ViewRoomDelta, - delta: ev.key === Key.ARROW_UP ? -1 : 1, - unread: ev.shiftKey, - }); - handled = true; - } - break; - - case Key.PERIOD: - if (ctrlCmdOnly && (this.props.page_type === "room_view" || this.props.page_type === "group_view")) { + case NavigationAction.ToggleRoomSidePanel: + if (this.props.page_type === "room_view" || this.props.page_type === "group_view") { dis.dispatch({ action: Action.ToggleRightPanel, type: this.props.page_type === "room_view" ? "room" : "group", @@ -526,16 +493,48 @@ class LoggedInView extends React.Component { handled = true; } break; - + case NavigationAction.SelectPrevRoom: + dis.dispatch({ + action: Action.ViewRoomDelta, + delta: -1, + unread: false, + }); + handled = true; + break; + case NavigationAction.SelectNextRoom: + dis.dispatch({ + action: Action.ViewRoomDelta, + delta: 1, + unread: false, + }); + handled = true; + break; + case NavigationAction.SelectPrevUnreadRoom: + dis.dispatch({ + action: Action.ViewRoomDelta, + delta: -1, + unread: true, + }); + break; + case NavigationAction.SelectNextUnreadRoom: + dis.dispatch({ + action: Action.ViewRoomDelta, + delta: 1, + unread: true, + }); + break; default: // if we do not have a handler for it, pass it to the platform which might handled = PlatformPeg.get().onKeyDown(ev); } - if (handled) { ev.stopPropagation(); ev.preventDefault(); - } else if (!isModifier && !ev.altKey && !ev.ctrlKey && !ev.metaKey) { + return; + } + + const isModifier = ev.key === Key.ALT || ev.key === Key.CONTROL || ev.key === Key.META || ev.key === Key.SHIFT; + if (!isModifier && !ev.altKey && !ev.ctrlKey && !ev.metaKey) { // The above condition is crafted to _allow_ characters with Shift // already pressed (but not the Shift key down itself). @@ -624,11 +623,9 @@ class LoggedInView extends React.Component { case PageTypes.RoomView: pageElement = { bodyClasses += ' mx_MatrixChat_useCompactLayout'; } - const leftPanel = ( - - ); - return (
{
- { leftPanel } + { SettingsStore.getValue("feature_spaces") ? : null } + { pageElement }
diff --git a/src/components/structures/MainSplit.js b/src/components/structures/MainSplit.js index 47dfe83ad6..5818d303fc 100644 --- a/src/components/structures/MainSplit.js +++ b/src/components/structures/MainSplit.js @@ -17,7 +17,9 @@ limitations under the License. import React from 'react'; import { Resizable } from 're-resizable'; +import {replaceableComponent} from "../../utils/replaceableComponent"; +@replaceableComponent("structures.MainSplit") export default class MainSplit extends React.Component { _onResizeStart = () => { this.props.resizeNotifier.startResizing(); diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index 1700b627db..078b296295 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -1,8 +1,5 @@ /* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2017 Vector Creations Ltd -Copyright 2017-2019 New Vector Ltd -Copyright 2019, 2020 The Matrix.org Foundation C.I.C. +Copyright 2015-2021 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -18,8 +15,7 @@ limitations under the License. */ import React, { createRef } from 'react'; -// @ts-ignore - XXX: no idea why this import fails -import * as Matrix from "matrix-js-sdk"; +import { createClient } from "matrix-js-sdk/src/matrix"; import { InvalidStoreError } from "matrix-js-sdk/src/errors"; import { RoomMember } from "matrix-js-sdk/src/models/room-member"; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; @@ -82,8 +78,13 @@ import {UIFeature} from "../../settings/UIFeature"; import { CommunityPrototypeStore } from "../../stores/CommunityPrototypeStore"; import DialPadModal from "../views/voip/DialPadModal"; import { showToast as showMobileGuideToast } from '../../toasts/MobileGuideToast'; +import { shouldUseLoginForWelcome } from "../../utils/pages"; import SpaceStore from "../../stores/SpaceStore"; -import SpaceRoomDirectory from "./SpaceRoomDirectory"; +import {replaceableComponent} from "../../utils/replaceableComponent"; +import RoomListStore from "../../stores/room-list/RoomListStore"; +import {RoomUpdateCause} from "../../stores/room-list/models"; +import defaultDispatcher from "../../dispatcher/dispatcher"; +import SecurityCustomisations from "../../customisations/Security"; /** constants for MatrixChat.state.view */ export enum Views { @@ -202,12 +203,12 @@ interface IState { ready: boolean; threepidInvite?: IThreepidInvite, roomOobData?: object; - viaServers?: string[]; pendingInitialSync?: boolean; justRegistered?: boolean; roomJustCreatedOpts?: IOpts; } +@replaceableComponent("structures.MatrixChat") export default class MatrixChat extends React.PureComponent { static displayName = "MatrixChat"; @@ -395,7 +396,11 @@ export default class MatrixChat extends React.PureComponent { const crossSigningIsSetUp = cli.getStoredCrossSigningForUser(cli.getUserId()); if (crossSigningIsSetUp) { - this.setStateForNewView({ view: Views.COMPLETE_SECURITY }); + if (SecurityCustomisations.SHOW_ENCRYPTION_SETUP_UI === false) { + this.onLoggedIn(); + } else { + this.setStateForNewView({view: Views.COMPLETE_SECURITY}); + } } else if (await cli.doesServerSupportUnstableFeature("org.matrix.e2e_cross_signing")) { this.setStateForNewView({ view: Views.E2E_SETUP }); } else { @@ -580,6 +585,7 @@ export default class MatrixChat extends React.PureComponent { } break; case 'logout': + dis.dispatch({action: "hangup_all"}); Lifecycle.logout(); break; case 'require_registration': @@ -604,12 +610,7 @@ export default class MatrixChat extends React.PureComponent { if (payload.screenAfterLogin) { this.screenAfterLogin = payload.screenAfterLogin; } - this.setStateForNewView({ - view: Views.LOGIN, - }); - this.notifyNewScreen('login'); - ThemeController.isLogin = true; - this.themeWatcher.recheck(); + this.viewLogin(); break; case 'start_password_recovery': this.setStateForNewView({ @@ -694,10 +695,10 @@ export default class MatrixChat extends React.PureComponent { } case Action.ViewRoomDirectory: { if (SpaceStore.instance.activeSpace) { - Modal.createTrackedDialog("Space room directory", "", SpaceRoomDirectory, { - space: SpaceStore.instance.activeSpace, - initialText: payload.initialText, - }, "mx_SpaceRoomDirectory_dialogWrapper", false, true); + defaultDispatcher.dispatch({ + action: "view_room", + room_id: SpaceStore.instance.activeSpace.roomId, + }); } else { const RoomDirectory = sdk.getComponent("structures.RoomDirectory"); Modal.createTrackedDialog('Room directory', '', RoomDirectory, { @@ -932,7 +933,6 @@ export default class MatrixChat extends React.PureComponent { page_type: PageTypes.RoomView, threepidInvite: roomInfo.threepid_invite, roomOobData: roomInfo.oob_data, - viaServers: roomInfo.via_servers, ready: true, roomJustCreatedOpts: roomInfo.justCreatedOpts, }, () => { @@ -973,6 +973,9 @@ export default class MatrixChat extends React.PureComponent { } private viewWelcome() { + if (shouldUseLoginForWelcome(SdkConfig.get())) { + return this.viewLogin(); + } this.setStateForNewView({ view: Views.WELCOME, }); @@ -981,6 +984,16 @@ export default class MatrixChat extends React.PureComponent { this.themeWatcher.recheck(); } + private viewLogin(otherState?: any) { + this.setStateForNewView({ + view: Views.LOGIN, + ...otherState, + }); + this.notifyNewScreen('login'); + ThemeController.isLogin = true; + this.themeWatcher.recheck(); + } + private viewHome(justRegistered = false) { // The home page requires the "logged in" view, so we'll set that. this.setStateForNewView({ @@ -1083,8 +1096,22 @@ export default class MatrixChat extends React.PureComponent { const roomToLeave = MatrixClientPeg.get().getRoom(roomId); const isSpace = roomToLeave?.isSpaceRoom(); // Show a warning if there are additional complications. - const joinRules = roomToLeave.currentState.getStateEvents('m.room.join_rules', ''); const warnings = []; + + const memberCount = roomToLeave.currentState.getJoinedMemberCount(); + if (memberCount === 1) { + warnings.push(( + + {' '/* Whitespace, otherwise the sentences get smashed together */ } + { _t("You are the only person here. " + + "If you leave, no one will be able to join in the future, including you.") } + + )); + + return warnings; + } + + const joinRules = roomToLeave.currentState.getStateEvents('m.room.join_rules', ''); if (joinRules) { const rule = joinRules.getContent().join_rule; if (rule !== "public") { @@ -1137,11 +1164,17 @@ export default class MatrixChat extends React.PureComponent { } private forgetRoom(roomId: string) { + const room = MatrixClientPeg.get().getRoom(roomId); MatrixClientPeg.get().forget(roomId).then(() => { // Switch to home page if we're currently viewing the forgotten room if (this.state.currentRoomId === roomId) { dis.dispatch({ action: "view_home_page" }); } + + // We have to manually update the room list because the forgotten room will not + // be notified to us, therefore the room list will have no other way of knowing + // the room is forgotten. + RoomListStore.instance.manualRoomUpdate(room, RoomUpdateCause.RoomRemoved); }).catch((err) => { const errCode = err.errcode || _td("unknown error code"); Modal.createTrackedDialog("Failed to forget room", '', ErrorDialog, { @@ -1296,17 +1329,13 @@ export default class MatrixChat extends React.PureComponent { * Called when the session is logged out */ private onLoggedOut() { - this.notifyNewScreen('login'); - this.setStateForNewView({ - view: Views.LOGIN, + this.viewLogin({ ready: false, collapseLhs: false, currentRoomId: null, }); this.subTitleStatus = ''; this.setPageSubtitle(); - ThemeController.isLogin = true; - this.themeWatcher.recheck(); } /** @@ -1544,7 +1573,7 @@ export default class MatrixChat extends React.PureComponent { } else if (request.pending) { ToastStore.sharedInstance().addOrReplaceToast({ key: 'verifreq_' + request.channel.transactionId, - title: request.isSelfVerification ? _t("Self-verification request") : _t("Verification Request"), + title: _t("Verification requested"), icon: "verification", props: {request}, component: sdk.getComponent("toasts.VerificationRequestToast"), @@ -1646,7 +1675,7 @@ export default class MatrixChat extends React.PureComponent { let cli = MatrixClientPeg.get(); if (!cli) { const {hsUrl, isUrl} = this.props.serverConfig; - cli = Matrix.createClient({ + cli = createClient({ baseUrl: hsUrl, idBaseUrl: isUrl, }); diff --git a/src/components/structures/MessagePanel.js b/src/components/structures/MessagePanel.js index 161227a139..132d9ab4c3 100644 --- a/src/components/structures/MessagePanel.js +++ b/src/components/structures/MessagePanel.js @@ -23,7 +23,6 @@ import classNames from 'classnames'; import shouldHideEvent from '../../shouldHideEvent'; import {wantsDateSeparator} from '../../DateUtils'; import * as sdk from '../../index'; -import dis from "../../dispatcher/dispatcher"; import {MatrixClientPeg} from '../../MatrixClientPeg'; import SettingsStore from '../../settings/SettingsStore'; @@ -34,6 +33,7 @@ import {textForEvent} from "../../TextForEvent"; import IRCTimelineProfileResizer from "../views/elements/IRCTimelineProfileResizer"; import DMRoomMap from "../../utils/DMRoomMap"; import NewRoomIntro from "../views/rooms/NewRoomIntro"; +import {replaceableComponent} from "../../utils/replaceableComponent"; const CONTINUATION_MAX_INTERVAL = 5 * 60 * 1000; // 5 minutes const continuedTypes = ['m.sticker', 'm.room.message']; @@ -46,6 +46,9 @@ function shouldFormContinuation(prevEvent, mxEvent) { // check if within the max continuation period if (mxEvent.getTs() - prevEvent.getTs() > CONTINUATION_MAX_INTERVAL) return false; + // As we summarise redactions, do not continue a redacted event onto a non-redacted one and vice-versa + if (mxEvent.isRedacted() !== prevEvent.isRedacted()) return false; + // Some events should appear as continuations from previous events of different types. if (mxEvent.getType() !== prevEvent.getType() && (!continuedTypes.includes(mxEvent.getType()) || @@ -66,6 +69,7 @@ const isMembershipChange = (e) => e.getType() === 'm.room.member' || e.getType() /* (almost) stateless UI component which builds the event tiles in the room timeline. */ +@replaceableComponent("structures.MessagePanel") export default class MessagePanel extends React.Component { static propTypes = { // true to give the component a 'display: none' style. @@ -208,13 +212,11 @@ export default class MessagePanel extends React.Component { componentDidMount() { this._isMounted = true; - this.dispatcherRef = dis.register(this.onAction); } componentWillUnmount() { this._isMounted = false; SettingsStore.unwatchSetting(this._showTypingNotificationsWatcherRef); - dis.unregister(this.dispatcherRef); } componentDidUpdate(prevProps, prevState) { @@ -227,14 +229,6 @@ export default class MessagePanel extends React.Component { } } - onAction = (payload) => { - switch (payload.action) { - case "scroll_to_bottom": - this.scrollToBottom(); - break; - } - } - onShowTypingNotificationsChange = () => { this.setState({ showTypingNotifications: SettingsStore.getValue("showTypingNotifications"), @@ -461,6 +455,20 @@ export default class MessagePanel extends React.Component { }); }; + _getNextEventInfo(arr, i) { + const nextEvent = i < arr.length - 1 + ? arr[i + 1] + : null; + + // The next event with tile is used to to determine the 'last successful' flag + // when rendering the tile. The shouldShowEvent function is pretty quick at what + // it does, so this should have no significant cost even when a room is used for + // not-chat purposes. + const nextTile = arr.slice(i + 1).find(e => this._shouldShowEvent(e)); + + return {nextEvent, nextTile}; + } + _getEventTiles() { this.eventNodes = {}; @@ -498,6 +506,9 @@ export default class MessagePanel extends React.Component { let prevEvent = null; // the last event we showed + // Note: the EventTile might still render a "sent/sending receipt" independent of + // this information. When not providing read receipt information, the tile is likely + // to assume that sent receipts are to be shown more often. this._readReceiptsByEvent = {}; if (this.props.showReadReceipts) { this._readReceiptsByEvent = this._getReadReceiptsByShownEvent(); @@ -509,6 +520,7 @@ export default class MessagePanel extends React.Component { const mxEv = this.props.events[i]; const eventId = mxEv.getId(); const last = (mxEv === lastShownEvent); + const {nextEvent, nextTile} = this._getNextEventInfo(this.props.events, i); if (grouper) { if (grouper.shouldGroup(mxEv)) { @@ -525,19 +537,16 @@ export default class MessagePanel extends React.Component { for (const Grouper of groupers) { if (Grouper.canStartGroup(this, mxEv)) { - grouper = new Grouper(this, mxEv, prevEvent, lastShownEvent); + grouper = new Grouper(this, mxEv, prevEvent, lastShownEvent, nextEvent, nextTile); } } if (!grouper) { const wantTile = this._shouldShowEvent(mxEv); if (wantTile) { - const nextEvent = i < this.props.events.length - 1 - ? this.props.events[i + 1] - : null; // make sure we unpack the array returned by _getTilesForEvent, // otherwise react will auto-generate keys and we will end up // replacing all of the DOM elements every time we paginate. - ret.push(...this._getTilesForEvent(prevEvent, mxEv, last, nextEvent)); + ret.push(...this._getTilesForEvent(prevEvent, mxEv, last, nextEvent, nextTile)); prevEvent = mxEv; } @@ -553,7 +562,7 @@ export default class MessagePanel extends React.Component { return ret; } - _getTilesForEvent(prevEvent, mxEv, last, nextEvent) { + _getTilesForEvent(prevEvent, mxEv, last, nextEvent, nextEventWithTile) { const TileErrorBoundary = sdk.getComponent('messages.TileErrorBoundary'); const EventTile = sdk.getComponent('rooms.EventTile'); const DateSeparator = sdk.getComponent('messages.DateSeparator'); @@ -595,6 +604,30 @@ export default class MessagePanel extends React.Component { const readReceipts = this._readReceiptsByEvent[eventId]; + let isLastSuccessful = false; + const isSentState = s => !s || s === 'sent'; + const isSent = isSentState(mxEv.getAssociatedStatus()); + const hasNextEvent = nextEvent && this._shouldShowEvent(nextEvent); + if (!hasNextEvent && isSent) { + isLastSuccessful = true; + } else if (hasNextEvent && isSent && !isSentState(nextEvent.getAssociatedStatus())) { + isLastSuccessful = true; + } + + // This is a bit nuanced, but if our next event is hidden but a future event is not + // hidden then we're not the last successful. + if ( + nextEventWithTile && + nextEventWithTile !== nextEvent && + isSentState(nextEventWithTile.getAssociatedStatus()) + ) { + isLastSuccessful = false; + } + + // We only want to consider "last successful" if the event is sent by us, otherwise of course + // it's successful: we received it. + isLastSuccessful = isLastSuccessful && mxEv.getSender() === MatrixClientPeg.get().getUserId(); + // use txnId as key if available so that we don't remount during sending ret.push(
  • , @@ -1001,6 +1036,103 @@ class CreationGrouper { } } +class RedactionGrouper { + static canStartGroup = function(panel, ev) { + return panel._shouldShowEvent(ev) && ev.isRedacted(); + } + + constructor(panel, ev, prevEvent, lastShownEvent, nextEvent, nextEventTile) { + this.panel = panel; + this.readMarker = panel._readMarkerForEvent( + ev.getId(), + ev === lastShownEvent, + ); + this.events = [ev]; + this.prevEvent = prevEvent; + this.lastShownEvent = lastShownEvent; + this.nextEvent = nextEvent; + this.nextEventTile = nextEventTile; + } + + shouldGroup(ev) { + // absorb hidden events so that they do not break up streams of messages & redaction events being grouped + if (!this.panel._shouldShowEvent(ev)) { + return true; + } + if (this.panel._wantsDateSeparator(this.events[0], ev.getDate())) { + return false; + } + return ev.isRedacted(); + } + + add(ev) { + this.readMarker = this.readMarker || this.panel._readMarkerForEvent( + ev.getId(), + ev === this.lastShownEvent, + ); + if (!this.panel._shouldShowEvent(ev)) { + return; + } + this.events.push(ev); + } + + getTiles() { + if (!this.events || !this.events.length) return []; + + const DateSeparator = sdk.getComponent('messages.DateSeparator'); + const EventListSummary = sdk.getComponent('views.elements.EventListSummary'); + + const panel = this.panel; + const ret = []; + const lastShownEvent = this.lastShownEvent; + + if (panel._wantsDateSeparator(this.prevEvent, this.events[0].getDate())) { + const ts = this.events[0].getTs(); + ret.push( +
  • , + ); + } + + const key = "redactioneventlistsummary-" + ( + this.prevEvent ? this.events[0].getId() : "initial" + ); + + const senders = new Set(); + let eventTiles = this.events.map((e, i) => { + senders.add(e.sender); + const prevEvent = i === 0 ? this.prevEvent : this.events[i - 1]; + return panel._getTilesForEvent(prevEvent, e, e === lastShownEvent, this.nextEvent, this.nextEventTile); + }).reduce((a, b) => a.concat(b), []); + + if (eventTiles.length === 0) { + eventTiles = null; + } + + ret.push( + + { eventTiles } + , + ); + + if (this.readMarker) { + ret.push(this.readMarker); + } + + return ret; + } + + getNewPrevEvent() { + return this.events[this.events.length - 1]; + } +} + // Wrap consecutive member events in a ListSummary, ignore if redacted class MemberGrouper { static canStartGroup = function(panel, ev) { @@ -1111,4 +1243,4 @@ class MemberGrouper { } // all the grouper classes that we use -const groupers = [CreationGrouper, MemberGrouper]; +const groupers = [CreationGrouper, MemberGrouper, RedactionGrouper]; diff --git a/src/components/structures/MyGroups.js b/src/components/structures/MyGroups.js index e0551eecdb..2ab11dad25 100644 --- a/src/components/structures/MyGroups.js +++ b/src/components/structures/MyGroups.js @@ -24,7 +24,9 @@ import dis from '../../dispatcher/dispatcher'; import AccessibleButton from '../views/elements/AccessibleButton'; import MatrixClientContext from "../../contexts/MatrixClientContext"; import AutoHideScrollbar from "./AutoHideScrollbar"; +import {replaceableComponent} from "../../utils/replaceableComponent"; +@replaceableComponent("structures.MyGroups") export default class MyGroups extends React.Component { static contextType = MatrixClientContext; diff --git a/src/components/structures/NonUrgentToastContainer.tsx b/src/components/structures/NonUrgentToastContainer.tsx index 8d415df4dd..7c193ec9d7 100644 --- a/src/components/structures/NonUrgentToastContainer.tsx +++ b/src/components/structures/NonUrgentToastContainer.tsx @@ -18,6 +18,7 @@ import * as React from "react"; import { ComponentClass } from "../../@types/common"; import NonUrgentToastStore from "../../stores/NonUrgentToastStore"; import { UPDATE_EVENT } from "../../stores/AsyncStore"; +import {replaceableComponent} from "../../utils/replaceableComponent"; interface IProps { } @@ -26,6 +27,7 @@ interface IState { toasts: ComponentClass[], } +@replaceableComponent("structures.NonUrgentToastContainer") export default class NonUrgentToastContainer extends React.PureComponent { public constructor(props, context) { super(props, context); diff --git a/src/components/structures/NotificationPanel.js b/src/components/structures/NotificationPanel.js index b4eb6c187b..41aafc8b13 100644 --- a/src/components/structures/NotificationPanel.js +++ b/src/components/structures/NotificationPanel.js @@ -23,10 +23,12 @@ import { _t } from '../../languageHandler'; import {MatrixClientPeg} from "../../MatrixClientPeg"; import * as sdk from "../../index"; import BaseCard from "../views/right_panel/BaseCard"; +import {replaceableComponent} from "../../utils/replaceableComponent"; /* * Component which shows the global notification list using a TimelinePanel */ +@replaceableComponent("structures.NotificationPanel") class NotificationPanel extends React.Component { static propTypes = { onClose: PropTypes.func.isRequired, diff --git a/src/components/structures/RightPanel.js b/src/components/structures/RightPanel.js index 3d9df2e927..5bcb3b2450 100644 --- a/src/components/structures/RightPanel.js +++ b/src/components/structures/RightPanel.js @@ -34,7 +34,9 @@ import MatrixClientContext from "../../contexts/MatrixClientContext"; import {Action} from "../../dispatcher/actions"; import RoomSummaryCard from "../views/right_panel/RoomSummaryCard"; import WidgetCard from "../views/right_panel/WidgetCard"; +import {replaceableComponent} from "../../utils/replaceableComponent"; +@replaceableComponent("structures.RightPanel") export default class RightPanel extends React.Component { static get propTypes() { return { diff --git a/src/components/structures/RoomDirectory.js b/src/components/structures/RoomDirectory.js index 7387e1aac0..3613261da6 100644 --- a/src/components/structures/RoomDirectory.js +++ b/src/components/structures/RoomDirectory.js @@ -27,13 +27,14 @@ import { _t } from '../../languageHandler'; import SdkConfig from '../../SdkConfig'; import { instanceForInstanceId, protocolNameForInstanceId } from '../../utils/DirectoryUtils'; import Analytics from '../../Analytics'; -import {getHttpUriForMxc} from "matrix-js-sdk/src/content-repo"; import {ALL_ROOMS} from "../views/directory/NetworkDropdown"; import SettingsStore from "../../settings/SettingsStore"; import GroupFilterOrderStore from "../../stores/GroupFilterOrderStore"; import GroupStore from "../../stores/GroupStore"; import FlairStore from "../../stores/FlairStore"; import CountlyAnalytics from "../../CountlyAnalytics"; +import {replaceableComponent} from "../../utils/replaceableComponent"; +import {mediaFromMxc} from "../../customisations/Media"; const MAX_NAME_LENGTH = 80; const MAX_TOPIC_LENGTH = 800; @@ -42,6 +43,7 @@ function track(action) { Analytics.trackEvent('RoomDirectory', action); } +@replaceableComponent("structures.RoomDirectory") export default class RoomDirectory extends React.Component { static propTypes = { initialText: PropTypes.string, @@ -519,10 +521,9 @@ export default class RoomDirectory extends React.Component { topic = `${topic.substring(0, MAX_TOPIC_LENGTH)}...`; } topic = linkifyAndSanitizeHtml(topic); - const avatarUrl = getHttpUriForMxc( - MatrixClientPeg.get().getHomeserverUrl(), - room.avatar_url, 32, 32, "crop", - ); + let avatarUrl = null; + if (room.avatar_url) avatarUrl = mediaFromMxc(room.avatar_url).getSquareThumbnailHttp(32); + return [
    this.onRoomClicked(room, ev)} diff --git a/src/components/structures/RoomSearch.tsx b/src/components/structures/RoomSearch.tsx index a64e40bc65..a64feed42c 100644 --- a/src/components/structures/RoomSearch.tsx +++ b/src/components/structures/RoomSearch.tsx @@ -1,5 +1,5 @@ /* -Copyright 2020 The Matrix.org Foundation C.I.C. +Copyright 2020, 2021 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -20,16 +20,21 @@ import classNames from "classnames"; import defaultDispatcher from "../../dispatcher/dispatcher"; import { _t } from "../../languageHandler"; import { ActionPayload } from "../../dispatcher/payloads"; -import { Key } from "../../Keyboard"; import AccessibleButton from "../views/elements/AccessibleButton"; import { Action } from "../../dispatcher/actions"; import RoomListStore from "../../stores/room-list/RoomListStore"; import { NameFilterCondition } from "../../stores/room-list/filters/NameFilterCondition"; +import { getKeyBindingsManager, RoomListAction } from "../../KeyBindingsManager"; +import {replaceableComponent} from "../../utils/replaceableComponent"; +import SpaceStore, {UPDATE_SELECTED_SPACE} from "../../stores/SpaceStore"; interface IProps { isMinimized: boolean; - onVerticalArrow(ev: React.KeyboardEvent): void; - onEnter(ev: React.KeyboardEvent): boolean; + onKeyDown(ev: React.KeyboardEvent): void; + /** + * @returns true if a room has been selected and the search field should be cleared + */ + onSelectRoom(): boolean; } interface IState { @@ -37,6 +42,7 @@ interface IState { focused: boolean; } +@replaceableComponent("structures.RoomSearch") export default class RoomSearch extends React.PureComponent { private dispatcherRef: string; private inputRef: React.RefObject = createRef(); @@ -51,6 +57,8 @@ export default class RoomSearch extends React.PureComponent { }; this.dispatcherRef = defaultDispatcher.register(this.onAction); + // clear filter when changing spaces, in future we may wish to maintain a filter per-space + SpaceStore.instance.on(UPDATE_SELECTED_SPACE, this.clearInput); } public componentDidUpdate(prevProps: Readonly, prevState: Readonly): void { @@ -70,6 +78,7 @@ export default class RoomSearch extends React.PureComponent { public componentWillUnmount() { defaultDispatcher.unregister(this.dispatcherRef); + SpaceStore.instance.off(UPDATE_SELECTED_SPACE, this.clearInput); } private onAction = (payload: ActionPayload) => { @@ -106,18 +115,26 @@ export default class RoomSearch extends React.PureComponent { }; private onKeyDown = (ev: React.KeyboardEvent) => { - if (ev.key === Key.ESCAPE) { - this.clearInput(); - defaultDispatcher.fire(Action.FocusComposer); - } else if (ev.key === Key.ARROW_UP || ev.key === Key.ARROW_DOWN) { - this.props.onVerticalArrow(ev); - } else if (ev.key === Key.ENTER) { - const shouldClear = this.props.onEnter(ev); - if (shouldClear) { - // wrap in set immediate to delay it so that we don't clear the filter & then change room - setImmediate(() => { - this.clearInput(); - }); + const action = getKeyBindingsManager().getRoomListAction(ev); + switch (action) { + case RoomListAction.ClearSearch: + this.clearInput(); + defaultDispatcher.fire(Action.FocusComposer); + break; + case RoomListAction.NextRoom: + case RoomListAction.PrevRoom: + // we don't handle these actions here put pass the event on to the interested party (LeftPanel) + this.props.onKeyDown(ev); + break; + case RoomListAction.SelectRoom: { + const shouldClear = this.props.onSelectRoom(); + if (shouldClear) { + // wrap in set immediate to delay it so that we don't clear the filter & then change room + setImmediate(() => { + this.clearInput(); + }); + } + break; } } }; diff --git a/src/components/structures/RoomStatusBar.js b/src/components/structures/RoomStatusBar.js index c1c4ad6292..38e3cd97e8 100644 --- a/src/components/structures/RoomStatusBar.js +++ b/src/components/structures/RoomStatusBar.js @@ -1,5 +1,5 @@ /* -Copyright 2015-2020 The Matrix.org Foundation C.I.C. +Copyright 2015-2021 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -16,25 +16,31 @@ limitations under the License. import React from 'react'; import PropTypes from 'prop-types'; -import Matrix from 'matrix-js-sdk'; import { _t, _td } from '../../languageHandler'; import {MatrixClientPeg} from '../../MatrixClientPeg'; import Resend from '../../Resend'; import dis from '../../dispatcher/dispatcher'; -import {messageForResourceLimitError, messageForSendError} from '../../utils/ErrorUtils'; +import {messageForResourceLimitError} from '../../utils/ErrorUtils'; import {Action} from "../../dispatcher/actions"; +import {replaceableComponent} from "../../utils/replaceableComponent"; +import {EventStatus} from "matrix-js-sdk/src/models/event"; +import NotificationBadge from "../views/rooms/NotificationBadge"; +import {StaticNotificationState} from "../../stores/notifications/StaticNotificationState"; +import AccessibleButton from "../views/elements/AccessibleButton"; +import InlineSpinner from "../views/elements/InlineSpinner"; const STATUS_BAR_HIDDEN = 0; const STATUS_BAR_EXPANDED = 1; const STATUS_BAR_EXPANDED_LARGE = 2; -function getUnsentMessages(room) { +export function getUnsentMessages(room) { if (!room) { return []; } return room.getPendingEvents().filter(function(ev) { - return ev.status === Matrix.EventStatus.NOT_SENT; + return ev.status === EventStatus.NOT_SENT; }); } +@replaceableComponent("structures.RoomStatusBar") export default class RoomStatusBar extends React.Component { static propTypes = { // the room this statusbar is representing. @@ -74,6 +80,7 @@ export default class RoomStatusBar extends React.Component { syncState: MatrixClientPeg.get().getSyncState(), syncStateData: MatrixClientPeg.get().getSyncStateData(), unsentMessages: getUnsentMessages(this.props.room), + isResending: false, }; componentDidMount() { @@ -107,7 +114,10 @@ export default class RoomStatusBar extends React.Component { }; _onResendAllClick = () => { - Resend.resendUnsentEvents(this.props.room); + Resend.resendUnsentEvents(this.props.room).then(() => { + this.setState({isResending: false}); + }); + this.setState({isResending: true}); dis.fire(Action.FocusComposer); }; @@ -118,9 +128,10 @@ export default class RoomStatusBar extends React.Component { _onRoomLocalEchoUpdated = (event, room, oldEventId, oldStatus) => { if (room.roomId !== this.props.room.roomId) return; - + const messages = getUnsentMessages(this.props.room); this.setState({ - unsentMessages: getUnsentMessages(this.props.room), + unsentMessages: messages, + isResending: messages.length > 0 && this.state.isResending, }); }; @@ -139,7 +150,7 @@ export default class RoomStatusBar extends React.Component { _getSize() { if (this._shouldShowConnectionError()) { return STATUS_BAR_EXPANDED; - } else if (this.state.unsentMessages.length > 0) { + } else if (this.state.unsentMessages.length > 0 || this.state.isResending) { return STATUS_BAR_EXPANDED_LARGE; } return STATUS_BAR_HIDDEN; @@ -160,7 +171,6 @@ export default class RoomStatusBar extends React.Component { _getUnsentMessageContent() { const unsentMessages = this.state.unsentMessages; - if (!unsentMessages.length) return null; let title; @@ -195,80 +205,85 @@ export default class RoomStatusBar extends React.Component { "Your message wasn't sent because this homeserver has hit its Monthly Active User Limit. " + "Please contact your service administrator to continue using the service.", ), + 'hs_disabled': _td( + "Your message wasn't sent because this homeserver has been blocked by it's administrator. " + + "Please contact your service administrator to continue using the service.", + ), '': _td( "Your message wasn't sent because this homeserver has exceeded a resource limit. " + "Please contact your service administrator to continue using the service.", ), }); - } else if ( - unsentMessages.length === 1 && - unsentMessages[0].error && - unsentMessages[0].error.data && - unsentMessages[0].error.data.error - ) { - title = messageForSendError(unsentMessages[0].error.data) || unsentMessages[0].error.data.error; } else { - title = _t('%(count)s of your messages have not been sent.', { count: unsentMessages.length }); + title = _t('Some of your messages have not been sent'); } - const content = _t("%(count)s Resend all or cancel all " + - "now. You can also select individual messages to resend or cancel.", - { count: unsentMessages.length }, - { - 'resendText': (sub) => - { sub }, - 'cancelText': (sub) => - { sub }, - }, - ); + let buttonRow = <> + + {_t("Delete all")} + + + {_t("Retry all")} + + ; + if (this.state.isResending) { + buttonRow = <> + + {/* span for css */} + {_t("Sending")} + ; + } - return
    - -
    -
    - { title } -
    -
    - { content } + return <> +
    +
    +
    + +
    +
    +
    + { title } +
    +
    + { _t("You can select all or individual messages to retry or delete") } +
    +
    +
    + {buttonRow} +
    -
    ; + ; } - // return suitable content for the main (text) part of the status bar. - _getContent() { + render() { if (this._shouldShowConnectionError()) { return ( -
    - /!\ -
    -
    - { _t('Connectivity to the server has been lost.') } -
    -
    - { _t('Sent messages will be stored until your connection has returned.') } +
    +
    +
    + /!\ +
    +
    + {_t('Connectivity to the server has been lost.')} +
    +
    + {_t('Sent messages will be stored until your connection has returned.')} +
    +
    ); } - if (this.state.unsentMessages.length > 0) { + if (this.state.unsentMessages.length > 0 || this.state.isResending) { return this._getUnsentMessageContent(); } return null; } - - render() { - const content = this._getContent(); - - return ( -
    -
    - { content } -
    -
    - ); - } } diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index 96808e651e..7168b7d139 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -40,7 +40,6 @@ import Tinter from '../../Tinter'; import rateLimitedFunc from '../../ratelimitedfunc'; import * as Rooms from '../../Rooms'; import eventSearch, { searchPagination } from '../../Searching'; -import { isOnlyCtrlOrCmdIgnoreShiftKeyEvent, Key } from '../../Keyboard'; import MainSplit from './MainSplit'; import RightPanel from './RightPanel'; import RoomViewStore from '../../stores/RoomViewStore'; @@ -79,9 +78,11 @@ import Notifier from "../../Notifier"; import { showToast as showNotificationsToast } from "../../toasts/DesktopNotificationsToast"; import { RoomNotificationStateStore } from "../../stores/notifications/RoomNotificationStateStore"; import { Container, WidgetLayoutStore } from "../../stores/widgets/WidgetLayoutStore"; +import { getKeyBindingsManager, RoomAction } from '../../KeyBindingsManager'; import { objectHasDiff } from "../../utils/objects"; import SpaceRoomView from "./SpaceRoomView"; import { IOpts } from "../../createRoom"; +import {replaceableComponent} from "../../utils/replaceableComponent"; const DEBUG = false; let debuglog = function(msg: string) {}; @@ -111,10 +112,6 @@ interface IProps { inviterName?: string; }; - // Servers the RoomView can use to try and assist joins - viaServers?: string[]; - - autoJoin?: boolean; resizeNotifier: ResizeNotifier; justCreatedOpts?: IOpts; @@ -195,6 +192,7 @@ export interface IState { dragCounter: number; } +@replaceableComponent("structures.RoomView") export default class RoomView extends React.Component { private readonly dispatcherRef: string; private readonly roomStoreToken: EventSubscription; @@ -448,9 +446,7 @@ export default class RoomView extends React.Component { // now not joined because the js-sdk peeking API will clobber our historical room, // making it impossible to indicate a newly joined room. if (!joining && roomId) { - if (this.props.autoJoin) { - this.onJoinButtonClicked(); - } else if (!room && shouldPeek) { + if (!room && shouldPeek) { console.info("Attempting to peek into room %s", roomId); this.setState({ peekLoading: true, @@ -666,26 +662,20 @@ export default class RoomView extends React.Component { private onReactKeyDown = ev => { let handled = false; - switch (ev.key) { - case Key.ESCAPE: - if (!ev.altKey && !ev.ctrlKey && !ev.shiftKey && !ev.metaKey) { - this.messagePanel.forgetReadMarker(); - this.jumpToLiveTimeline(); - handled = true; - } + const action = getKeyBindingsManager().getRoomAction(ev); + switch (action) { + case RoomAction.DismissReadMarker: + this.messagePanel.forgetReadMarker(); + this.jumpToLiveTimeline(); + handled = true; break; - case Key.PAGE_UP: - if (!ev.altKey && !ev.ctrlKey && ev.shiftKey && !ev.metaKey) { - this.jumpToReadMarker(); - handled = true; - } + case RoomAction.JumpToOldestUnread: + this.jumpToReadMarker(); + handled = true; break; - case Key.U: // Mac returns lowercase - case Key.U.toUpperCase(): - if (isOnlyCtrlOrCmdIgnoreShiftKeyEvent(ev) && ev.shiftKey) { - dis.dispatch({ action: "upload_file" }, true); - handled = true; - } + case RoomAction.UploadFile: + dis.dispatch({ action: "upload_file" }, true); + handled = true; break; } @@ -711,9 +701,9 @@ export default class RoomView extends React.Component { [payload.file], this.state.room.roomId, this.context); break; case 'notifier_enabled': - case 'upload_started': - case 'upload_finished': - case 'upload_canceled': + case Action.UploadStarted: + case Action.UploadFinished: + case Action.UploadCanceled: this.forceUpdate(); break; case 'call_state': { @@ -1121,7 +1111,7 @@ export default class RoomView extends React.Component { const signUrl = this.props.threepidInvite?.signUrl; dis.dispatch({ action: 'join_room', - opts: { inviteSignUrl: signUrl, viaServers: this.props.viaServers }, + opts: { inviteSignUrl: signUrl }, _type: "unknown", // TODO: instrumentation }); return Promise.resolve(); @@ -1147,10 +1137,16 @@ export default class RoomView extends React.Component { ev.stopPropagation(); ev.preventDefault(); - this.setState({ - dragCounter: this.state.dragCounter + 1, - draggingFile: true, - }); + // We always increment the counter no matter the types, because dragging is + // still happening. If we didn't, the drag counter would get out of sync. + this.setState({dragCounter: this.state.dragCounter + 1}); + + // See: + // https://docs.w3cub.com/dom/datatransfer/types + // https://developer.mozilla.org/en-US/docs/Web/API/HTML_Drag_and_Drop_API/Recommended_drag_types#file + if (ev.dataTransfer.types.includes("Files") || ev.dataTransfer.types.includes("application/x-moz-file")) { + this.setState({draggingFile: true}); + } }; private onDragLeave = ev => { @@ -1174,6 +1170,9 @@ export default class RoomView extends React.Component { ev.dataTransfer.dropEffect = 'none'; + // See: + // https://docs.w3cub.com/dom/datatransfer/types + // https://developer.mozilla.org/en-US/docs/Web/API/HTML_Drag_and_Drop_API/Recommended_drag_types#file if (ev.dataTransfer.types.includes("Files") || ev.dataTransfer.types.includes("application/x-moz-file")) { ev.dataTransfer.dropEffect = 'copy'; } @@ -1911,7 +1910,7 @@ export default class RoomView extends React.Component { ); } - if (this.state.room?.isSpaceRoom()) { + if (SettingsStore.getValue("feature_spaces") && this.state.room?.isSpaceRoom()) { return { const scrollNode = this._getScrollNode(); - const delta = mult * scrollNode.clientHeight * 0.5; + const delta = mult * scrollNode.clientHeight * 0.9; scrollNode.scrollBy(0, delta); this._saveScrollState(); }; @@ -533,29 +535,19 @@ export default class ScrollPanel extends React.Component { * @param {object} ev the keyboard event */ handleScrollKey = ev => { - switch (ev.key) { - case Key.PAGE_UP: - if (!ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey) { - this.scrollRelative(-1); - } + const roomAction = getKeyBindingsManager().getRoomAction(ev); + switch (roomAction) { + case RoomAction.ScrollUp: + this.scrollRelative(-1); break; - - case Key.PAGE_DOWN: - if (!ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey) { - this.scrollRelative(1); - } + case RoomAction.RoomScrollDown: + this.scrollRelative(1); break; - - case Key.HOME: - if (ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey) { - this.scrollToTop(); - } + case RoomAction.JumpToFirstMessage: + this.scrollToTop(); break; - - case Key.END: - if (ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey) { - this.scrollToBottom(); - } + case RoomAction.JumpToLatestMessage: + this.scrollToBottom(); break; } }; diff --git a/src/components/structures/SearchBox.js b/src/components/structures/SearchBox.js index c1e3ad0cf2..abeb858274 100644 --- a/src/components/structures/SearchBox.js +++ b/src/components/structures/SearchBox.js @@ -22,7 +22,9 @@ import dis from '../../dispatcher/dispatcher'; import {throttle} from 'lodash'; import AccessibleButton from '../../components/views/elements/AccessibleButton'; import classNames from 'classnames'; +import {replaceableComponent} from "../../utils/replaceableComponent"; +@replaceableComponent("structures.SearchBox") export default class SearchBox extends React.Component { static propTypes = { onSearch: PropTypes.func, @@ -30,6 +32,8 @@ export default class SearchBox extends React.Component { onKeyDown: PropTypes.func, className: PropTypes.string, placeholder: PropTypes.string.isRequired, + autoFocus: PropTypes.bool, + initialValue: PropTypes.string, // If true, the search box will focus and clear itself // on room search focus action (it would be nicer to take @@ -47,7 +51,7 @@ export default class SearchBox extends React.Component { this._search = createRef(); this.state = { - searchTerm: "", + searchTerm: this.props.initialValue || "", blurred: true, }; } @@ -156,6 +160,7 @@ export default class SearchBox extends React.Component { onBlur={this._onBlur} placeholder={ placeholder } autoComplete="off" + autoFocus={this.props.autoFocus} /> { clearButton }
    diff --git a/src/components/structures/SpaceRoomDirectory.tsx b/src/components/structures/SpaceRoomDirectory.tsx index 06df6a528e..8d6c9f0a70 100644 --- a/src/components/structures/SpaceRoomDirectory.tsx +++ b/src/components/structures/SpaceRoomDirectory.tsx @@ -14,31 +14,37 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, {useMemo, useRef, useState} from "react"; -import Room from "matrix-js-sdk/src/models/room"; -import MatrixEvent from "matrix-js-sdk/src/models/event"; +import React, {useMemo, useState} from "react"; +import {Room} from "matrix-js-sdk/src/models/room"; +import {MatrixClient} from "matrix-js-sdk/src/client"; import {EventType, RoomType} from "matrix-js-sdk/src/@types/event"; +import classNames from "classnames"; +import {sortBy} from "lodash"; import {MatrixClientPeg} from "../../MatrixClientPeg"; import dis from "../../dispatcher/dispatcher"; import {_t} from "../../languageHandler"; import AccessibleButton from "../views/elements/AccessibleButton"; import BaseDialog from "../views/dialogs/BaseDialog"; -import FormButton from "../views/elements/FormButton"; +import Spinner from "../views/elements/Spinner"; import SearchBox from "./SearchBox"; import RoomAvatar from "../views/avatars/RoomAvatar"; import RoomName from "../views/elements/RoomName"; import {useAsyncMemo} from "../../hooks/useAsyncMemo"; -import {shouldShowSpaceSettings} from "../../utils/space"; import {EnhancedMap} from "../../utils/maps"; import StyledCheckbox from "../views/elements/StyledCheckbox"; import AutoHideScrollbar from "./AutoHideScrollbar"; import BaseAvatar from "../views/avatars/BaseAvatar"; +import {mediaFromMxc} from "../../customisations/Media"; +import InfoTooltip from "../views/elements/InfoTooltip"; +import TextWithTooltip from "../views/elements/TextWithTooltip"; +import {useStateToggle} from "../../hooks/useStateToggle"; -interface IProps { +interface IHierarchyProps { space: Room; initialText?: string; - onFinished(): void; + refreshToken?: any; + showRoom(room: ISpaceSummaryRoom, viaServers?: string[], autoJoin?: boolean): void; } /* eslint-disable camelcase */ @@ -64,243 +70,138 @@ export interface ISpaceSummaryEvent { state_key: string; content: { order?: string; + suggested?: boolean; auto_join?: boolean; via?: string; }; } /* eslint-enable camelcase */ -interface ISubspaceProps { - space: ISpaceSummaryRoom; - event?: MatrixEvent; - editing?: boolean; - onPreviewClick?(): void; - queueAction?(action: IAction): void; - onJoinClick?(): void; +interface ITileProps { + room: ISpaceSummaryRoom; + suggested?: boolean; + selected?: boolean; + numChildRooms?: number; + hasPermissions?: boolean; + onViewRoomClick(autoJoin: boolean): void; + onToggleClick?(): void; } -const SubSpace: React.FC = ({ - space, - editing, - event, - queueAction, - onJoinClick, - onPreviewClick, +const Tile: React.FC = ({ + room, + suggested, + selected, + hasPermissions, + onToggleClick, + onViewRoomClick, + numChildRooms, children, }) => { - const name = space.name || space.canonical_alias || space.aliases?.[0] || _t("Unnamed Space"); + const name = room.name || room.canonical_alias || room.aliases?.[0] + || (room.room_type === RoomType.Space ? _t("Unnamed Space") : _t("Unnamed Room")); - const evContent = event?.getContent(); - const [autoJoin, _setAutoJoin] = useState(evContent?.auto_join); - const [removed, _setRemoved] = useState(!evContent?.via); - - const cli = MatrixClientPeg.get(); - const cliRoom = cli.getRoom(space.room_id); - const myMembership = cliRoom?.getMyMembership(); - - // TODO DRY code - let actions; - if (editing && queueAction) { - if (event && cli.getRoom(event.getRoomId())?.currentState.maySendStateEvent(event.getType(), cli.getUserId())) { - const setAutoJoin = () => { - _setAutoJoin(v => { - queueAction({ - event, - removed, - autoJoin: !v, - }); - return !v; - }); - }; - - const setRemoved = () => { - _setRemoved(v => { - queueAction({ - event, - removed: !v, - autoJoin, - }); - return !v; - }); - }; - - if (removed) { - actions = - - ; - } else { - actions = - - - ; - } - } else { - actions = - { _t("No permissions")} - ; - } - // TODO confirm remove from space click behaviour here - } else { - if (myMembership === "join") { - actions = - { _t("You're in this space")} - ; - } else if (onJoinClick) { - actions = - - { _t("Preview") } - - - - } - } - - let url: string; - if (space.avatar_url) { - url = MatrixClientPeg.get().mxcUrlToHttp( - space.avatar_url, - Math.floor(24 * window.devicePixelRatio), - Math.floor(24 * window.devicePixelRatio), - "crop", - ); - } - - return
    -
    - - { name } - -
    - { actions } -
    -
    -
    - { children } -
    -
    -}; - -interface IAction { - event: MatrixEvent; - removed: boolean; - autoJoin: boolean; -} - -interface IRoomTileProps { - room: ISpaceSummaryRoom; - event?: MatrixEvent; - editing?: boolean; - onPreviewClick(): void; - queueAction?(action: IAction): void; - onJoinClick?(): void; -} - -const RoomTile = ({ room, event, editing, queueAction, onPreviewClick, onJoinClick }: IRoomTileProps) => { - const name = room.name || room.canonical_alias || room.aliases?.[0] || _t("Unnamed Room"); - - const evContent = event?.getContent(); - const [autoJoin, _setAutoJoin] = useState(evContent?.auto_join); - const [removed, _setRemoved] = useState(!evContent?.via); + const [showChildren, toggleShowChildren] = useStateToggle(true); const cli = MatrixClientPeg.get(); const cliRoom = cli.getRoom(room.room_id); const myMembership = cliRoom?.getMyMembership(); - let actions; - if (editing && queueAction) { - if (event && cli.getRoom(event.getRoomId())?.currentState.maySendStateEvent(event.getType(), cli.getUserId())) { - const setAutoJoin = () => { - _setAutoJoin(v => { - queueAction({ - event, - removed, - autoJoin: !v, - }); - return !v; - }); - }; + const onPreviewClick = () => onViewRoomClick(false); + const onJoinClick = () => onViewRoomClick(true); - const setRemoved = () => { - _setRemoved(v => { - queueAction({ - event, - removed: !v, - autoJoin, - }); - return !v; - }); - }; + let button; + if (myMembership === "join") { + button = + { _t("View") } + ; + } else if (onJoinClick) { + button = + { _t("Join") } + ; + } - if (removed) { - actions = - - ; - } else { - actions = - - - ; - } + let checkbox; + if (onToggleClick) { + if (hasPermissions) { + checkbox = ; } else { - actions = - { _t("No permissions")} - ; - } - // TODO confirm remove from space click behaviour here - } else { - if (myMembership === "join") { - actions = - { _t("You're in this room")} - ; - } else if (onJoinClick) { - actions = - - { _t("Preview") } - - - + checkbox = { ev.stopPropagation() }} + > + + ; } } let url: string; if (room.avatar_url) { - url = cli.mxcUrlToHttp( - room.avatar_url, - Math.floor(32 * window.devicePixelRatio), - Math.floor(32 * window.devicePixelRatio), - "crop", - ); + url = mediaFromMxc(room.avatar_url).getSquareThumbnailHttp(20); + } + + let description = _t("%(count)s members", { count: room.num_joined_members }); + if (numChildRooms) { + description += " · " + _t("%(count)s rooms", { count: numChildRooms }); + } + if (room.topic) { + description += " · " + room.topic; + } + + let suggestedSection; + if (suggested) { + suggestedSection = + { _t("Suggested") } + ; } const content = - + +
    + { name } + { suggestedSection } +
    -
    - { name } -
    -
    - { room.topic } -
    + { description }
    -
    - { room.num_joined_members } -
    -
    - { actions } + { button } + { checkbox }
    ; - if (editing) { - return
    - { content } -
    + let childToggle; + let childSection; + if (children) { + // the chevron is purposefully a div rather than a button as it should be ignored for a11y + childToggle =
    { + ev.stopPropagation(); + toggleShowChildren(); + }} + />; + if (showChildren) { + childSection =
    + { children } +
    ; + } } - return - { content } - ; + return <> + + { content } + { childToggle } + + { childSection } + ; }; export const showRoom = (room: ISpaceSummaryRoom, viaServers?: string[], autoJoin = false) => { @@ -333,99 +234,339 @@ export const showRoom = (room: ISpaceSummaryRoom, viaServers?: string[], autoJoi interface IHierarchyLevelProps { spaceId: string; rooms: Map; - editing?: boolean; - relations: EnhancedMap; + relations: Map>; parents: Set; - queueAction?(action: IAction): void; - onPreviewClick(roomId: string): void; - onRemoveFromSpaceClick?(roomId: string): void; - onJoinClick?(roomId: string): void; + selectedMap?: Map>; + onViewRoomClick(roomId: string, autoJoin: boolean): void; + onToggleClick?(parentId: string, childId: string): void; } export const HierarchyLevel = ({ spaceId, rooms, - editing, relations, parents, - onPreviewClick, - onJoinClick, - queueAction, + selectedMap, + onViewRoomClick, + onToggleClick, }: IHierarchyLevelProps) => { const cli = MatrixClientPeg.get(); const space = cli.getRoom(spaceId); - // TODO respect order - const [subspaces, childRooms] = relations.get(spaceId)?.reduce((result, roomId: string) => { - if (!rooms.has(roomId)) return result; // TODO wat + const hasPermissions = space?.currentState.maySendStateEvent(EventType.SpaceChild, cli.getUserId()); + + const sortedChildren = sortBy([...(relations.get(spaceId)?.values() || [])], ev => ev.content.order || null); + const [subspaces, childRooms] = sortedChildren.reduce((result, ev: ISpaceSummaryEvent) => { + const roomId = ev.state_key; + if (!rooms.has(roomId)) return result; result[rooms.get(roomId).room_type === RoomType.Space ? 0 : 1].push(roomId); return result; }, [[], []]) || [[], []]; - // Don't render this subspace if it has no rooms we can show - // TODO this is broken - as a space may have subspaces we still need to show - // if (!childRooms.length) return null; - - const userId = cli.getUserId(); - const newParents = new Set(parents).add(spaceId); return { childRooms.map(roomId => ( - { - onPreviewClick(roomId); + suggested={relations.get(spaceId)?.get(roomId)?.content.suggested} + selected={selectedMap?.get(spaceId)?.has(roomId)} + onViewRoomClick={(autoJoin) => { + onViewRoomClick(roomId, autoJoin); }} - onJoinClick={onJoinClick ? () => { - onJoinClick(roomId); - } : undefined} + hasPermissions={hasPermissions} + onToggleClick={onToggleClick ? () => onToggleClick(spaceId, roomId) : undefined} /> )) } { subspaces.filter(roomId => !newParents.has(roomId)).map(roomId => ( - { - onPreviewClick(roomId); - }} - onJoinClick={() => { - onJoinClick(roomId); + room={rooms.get(roomId)} + numChildRooms={Array.from(relations.get(roomId)?.values() || []) + .filter(ev => rooms.get(ev.state_key)?.room_type !== RoomType.Space).length} + suggested={relations.get(spaceId)?.get(roomId)?.content.suggested} + selected={selectedMap?.get(spaceId)?.has(roomId)} + onViewRoomClick={(autoJoin) => { + onViewRoomClick(roomId, autoJoin); }} + hasPermissions={hasPermissions} + onToggleClick={onToggleClick ? () => onToggleClick(spaceId, roomId) : undefined} > - + )) } }; -const SpaceRoomDirectory: React.FC = ({ space, initialText = "", onFinished }) => { +// mutate argument refreshToken to force a reload +export const useSpaceSummary = (cli: MatrixClient, space: Room, refreshToken?: any): [ + null, + ISpaceSummaryRoom[], + Map>?, + Map>?, + Map>?, +] | [Error] => { // TODO pagination - const cli = MatrixClientPeg.get(); - const [query, setQuery] = useState(initialText); - const [isEditing, setIsEditing] = useState(false); + return useAsyncMemo(async () => { + try { + const data = await cli.getSpaceSummary(space.roomId); + const parentChildRelations = new EnhancedMap>(); + const childParentRelations = new EnhancedMap>(); + const viaMap = new EnhancedMap>(); + data.events.map((ev: ISpaceSummaryEvent) => { + if (ev.type === EventType.SpaceChild) { + parentChildRelations.getOrCreate(ev.room_id, new Map()).set(ev.state_key, ev); + childParentRelations.getOrCreate(ev.state_key, new Set()).add(ev.room_id); + } + if (Array.isArray(ev.content["via"])) { + const set = viaMap.getOrCreate(ev.state_key, new Set()); + ev.content["via"].forEach(via => set.add(via)); + } + }); + + return [null, data.rooms as ISpaceSummaryRoom[], parentChildRelations, viaMap, childParentRelations]; + } catch (e) { + console.error(e); // TODO + return [e]; + } + }, [space, refreshToken], [undefined]); +}; + +export const SpaceHierarchy: React.FC = ({ + space, + initialText = "", + showRoom, + refreshToken, + children, +}) => { + const cli = MatrixClientPeg.get(); + const userId = cli.getUserId(); + const [query, setQuery] = useState(initialText); + + const [selected, setSelected] = useState(new Map>()); // Map> + + const [summaryError, rooms, parentChildMap, viaMap, childParentMap] = useSpaceSummary(cli, space, refreshToken); + + const roomsMap = useMemo(() => { + if (!rooms) return null; + const lcQuery = query.toLowerCase().trim(); + + const roomsMap = new Map(rooms.map(r => [r.room_id, r])); + if (!lcQuery) return roomsMap; + + const directMatches = rooms.filter(r => { + return r.name?.toLowerCase().includes(lcQuery) || r.topic?.toLowerCase().includes(lcQuery); + }); + + // Walk back up the tree to find all parents of the direct matches to show their place in the hierarchy + const visited = new Set(); + const queue = [...directMatches.map(r => r.room_id)]; + while (queue.length) { + const roomId = queue.pop(); + visited.add(roomId); + childParentMap.get(roomId)?.forEach(parentId => { + if (!visited.has(parentId)) { + queue.push(parentId); + } + }); + } + + // Remove any mappings for rooms which were not visited in the walk + Array.from(roomsMap.keys()).forEach(roomId => { + if (!visited.has(roomId)) { + roomsMap.delete(roomId); + } + }); + return roomsMap; + }, [rooms, childParentMap, query]); + + const [error, setError] = useState(""); + const [removing, setRemoving] = useState(false); + const [saving, setSaving] = useState(false); + + if (summaryError) { + return

    {_t("Your server does not support showing space hierarchies.")}

    ; + } + + let content; + if (roomsMap) { + const numRooms = Array.from(roomsMap.values()).filter(r => r.room_type !== RoomType.Space).length; + const numSpaces = roomsMap.size - numRooms - 1; // -1 at the end to exclude the space we are looking at + + let countsStr; + if (numSpaces > 1) { + countsStr = _t("%(count)s rooms and %(numSpaces)s spaces", { count: numRooms, numSpaces }); + } else if (numSpaces > 0) { + countsStr = _t("%(count)s rooms and 1 space", { count: numRooms, numSpaces }); + } else { + countsStr = _t("%(count)s rooms", { count: numRooms, numSpaces }); + } + + let editSection; + if (space.getMyMembership() === "join" && space.currentState.maySendStateEvent(EventType.SpaceChild, userId)) { + const selectedRelations = Array.from(selected.keys()).flatMap(parentId => { + return [...selected.get(parentId).values()].map(childId => [parentId, childId]) as [string, string][]; + }); + + let buttons; + if (selectedRelations.length) { + const selectionAllSuggested = selectedRelations.every(([parentId, childId]) => { + return parentChildMap.get(parentId)?.get(childId)?.content.suggested; + }); + + const disabled = removing || saving; + + buttons = <> + { + setRemoving(true); + try { + for (const [parentId, childId] of selectedRelations) { + await cli.sendStateEvent(parentId, EventType.SpaceChild, {}, childId); + parentChildMap.get(parentId).get(childId).content = {}; + parentChildMap.set(parentId, new Map(parentChildMap.get(parentId))); + } + } catch (e) { + setError(_t("Failed to remove some rooms. Try again later")); + } + setRemoving(false); + }} + kind="danger_outline" + disabled={disabled} + > + { removing ? _t("Removing...") : _t("Remove") } + + { + setSaving(true); + try { + for (const [parentId, childId] of selectedRelations) { + const suggested = !selectionAllSuggested; + const existingContent = parentChildMap.get(parentId)?.get(childId)?.content; + if (!existingContent || existingContent.suggested === suggested) continue; + + const content = { + ...existingContent, + suggested: !selectionAllSuggested, + }; + + await cli.sendStateEvent(parentId, EventType.SpaceChild, content, childId); + + parentChildMap.get(parentId).get(childId).content = content; + parentChildMap.set(parentId, new Map(parentChildMap.get(parentId))); + } + } catch (e) { + setError("Failed to update some suggestions. Try again later"); + } + setSaving(false); + }} + kind="primary_outline" + disabled={disabled} + > + { saving + ? _t("Saving...") + : (selectionAllSuggested ? _t("Mark as not suggested") : _t("Mark as suggested")) + } + + ; + } + + editSection = + { buttons } + ; + } + + let results; + if (roomsMap.size) { + const hasPermissions = space?.currentState.maySendStateEvent(EventType.SpaceChild, cli.getUserId()); + + results = <> + { + setError(""); + if (!selected.has(parentId)) { + setSelected(new Map(selected.set(parentId, new Set([childId])))); + return; + } + + const parentSet = selected.get(parentId); + if (!parentSet.has(childId)) { + setSelected(new Map(selected.set(parentId, new Set([...parentSet, childId])))); + return; + } + + parentSet.delete(childId); + setSelected(new Map(selected.set(parentId, new Set(parentSet)))); + } : undefined} + onViewRoomClick={(roomId, autoJoin) => { + showRoom(roomsMap.get(roomId), Array.from(viaMap.get(roomId) || []), autoJoin); + }} + /> + { children &&
    } + ; + } else { + results =
    +

    { _t("No results found") }

    +
    { _t("You may want to try a different search or check for typos.") }
    +
    ; + } + + content = <> +
    + { countsStr } + { editSection } +
    + { error &&
    + { error } +
    } + + { results } + { children } + + ; + } else { + content = ; + } + + // TODO loading state/error state + return <> + + + { content } + ; +}; + +interface IProps { + space: Room; + initialText?: string; + onFinished(): void; +} + +const SpaceRoomDirectory: React.FC = ({ space, onFinished, initialText }) => { const onCreateRoomClick = () => { dis.dispatch({ action: 'view_create_room', @@ -434,134 +575,40 @@ const SpaceRoomDirectory: React.FC = ({ space, initialText = "", onFinis onFinished(); }; - // stored within a ref as we don't need to re-render when it changes - const pendingActions = useRef(new Map()); - - let adminButton; - if (shouldShowSpaceSettings(cli, space)) { // TODO this is an imperfect test - const onManageButtonClicked = () => { - setIsEditing(true); - }; - - const onSaveButtonClicked = () => { - // TODO setBusy - pendingActions.current.forEach(({event, autoJoin, removed}) => { - const content = { - ...event.getContent(), - auto_join: autoJoin, - }; - - if (removed) { - delete content["via"]; - } - - cli.sendStateEvent(event.getRoomId(), event.getType(), content, event.getStateKey()); - }); - setIsEditing(false); - }; - - if (isEditing) { - adminButton = - - { _t("All users join by default") } - ; - } else { - adminButton = ; - } - } - - const [rooms, relations, viaMap] = useAsyncMemo(async () => { - try { - const data = await cli.getSpaceSummary(space.roomId); - - const parentChildRelations = new EnhancedMap(); - const viaMap = new EnhancedMap>(); - data.events.map((ev: ISpaceSummaryEvent) => { - if (ev.type === EventType.SpaceChild) { - parentChildRelations.getOrCreate(ev.room_id, []).push(ev.state_key); - } - if (Array.isArray(ev.content["via"])) { - const set = viaMap.getOrCreate(ev.state_key, new Set()); - ev.content["via"].forEach(via => set.add(via)); - } - }); - - return [data.rooms, parentChildRelations, viaMap]; - } catch (e) { - console.error(e); // TODO - } - - return []; - }, [space], []); - - const roomsMap = useMemo(() => { - if (!rooms) return null; - const lcQuery = query.toLowerCase(); - - const filteredRooms = rooms.filter(r => { - return r.room_type === RoomType.Space // always include spaces to allow filtering of sub-space rooms - || r.name?.toLowerCase().includes(lcQuery) - || r.topic?.toLowerCase().includes(lcQuery); - }); - - return new Map(filteredRooms.map(r => [r.room_id, r])); - // const root = rooms.get(space.roomId); - }, [rooms, query]); - const title = - +

    { _t("Explore rooms") }

    ; - const explanation = - _t("If you can't find the room you're looking for, ask for an invite or Create a new room.", null, - {a: sub => { - return {sub}; - }}, - ); - let content; - if (roomsMap) { - content = - { - pendingActions.current.set(action.event.room_id, action); - }} - onPreviewClick={roomId => { - showRoom(roomsMap.get(roomId), Array.from(viaMap.get(roomId) || []), false); - onFinished(); - }} - onJoinClick={(roomId) => { - showRoom(roomsMap.get(roomId), Array.from(viaMap.get(roomId) || []), true); - onFinished(); - }} - /> - ; - } - - // TODO loading state/error state return (
    - { explanation } + { _t("If you can't find the room you're looking for, ask for an invite or create a new room.", + null, + {a: sub => { + return {sub}; + }}, + ) } - - -
    - { adminButton } -
    - { content } + { + showRoom(room, viaServers, autoJoin); + onFinished(); + }} + initialText={initialText} + > + + { _t("Create room") } + +
    ); diff --git a/src/components/structures/SpaceRoomView.tsx b/src/components/structures/SpaceRoomView.tsx index 5c91efc1c0..cdf9dc02d3 100644 --- a/src/components/structures/SpaceRoomView.tsx +++ b/src/components/structures/SpaceRoomView.tsx @@ -15,8 +15,9 @@ limitations under the License. */ import React, {RefObject, useContext, useRef, useState} from "react"; -import {EventType, RoomType} from "matrix-js-sdk/src/@types/event"; +import {EventType} from "matrix-js-sdk/src/@types/event"; import {Room} from "matrix-js-sdk/src/models/room"; +import {EventSubscription} from "fbemitter"; import MatrixClientContext from "../../contexts/MatrixClientContext"; import RoomAvatar from "../views/avatars/RoomAvatar"; @@ -25,13 +26,11 @@ import AccessibleButton from "../views/elements/AccessibleButton"; import RoomName from "../views/elements/RoomName"; import RoomTopic from "../views/elements/RoomTopic"; import InlineSpinner from "../views/elements/InlineSpinner"; -import FormButton from "../views/elements/FormButton"; import {inviteMultipleToRoom, showRoomInviteDialog} from "../../RoomInvite"; import {useRoomMembers} from "../../hooks/useRoomMembers"; import createRoom, {IOpts, Preset} from "../../createRoom"; import Field from "../views/elements/Field"; import {useEventEmitter} from "../../hooks/useEventEmitter"; -import StyledRadioGroup from "../views/elements/StyledRadioGroup"; import withValidation from "../views/elements/Validation"; import * as Email from "../../email"; import defaultDispatcher from "../../dispatcher/dispatcher"; @@ -42,18 +41,19 @@ import ErrorBoundary from "../views/elements/ErrorBoundary"; import {ActionPayload} from "../../dispatcher/payloads"; import RightPanel from "./RightPanel"; import RightPanelStore from "../../stores/RightPanelStore"; -import {EventSubscription} from "fbemitter"; import {RightPanelPhases} from "../../stores/RightPanelStorePhases"; import {SetRightPanelPhasePayload} from "../../dispatcher/payloads/SetRightPanelPhasePayload"; import {useStateArray} from "../../hooks/useStateArray"; import SpacePublicShare from "../views/spaces/SpacePublicShare"; import {showAddExistingRooms, showCreateNewRoom, shouldShowSpaceSettings, showSpaceSettings} from "../../utils/space"; -import {HierarchyLevel, ISpaceSummaryEvent, ISpaceSummaryRoom, showRoom} from "./SpaceRoomDirectory"; -import {useAsyncMemo} from "../../hooks/useAsyncMemo"; -import {EnhancedMap} from "../../utils/maps"; -import AutoHideScrollbar from "./AutoHideScrollbar"; +import {showRoom, SpaceHierarchy} from "./SpaceRoomDirectory"; import MemberAvatar from "../views/avatars/MemberAvatar"; import {useStateToggle} from "../../hooks/useStateToggle"; +import SpaceStore from "../../stores/SpaceStore"; +import FacePile from "../views/elements/FacePile"; +import {AddExistingToSpace} from "../views/dialogs/AddExistingToSpaceDialog"; +import {allSettled} from "../../utils/promise"; +import {calculateRoomVia} from "../../utils/permalinks/Permalinks"; interface IProps { space: Room; @@ -66,6 +66,7 @@ interface IProps { interface IState { phase: Phase; showRightPanel: boolean; + myMembership: string; } enum Phase { @@ -94,40 +95,151 @@ const useMyRoomMembership = (room: Room) => { return membership; }; -const SpaceLanding = ({ space, onJoinButtonClicked, onRejectButtonClicked }) => { +const SpaceInfo = ({ space }) => { + const joinRule = space.getJoinRule(); + + let visibilitySection; + if (joinRule === "public") { + visibilitySection = + { _t("Public space") } + ; + } else { + visibilitySection = + { _t("Private space") } + ; + } + + return
    + { visibilitySection } + { joinRule === "public" && + {(count) => count > 0 ? ( + { + defaultDispatcher.dispatch({ + action: Action.SetRightPanelPhase, + phase: RightPanelPhases.RoomMemberList, + refireParams: { space }, + }); + }} + > + { _t("%(count)s members", { count }) } + + ) : null} + } +
    +}; + +const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) => { const cli = useContext(MatrixClientContext); const myMembership = useMyRoomMembership(space); - const joinRule = space.getJoinRule(); - const userId = cli.getUserId(); + const [busy, setBusy] = useState(false); + + let inviterSection; let joinButtons; if (myMembership === "invite") { - joinButtons =
    - - - {_t("Decline")} + const inviteSender = space.getMember(cli.getUserId())?.events.member?.getSender(); + const inviter = inviteSender && space.getMember(inviteSender); + + if (inviteSender) { + inviterSection =
    + +
    +
    + { _t(" invites you", {}, { + inviter: () => { inviter.name || inviteSender }, + }) } +
    + { inviter ?
    + { inviteSender } +
    : null } +
    +
    ; + } + + joinButtons = <> + { + setBusy(true); + onRejectButtonClicked(); + }} + > + { _t("Reject") } -
    ; - } else if (myMembership !== "join" && joinRule === "public") { - joinButtons =
    - -
    ; + { + setBusy(true); + onJoinButtonClicked(); + }} + > + { _t("Accept") } + + ; + } else { + joinButtons = ( + { + setBusy(true); + onJoinButtonClicked(); + }} + > + { _t("Join") } + + ) } + if (busy) { + joinButtons = ; + } + + return
    + { inviterSection } + +

    + +

    + + + {(topic, ref) => +
    + { topic } +
    + } +
    + { space.getJoinRule() === "public" && } +
    + { joinButtons } +
    +
    ; +}; + +const SpaceLanding = ({ space }) => { + const cli = useContext(MatrixClientContext); + const myMembership = useMyRoomMembership(space); + const userId = cli.getUserId(); + let inviteButton; if (myMembership === "join" && space.canInvite(userId)) { inviteButton = ( - { - showRoomInviteDialog(space.roomId); - }}> - { _t("Invite people") } + { + showRoomInviteDialog(space.roomId); + }} + > + { _t("Invite") } ); } const canAddRooms = myMembership === "join" && space.currentState.maySendStateEvent(EventType.SpaceChild, userId); - const [_, forceUpdate] = useStateToggle(false); // TODO + const [refreshToken, forceUpdate] = useStateToggle(false); let addRoomButtons; if (canAddRooms) { @@ -157,50 +269,13 @@ const SpaceLanding = ({ space, onJoinButtonClicked, onRejectButtonClicked }) => ; } - const [loading, roomsMap, relations, numRooms] = useAsyncMemo(async () => { - try { - const data = await cli.getSpaceSummary(space.roomId, undefined, myMembership !== "join"); - - const parentChildRelations = new EnhancedMap(); - data.events.map((ev: ISpaceSummaryEvent) => { - if (ev.type === EventType.SpaceChild) { - parentChildRelations.getOrCreate(ev.room_id, []).push(ev.state_key); - } - }); - - const roomsMap = new Map(data.rooms.map(r => [r.room_id, r])); - const numRooms = data.rooms.filter(r => r.room_type !== RoomType.Space).length; - return [false, roomsMap, parentChildRelations, numRooms]; - } catch (e) { - console.error(e); // TODO - } - - return [false]; - }, [space, _], [true]); - - let previewRooms; - if (roomsMap) { - previewRooms = -
    -

    { myMembership === "join" ? _t("Rooms") : _t("Default Rooms")}

    - { numRooms } -
    - { - showRoom(roomsMap.get(roomId), [], false); // TODO - }} - /> -
    ; - } else if (loading) { - previewRooms = ; - } else { - previewRooms =

    {_t("Your server does not support showing space hierarchies.")}

    ; - } + const onMembersClick = () => { + defaultDispatcher.dispatch({ + action: Action.SetRightPanelPhase, + phase: RightPanelPhases.RoomMemberList, + refireParams: { space }, + }); + }; return
    @@ -209,65 +284,26 @@ const SpaceLanding = ({ space, onJoinButtonClicked, onRejectButtonClicked }) => {(name) => { const tags = { name: () =>

    { name }

    - - {(count) => count > 0 ? ( - { - defaultDispatcher.dispatch({ - action: Action.SetRightPanelPhase, - phase: RightPanelPhases.RoomMemberList, - refireParams: { space }, - }); - }} - > - { _t("%(count)s members", { count }) } - - ) : null} -
    }; - if (myMembership === "invite") { - const inviteSender = space.getMember(userId)?.events.member?.getSender(); - const inviter = inviteSender && space.getMember(inviteSender); - - if (inviteSender) { - return _t(" invited you to ", {}, { - name: tags.name, - inviter: () => inviter - ? - - { inviter.name } - - : - { inviteSender } - , - }) as JSX.Element; - } else { - return _t("You have been invited to ", {}, tags) as JSX.Element; - } - } else if (shouldShowSpaceSettings(cli, space)) { - if (space.getJoinRule() === "public") { - return _t("Your public space ", {}, tags) as JSX.Element; - } else { - return _t("Your private space ", {}, tags) as JSX.Element; - } - } return _t("Welcome to ", {}, tags) as JSX.Element; }}
    +
    + + + { inviteButton } +
    - { joinButtons } +
    - { inviteButton } { addRoomButtons } { settingsButton }
    - { previewRooms } +
    ; }; @@ -288,6 +324,7 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => { placeholder={placeholders[i]} value={roomNames[i]} onChange={ev => setRoomName(i, ev.target.value)} + autoFocus={i === 2} />; }); @@ -320,7 +357,7 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => { let buttonLabel = _t("Skip for now"); if (roomNames.some(name => name.trim())) { onClick = onNextClick; - buttonLabel = busy ? _t("Creating rooms...") : _t("Next") + buttonLabel = busy ? _t("Creating rooms...") : _t("Continue"); } return
    @@ -331,61 +368,114 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => { { fields }
    - + > + { buttonLabel } + +
    +
    ; +}; + +const SpaceAddExistingRooms = ({ space, onFinished }) => { + const [selectedToAdd, setSelectedToAdd] = useState(new Set()); + + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + + let onClick = onFinished; + let buttonLabel = _t("Skip for now"); + if (selectedToAdd.size > 0) { + onClick = async () => { + // TODO rate limiting + setBusy(true); + try { + await allSettled(Array.from(selectedToAdd).map((room) => + SpaceStore.instance.addRoomToSpace(space, room.roomId, calculateRoomVia(room)))); + onFinished(true); + } catch (e) { + console.error("Failed to add rooms to space", e); + setError(_t("Failed to add rooms to space")); + } + setBusy(false); + }; + buttonLabel = busy ? _t("Adding...") : _t("Add"); + } + + return
    +

    { _t("What do you want to organise?") }

    +
    + { _t("Pick rooms or conversations to add. This is just a space for you, " + + "no one will be informed. You can add more later.") } +
    + + { error &&
    { error }
    } + + { + if (checked) { + selectedToAdd.add(room); + } else { + selectedToAdd.delete(room); + } + setSelectedToAdd(new Set(selectedToAdd)); + }} + /> + +
    + + { buttonLabel } +
    ; }; const SpaceSetupPublicShare = ({ space, onFinished }) => { return
    -

    { _t("Share your public space") }

    -
    { _t("At the moment only you can see it.") }
    +

    { _t("Share %(name)s", { name: space.name }) }

    +
    + { _t("It's just you at the moment, it will be even better with others.") } +
    - +
    - + + { _t("Go to my first room") } +
    ; }; -const SpaceSetupPrivateScope = ({ onFinished }) => { - const [option, setOption] = useState(null); - +const SpaceSetupPrivateScope = ({ space, onFinished }) => { return

    { _t("Who are you working with?") }

    -
    { _t("Ensure the right people have access to the space.") }
    - - -

    { _t("Just Me") }

    -
    { _t("A private space just for you") }
    - , - }, { - value: "meAndMyTeammates", - className: "mx_SpaceRoomView_privateScope_meAndMyTeammatesButton", - label: -

    { _t("Me and my teammates") }

    -
    { _t("A private space for you and your teammates") }
    -
    , - }, - ]} - /> - -
    - onFinished(option !== "justMe")} /> +
    + { _t("Make sure the right people have access to %(name)s", { name: space.name }) }
    + + { onFinished(false) }} + > +

    { _t("Just me") }

    +
    { _t("A private space to organise your rooms") }
    +
    + { onFinished(true) }} + > +

    { _t("Me and my teammates") }

    +
    { _t("A private space for you and your teammates") }
    +
    ; }; @@ -415,6 +505,7 @@ const SpaceSetupPrivateInvite = ({ space, onFinished }) => { onChange={ev => setEmailAddress(i, ev.target.value)} ref={fieldRefs[i]} onValidate={validateEmailRules} + autoFocus={i === 0} />; }); @@ -452,9 +543,18 @@ const SpaceSetupPrivateInvite = ({ space, onFinished }) => { setBusy(false); }; + let onClick = onFinished; + let buttonLabel = _t("Skip for now"); + if (emailAddresses.some(name => name.trim())) { + onClick = onNextClick; + buttonLabel = busy ? _t("Inviting...") : _t("Continue") + } + return

    { _t("Invite your teammates") }

    -
    { _t("Ensure the right people have access to the space.") }
    +
    + { _t("Make sure the right people have access. You can invite more later.") } +
    { error &&
    { error }
    } { fields } @@ -469,8 +569,9 @@ const SpaceSetupPrivateInvite = ({ space, onFinished }) => {
    - {_t("Skip for now")} - + + { buttonLabel } +
    ; }; @@ -498,17 +599,26 @@ export default class SpaceRoomView extends React.PureComponent { this.state = { phase, showRightPanel: RightPanelStore.getSharedInstance().isOpenForRoom, + myMembership: this.props.space.getMyMembership(), }; this.dispatcherRef = defaultDispatcher.register(this.onAction); this.rightPanelStoreToken = RightPanelStore.getSharedInstance().addListener(this.onRightPanelStoreUpdate); + this.context.on("Room.myMembership", this.onMyMembership); } componentWillUnmount() { defaultDispatcher.unregister(this.dispatcherRef); this.rightPanelStoreToken.remove(); + this.context.off("Room.myMembership", this.onMyMembership); } + private onMyMembership = (room: Room, myMembership: string) => { + if (room.roomId === this.props.space.roomId) { + this.setState({ myMembership }); + } + }; + private onRightPanelStoreUpdate = () => { this.setState({ showRightPanel: RightPanelStore.getSharedInstance().isOpenForRoom, @@ -545,32 +655,73 @@ export default class SpaceRoomView extends React.PureComponent { } }; + private goToFirstRoom = async () => { + // TODO actually go to the first room + + const childRooms = SpaceStore.instance.getChildRooms(this.props.space.roomId); + if (childRooms.length) { + const room = childRooms[0]; + defaultDispatcher.dispatch({ + action: "view_room", + room_id: room.roomId, + }); + return; + } + + let suggestedRooms = SpaceStore.instance.suggestedRooms; + if (SpaceStore.instance.activeSpace !== this.props.space) { + // the space store has the suggested rooms loaded for a different space, fetch the right ones + suggestedRooms = (await SpaceStore.instance.fetchSuggestedRooms(this.props.space, 1)).rooms; + } + + if (suggestedRooms.length) { + const room = suggestedRooms[0]; + defaultDispatcher.dispatch({ + action: "view_room", + room_id: room.room_id, + oobData: { + avatarUrl: room.avatar_url, + name: room.name || room.canonical_alias || room.aliases.pop() || _t("Empty room"), + }, + }); + return; + } + + this.setState({ phase: Phase.Landing }); + }; + private renderBody() { switch (this.state.phase) { case Phase.Landing: - return ; - + if (this.state.myMembership === "join") { + return ; + } else { + return ; + } case Phase.PublicCreateRooms: return this.setState({ phase: Phase.PublicShare })} />; case Phase.PublicShare: - return this.setState({ phase: Phase.Landing })} - />; + return ; case Phase.PrivateScope: return { - this.setState({ phase: invite ? Phase.PrivateInvite : Phase.PrivateCreateRooms }); + this.setState({ phase: invite ? Phase.PrivateInvite : Phase.PrivateExistingRooms }); }} />; case Phase.PrivateInvite: @@ -582,7 +733,13 @@ export default class SpaceRoomView extends React.PureComponent { return this.setState({ phase: Phase.Landing })} + />; + case Phase.PrivateExistingRooms: + return this.setState({ phase: Phase.Landing })} />; } diff --git a/src/components/structures/TabbedView.tsx b/src/components/structures/TabbedView.tsx index 21f9f3f5d6..0097d55cf5 100644 --- a/src/components/structures/TabbedView.tsx +++ b/src/components/structures/TabbedView.tsx @@ -20,6 +20,7 @@ import * as React from "react"; import {_t} from '../../languageHandler'; import * as sdk from "../../index"; import AutoHideScrollbar from './AutoHideScrollbar'; +import {replaceableComponent} from "../../utils/replaceableComponent"; /** * Represents a tab for the TabbedView. @@ -45,6 +46,7 @@ interface IState { activeTabIndex: number; } +@replaceableComponent("structures.TabbedView") export default class TabbedView extends React.Component { constructor(props: IProps) { super(props); diff --git a/src/components/structures/TimelinePanel.js b/src/components/structures/TimelinePanel.js index 6bc1f70ba1..12f5d6e890 100644 --- a/src/components/structures/TimelinePanel.js +++ b/src/components/structures/TimelinePanel.js @@ -22,8 +22,8 @@ import {LayoutPropType} from "../../settings/Layout"; import React, {createRef} from 'react'; import ReactDOM from "react-dom"; import PropTypes from 'prop-types'; -import {EventTimeline} from "matrix-js-sdk"; -import * as Matrix from "matrix-js-sdk"; +import {EventTimeline} from "matrix-js-sdk/src/models/event-timeline"; +import {TimelineWindow} from "matrix-js-sdk/src/timeline-window"; import { _t } from '../../languageHandler'; import {MatrixClientPeg} from "../../MatrixClientPeg"; import UserActivity from "../../UserActivity"; @@ -37,6 +37,7 @@ import EditorStateTransfer from '../../utils/EditorStateTransfer'; import {haveTileForEvent} from "../views/rooms/EventTile"; import {UIFeature} from "../../settings/UIFeature"; import {objectHasDiff} from "../../utils/objects"; +import {replaceableComponent} from "../../utils/replaceableComponent"; const PAGINATE_SIZE = 20; const INITIAL_SIZE = 20; @@ -55,6 +56,7 @@ if (DEBUG) { * * Also responsible for handling and sending read receipts. */ +@replaceableComponent("structures.TimelinePanel") class TimelinePanel extends React.Component { static propTypes = { // The js-sdk EventTimelineSet object for the timeline sequence we are @@ -461,6 +463,9 @@ class TimelinePanel extends React.Component { } }); } + if (payload.action === "scroll_to_bottom") { + this.jumpToLiveTimeline(); + } }; onRoomTimeline = (ev, room, toStartOfTimeline, removed, data) => { @@ -1005,7 +1010,7 @@ class TimelinePanel extends React.Component { * returns a promise which will resolve when the load completes. */ _loadTimeline(eventId, pixelOffset, offsetBase) { - this._timelineWindow = new Matrix.TimelineWindow( + this._timelineWindow = new TimelineWindow( MatrixClientPeg.get(), this.props.timelineSet, {windowLimit: this.props.timelineCap}); diff --git a/src/components/structures/ToastContainer.tsx b/src/components/structures/ToastContainer.tsx index 513cca82c3..1fd3e3419f 100644 --- a/src/components/structures/ToastContainer.tsx +++ b/src/components/structures/ToastContainer.tsx @@ -17,12 +17,14 @@ limitations under the License. import * as React from "react"; import ToastStore, {IToast} from "../../stores/ToastStore"; import classNames from "classnames"; +import {replaceableComponent} from "../../utils/replaceableComponent"; interface IState { toasts: IToast[]; countSeen: number; } +@replaceableComponent("structures.ToastContainer") export default class ToastContainer extends React.Component<{}, IState> { constructor(props, context) { super(props, context); diff --git a/src/components/structures/UploadBar.js b/src/components/structures/UploadBar.js deleted file mode 100644 index 16cc4cb987..0000000000 --- a/src/components/structures/UploadBar.js +++ /dev/null @@ -1,109 +0,0 @@ -/* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2019 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -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 ContentMessages from '../../ContentMessages'; -import dis from "../../dispatcher/dispatcher"; -import filesize from "filesize"; -import { _t } from '../../languageHandler'; - -export default class UploadBar extends React.Component { - static propTypes = { - room: PropTypes.object, - }; - - componentDidMount() { - this.dispatcherRef = dis.register(this.onAction); - this.mounted = true; - } - - componentWillUnmount() { - this.mounted = false; - dis.unregister(this.dispatcherRef); - } - - onAction = payload => { - switch (payload.action) { - case 'upload_progress': - case 'upload_finished': - case 'upload_canceled': - case 'upload_failed': - if (this.mounted) this.forceUpdate(); - break; - } - }; - - render() { - const uploads = ContentMessages.sharedInstance().getCurrentUploads(); - - // for testing UI... - also fix up the ContentMessages.getCurrentUploads().length - // check in RoomView - // - // uploads = [{ - // roomId: this.props.room.roomId, - // loaded: 123493, - // total: 347534, - // fileName: "testing_fooble.jpg", - // }]; - - if (uploads.length == 0) { - return
    ; - } - - let upload; - for (let i = 0; i < uploads.length; ++i) { - if (uploads[i].roomId == this.props.room.roomId) { - upload = uploads[i]; - break; - } - } - if (!upload) { - return
    ; - } - - const innerProgressStyle = { - width: ((upload.loaded / (upload.total || 1)) * 100) + '%', - }; - let uploadedSize = filesize(upload.loaded); - const totalSize = filesize(upload.total); - if (uploadedSize.replace(/^.* /, '') === totalSize.replace(/^.* /, '')) { - uploadedSize = uploadedSize.replace(/ .*/, ''); - } - - // MUST use var name 'count' for pluralization to kick in - const uploadText = _t( - "Uploading %(filename)s and %(count)s others", {filename: upload.fileName, count: (uploads.length - 1)}, - ); - - return ( -
    -
    -
    -
    - - -
    - { uploadedSize } / { totalSize } -
    -
    { uploadText }
    -
    - ); - } -} diff --git a/src/components/structures/UploadBar.tsx b/src/components/structures/UploadBar.tsx new file mode 100644 index 0000000000..e19e312f58 --- /dev/null +++ b/src/components/structures/UploadBar.tsx @@ -0,0 +1,110 @@ +/* +Copyright 2015, 2016, 2019, 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from 'react'; +import { Room } from "matrix-js-sdk/src/models/room"; +import ContentMessages from '../../ContentMessages'; +import dis from "../../dispatcher/dispatcher"; +import filesize from "filesize"; +import { _t } from '../../languageHandler'; +import { ActionPayload } from "../../dispatcher/payloads"; +import { Action } from "../../dispatcher/actions"; +import ProgressBar from "../views/elements/ProgressBar"; +import AccessibleButton from "../views/elements/AccessibleButton"; +import { IUpload } from "../../models/IUpload"; +import {replaceableComponent} from "../../utils/replaceableComponent"; + +interface IProps { + room: Room; +} + +interface IState { + currentUpload?: IUpload; + uploadsHere: IUpload[]; +} + +@replaceableComponent("structures.UploadBar") +export default class UploadBar extends React.Component { + private dispatcherRef: string; + private mounted: boolean; + + constructor(props) { + super(props); + + // Set initial state to any available upload in this room - we might be mounting + // earlier than the first progress event, so should show something relevant. + const uploadsHere = this.getUploadsInRoom(); + this.state = {currentUpload: uploadsHere[0], uploadsHere}; + } + + componentDidMount() { + this.dispatcherRef = dis.register(this.onAction); + this.mounted = true; + } + + componentWillUnmount() { + this.mounted = false; + dis.unregister(this.dispatcherRef); + } + + private getUploadsInRoom(): IUpload[] { + const uploads = ContentMessages.sharedInstance().getCurrentUploads(); + return uploads.filter(u => u.roomId === this.props.room.roomId); + } + + private onAction = (payload: ActionPayload) => { + switch (payload.action) { + case Action.UploadStarted: + case Action.UploadProgress: + case Action.UploadFinished: + case Action.UploadCanceled: + case Action.UploadFailed: { + if (!this.mounted) return; + const uploadsHere = this.getUploadsInRoom(); + this.setState({currentUpload: uploadsHere[0], uploadsHere}); + break; + } + } + }; + + private onCancelClick = (ev) => { + ev.preventDefault(); + ContentMessages.sharedInstance().cancelUpload(this.state.currentUpload.promise); + }; + + render() { + if (!this.state.currentUpload) { + return null; + } + + // MUST use var name 'count' for pluralization to kick in + const uploadText = _t( + "Uploading %(filename)s and %(count)s others", { + filename: this.state.currentUpload.fileName, + count: this.state.uploadsHere.length - 1, + }, + ); + + const uploadSize = filesize(this.state.currentUpload.total); + return ( +
    +
    {uploadText} ({uploadSize})
    + + +
    + ); + } +} diff --git a/src/components/structures/UserMenu.tsx b/src/components/structures/UserMenu.tsx index b31a5f4b8e..65861624e6 100644 --- a/src/components/structures/UserMenu.tsx +++ b/src/components/structures/UserMenu.tsx @@ -56,6 +56,7 @@ import HostSignupAction from "./HostSignupAction"; import { IHostSignupConfig } from "../views/dialogs/HostSignupDialogTypes"; import SpaceStore, { UPDATE_SELECTED_SPACE } from "../../stores/SpaceStore"; import RoomName from "../views/elements/RoomName"; +import {replaceableComponent} from "../../utils/replaceableComponent"; interface IProps { isMinimized: boolean; @@ -69,9 +70,11 @@ interface IState { selectedSpace?: Room; } +@replaceableComponent("structures.UserMenu") export default class UserMenu extends React.Component { private dispatcherRef: string; private themeWatcherRef: string; + private dndWatcherRef: string; private buttonRef: React.RefObject = createRef(); private tagStoreRef: fbEmitter.EventSubscription; @@ -87,6 +90,9 @@ export default class UserMenu extends React.Component { if (SettingsStore.getValue("feature_spaces")) { SpaceStore.instance.on(UPDATE_SELECTED_SPACE, this.onSelectedSpaceUpdate); } + + // Force update is the easiest way to trigger the UI update (we don't store state for this) + this.dndWatcherRef = SettingsStore.watchSetting("doNotDisturb", null, () => this.forceUpdate()); } private get hasHomePage(): boolean { @@ -101,6 +107,7 @@ export default class UserMenu extends React.Component { public componentWillUnmount() { if (this.themeWatcherRef) SettingsStore.unwatchSetting(this.themeWatcherRef); + if (this.dndWatcherRef) SettingsStore.unwatchSetting(this.dndWatcherRef); if (this.dispatcherRef) defaultDispatcher.unregister(this.dispatcherRef); OwnProfileStore.instance.off(UPDATE_EVENT, this.onProfileUpdate); this.tagStoreRef.remove(); @@ -286,6 +293,12 @@ export default class UserMenu extends React.Component { this.setState({contextMenuPosition: null}); // also close the menu }; + private onDndToggle = (ev) => { + ev.stopPropagation(); + const current = SettingsStore.getValue("doNotDisturb"); + SettingsStore.setValue("doNotDisturb", null, SettingLevel.DEVICE, !current); + }; + private renderContextMenu = (): React.ReactNode => { if (!this.state.contextMenuPosition) return null; @@ -532,6 +545,7 @@ export default class UserMenu extends React.Component { {/* masked image in CSS */} ); + let dnd; if (this.state.selectedSpace) { name = (
    @@ -558,6 +572,16 @@ export default class UserMenu extends React.Component {
    ); isPrototype = true; + } else if (SettingsStore.getValue("feature_dnd")) { + const isDnd = SettingsStore.getValue("doNotDisturb"); + dnd = ; } if (this.props.isMinimized) { name = null; @@ -593,6 +617,7 @@ export default class UserMenu extends React.Component { /> {name} + {dnd} {buttons}
    diff --git a/src/components/structures/UserView.js b/src/components/structures/UserView.js index 8e21771bb9..6b472783bb 100644 --- a/src/components/structures/UserView.js +++ b/src/components/structures/UserView.js @@ -17,13 +17,16 @@ limitations under the License. import React from "react"; import PropTypes from "prop-types"; -import Matrix from "matrix-js-sdk"; import {MatrixClientPeg} from "../../MatrixClientPeg"; import * as sdk from "../../index"; import Modal from '../../Modal'; import { _t } from '../../languageHandler'; import HomePage from "./HomePage"; +import {replaceableComponent} from "../../utils/replaceableComponent"; +import {MatrixEvent} from "matrix-js-sdk/src/models/event"; +import {RoomMember} from "matrix-js-sdk/src/models/room-member"; +@replaceableComponent("structures.UserView") export default class UserView extends React.Component { static get propTypes() { return { @@ -66,8 +69,8 @@ export default class UserView extends React.Component { this.setState({loading: false}); return; } - const fakeEvent = new Matrix.MatrixEvent({type: "m.room.member", content: profileInfo}); - const member = new Matrix.RoomMember(null, this.props.userId); + const fakeEvent = new MatrixEvent({type: "m.room.member", content: profileInfo}); + const member = new RoomMember(null, this.props.userId); member.setMembershipEvent(fakeEvent); this.setState({member, loading: false}); } diff --git a/src/components/structures/ViewSource.js b/src/components/structures/ViewSource.js index 0b969784e5..6fe99dd464 100644 --- a/src/components/structures/ViewSource.js +++ b/src/components/structures/ViewSource.js @@ -16,34 +16,176 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from 'react'; -import PropTypes from 'prop-types'; -import SyntaxHighlight from '../views/elements/SyntaxHighlight'; -import {_t} from "../../languageHandler"; +import React from "react"; +import PropTypes from "prop-types"; +import SyntaxHighlight from "../views/elements/SyntaxHighlight"; +import { _t } from "../../languageHandler"; import * as sdk from "../../index"; +import MatrixClientContext from "../../contexts/MatrixClientContext"; +import { SendCustomEvent } from "../views/dialogs/DevtoolsDialog"; +import { canEditContent } from "../../utils/EventUtils"; +import { MatrixClientPeg } from '../../MatrixClientPeg'; +import { replaceableComponent } from "../../utils/replaceableComponent"; - +@replaceableComponent("structures.ViewSource") export default class ViewSource extends React.Component { static propTypes = { - content: PropTypes.object.isRequired, onFinished: PropTypes.func.isRequired, - roomId: PropTypes.string.isRequired, - eventId: PropTypes.string.isRequired, + mxEvent: PropTypes.object.isRequired, // the MatrixEvent associated with the context menu }; - render() { - const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); - return ( - -
    Room ID: { this.props.roomId }
    -
    Event ID: { this.props.eventId }
    -
    + constructor(props) { + super(props); -
    - - { JSON.stringify(this.props.content, null, 2) } - + this.state = { + isEditing: false, + }; + } + + onBack() { + // TODO: refresh the "Event ID:" modal header + this.setState({ isEditing: false }); + } + + onEdit() { + this.setState({ isEditing: true }); + } + + // returns the dialog body for viewing the event source + viewSourceContent() { + const mxEvent = this.props.mxEvent.replacingEvent() || this.props.mxEvent; // show the replacing event, not the original, if it is an edit + const isEncrypted = mxEvent.isEncrypted(); + const decryptedEventSource = mxEvent._clearEvent; // FIXME: _clearEvent is private + const originalEventSource = mxEvent.event; + + if (isEncrypted) { + return ( + <> +
    + + {_t("Decrypted event source")} + + {JSON.stringify(decryptedEventSource, null, 2)} +
    +
    + + {_t("Original event source")} + + {JSON.stringify(originalEventSource, null, 2)} +
    + + ); + } else { + return ( + <> +
    {_t("Original event source")}
    + {JSON.stringify(originalEventSource, null, 2)} + + ); + } + } + + // returns the id of the initial message, not the id of the previous edit + getBaseEventId() { + const mxEvent = this.props.mxEvent.replacingEvent() || this.props.mxEvent; // show the replacing event, not the original, if it is an edit + const isEncrypted = mxEvent.isEncrypted(); + const baseMxEvent = this.props.mxEvent; + + if (isEncrypted) { + // `relates_to` field is inside the encrypted event + return mxEvent.event.content["m.relates_to"]?.event_id ?? baseMxEvent.getId(); + } else { + return mxEvent.getContent()["m.relates_to"]?.event_id ?? baseMxEvent.getId(); + } + } + + // returns the SendCustomEvent component prefilled with the correct details + editSourceContent() { + const mxEvent = this.props.mxEvent.replacingEvent() || this.props.mxEvent; // show the replacing event, not the original, if it is an edit + + const isStateEvent = mxEvent.isState(); + const roomId = mxEvent.getRoomId(); + const originalContent = mxEvent.getContent(); + + if (isStateEvent) { + return ( + + {(cli) => ( + this.onBack()} + inputs={{ + eventType: mxEvent.getType(), + evContent: JSON.stringify(originalContent, null, "\t"), + stateKey: mxEvent.getStateKey(), + }} + /> + )} + + ); + } else { + // prefill an edit-message event + // keep only the `body` and `msgtype` fields of originalContent + const bodyToStartFrom = originalContent["m.new_content"]?.body ?? originalContent.body; // prefill the last edit body, to start editing from there + const newContent = { + "body": ` * ${bodyToStartFrom}`, + "msgtype": originalContent.msgtype, + "m.new_content": { + body: bodyToStartFrom, + msgtype: originalContent.msgtype, + }, + "m.relates_to": { + rel_type: "m.replace", + event_id: this.getBaseEventId(), + }, + }; + return ( + + {(cli) => ( + this.onBack()} + inputs={{ + eventType: mxEvent.getType(), + evContent: JSON.stringify(newContent, null, "\t"), + }} + /> + )} + + ); + } + } + + canSendStateEvent(mxEvent) { + const cli = MatrixClientPeg.get(); + const room = cli.getRoom(mxEvent.getRoomId()); + return room.currentState.mayClientSendStateEvent(mxEvent.getType(), cli); + } + + render() { + const BaseDialog = sdk.getComponent("views.dialogs.BaseDialog"); + const mxEvent = this.props.mxEvent.replacingEvent() || this.props.mxEvent; // show the replacing event, not the original, if it is an edit + + const isEditing = this.state.isEditing; + const roomId = mxEvent.getRoomId(); + const eventId = mxEvent.getId(); + const canEdit = mxEvent.isState() ? this.canSendStateEvent(mxEvent) : canEditContent(this.props.mxEvent); + return ( + +
    +
    Room ID: {roomId}
    +
    Event ID: {eventId}
    +
    + {isEditing ? this.editSourceContent() : this.viewSourceContent()}
    + {!isEditing && canEdit && ( +
    + +
    + )} ); } diff --git a/src/components/structures/auth/CompleteSecurity.js b/src/components/structures/auth/CompleteSecurity.js index c73691611d..49fcf20415 100644 --- a/src/components/structures/auth/CompleteSecurity.js +++ b/src/components/structures/auth/CompleteSecurity.js @@ -20,13 +20,16 @@ import { _t } from '../../../languageHandler'; import * as sdk from '../../../index'; import { SetupEncryptionStore, + PHASE_LOADING, PHASE_INTRO, PHASE_BUSY, PHASE_DONE, PHASE_CONFIRM_SKIP, } from '../../../stores/SetupEncryptionStore'; import SetupEncryptionBody from "./SetupEncryptionBody"; +import {replaceableComponent} from "../../../utils/replaceableComponent"; +@replaceableComponent("structures.auth.CompleteSecurity") export default class CompleteSecurity extends React.Component { static propTypes = { onFinished: PropTypes.func.isRequired, @@ -58,7 +61,9 @@ export default class CompleteSecurity extends React.Component { let icon; let title; - if (phase === PHASE_INTRO) { + if (phase === PHASE_LOADING) { + return null; + } else if (phase === PHASE_INTRO) { icon = ; title = _t("Verify this login"); } else if (phase === PHASE_DONE) { diff --git a/src/components/structures/auth/E2eSetup.js b/src/components/structures/auth/E2eSetup.js index d97a972718..4e51ae828c 100644 --- a/src/components/structures/auth/E2eSetup.js +++ b/src/components/structures/auth/E2eSetup.js @@ -19,7 +19,9 @@ import PropTypes from 'prop-types'; import AuthPage from '../../views/auth/AuthPage'; import CompleteSecurityBody from '../../views/auth/CompleteSecurityBody'; import CreateCrossSigningDialog from '../../views/dialogs/security/CreateCrossSigningDialog'; +import {replaceableComponent} from "../../../utils/replaceableComponent"; +@replaceableComponent("structures.auth.E2eSetup") export default class E2eSetup extends React.Component { static propTypes = { onFinished: PropTypes.func.isRequired, diff --git a/src/components/structures/auth/ForgotPassword.js b/src/components/structures/auth/ForgotPassword.js index 5a39fe9fd9..6188fdb5e4 100644 --- a/src/components/structures/auth/ForgotPassword.js +++ b/src/components/structures/auth/ForgotPassword.js @@ -18,7 +18,7 @@ limitations under the License. import React from 'react'; import PropTypes from 'prop-types'; -import { _t } from '../../../languageHandler'; +import { _t, _td } from '../../../languageHandler'; import * as sdk from '../../../index'; import Modal from "../../../Modal"; import PasswordReset from "../../../PasswordReset"; @@ -27,6 +27,9 @@ import classNames from 'classnames'; import AuthPage from "../../views/auth/AuthPage"; import CountlyAnalytics from "../../../CountlyAnalytics"; import ServerPicker from "../../views/elements/ServerPicker"; +import PassphraseField from '../../views/auth/PassphraseField'; +import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { PASSWORD_MIN_SCORE } from '../../views/auth/RegistrationForm'; // Phases // Show the forgot password inputs @@ -38,6 +41,7 @@ const PHASE_EMAIL_SENT = 3; // User has clicked the link in email and completed reset const PHASE_DONE = 4; +@replaceableComponent("structures.auth.ForgotPassword") export default class ForgotPassword extends React.Component { static propTypes = { serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired, @@ -135,10 +139,14 @@ export default class ForgotPassword extends React.Component { // refresh the server errors, just in case the server came back online await this._checkServerLiveliness(this.props.serverConfig); + await this['password_field'].validate({ allowEmpty: false }); + if (!this.state.email) { this.showErrorDialog(_t('The email address linked to your account must be entered.')); } else if (!this.state.password || !this.state.password2) { this.showErrorDialog(_t('A new password must be entered.')); + } else if (!this.state.passwordFieldValid) { + this.showErrorDialog(_t('Please choose a strong password')); } else if (this.state.password !== this.state.password2) { this.showErrorDialog(_t('New passwords must match each other.')); } else { @@ -184,6 +192,12 @@ export default class ForgotPassword extends React.Component { }); } + onPasswordValidate(result) { + this.setState({ + passwordFieldValid: result.valid, + }); + } + renderForgot() { const Field = sdk.getComponent('elements.Field'); @@ -228,12 +242,15 @@ export default class ForgotPassword extends React.Component { />
    - this['password_field'] = field} + onValidate={(result) => this.onPasswordValidate(result)} onFocus={() => CountlyAnalytics.instance.track("onboarding_forgot_password_newPassword_focus")} onBlur={() => CountlyAnalytics.instance.track("onboarding_forgot_password_newPassword_blur")} autoComplete="new-password" diff --git a/src/components/structures/auth/Login.tsx b/src/components/structures/auth/Login.tsx index a217f1b4d9..34a5410928 100644 --- a/src/components/structures/auth/Login.tsx +++ b/src/components/structures/auth/Login.tsx @@ -1,5 +1,5 @@ /* -Copyright 2015, 2016, 2017, 2018, 2019 The Matrix.org Foundation C.I.C. +Copyright 2015-2021 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -35,6 +35,7 @@ import InlineSpinner from "../../views/elements/InlineSpinner"; import Spinner from "../../views/elements/Spinner"; import SSOButtons from "../../views/elements/SSOButtons"; import ServerPicker from "../../views/elements/ServerPicker"; +import {replaceableComponent} from "../../../utils/replaceableComponent"; // 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. @@ -93,12 +94,13 @@ interface IState { // be seeing. serverIsAlive: boolean; serverErrorIsFatal: boolean; - serverDeadError: string; + serverDeadError?: ReactNode; } /* * A wire component which glues together login UI components and Login logic */ +@replaceableComponent("structures.auth.LoginComponent") export default class LoginComponent extends React.PureComponent { private unmounted = false; private loginLogic: Login; @@ -218,6 +220,9 @@ export default class LoginComponent extends React.PureComponent 'monthly_active_user': _td( "This homeserver has hit its Monthly Active User limit.", ), + 'hs_blocked': _td( + "This homeserver has been blocked by it's administrator.", + ), '': _td( "This homeserver has exceeded one of its resource limits.", ), diff --git a/src/components/structures/auth/Registration.tsx b/src/components/structures/auth/Registration.tsx index 095f3d3433..96fb9bdc82 100644 --- a/src/components/structures/auth/Registration.tsx +++ b/src/components/structures/auth/Registration.tsx @@ -1,5 +1,5 @@ /* -Copyright 2015, 2016, 2017, 2018, 2019, 2020 The Matrix.org Foundation C.I.C. +Copyright 2015-2021 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import Matrix from 'matrix-js-sdk'; +import {createClient} from 'matrix-js-sdk/src/matrix'; import React, {ReactNode} from 'react'; import {MatrixClient} from "matrix-js-sdk/src/client"; @@ -30,6 +30,7 @@ import Login, {ISSOFlow} from "../../../Login"; import dis from "../../../dispatcher/dispatcher"; import SSOButtons from "../../views/elements/SSOButtons"; import ServerPicker from '../../views/elements/ServerPicker'; +import {replaceableComponent} from "../../../utils/replaceableComponent"; interface IProps { serverConfig: ValidatedServerConfig; @@ -94,7 +95,7 @@ interface IState { // be seeing. serverIsAlive: boolean; serverErrorIsFatal: boolean; - serverDeadError: string; + serverDeadError?: ReactNode; // Our matrix client - part of state because we can't render the UI auth // component without it. @@ -109,6 +110,7 @@ interface IState { ssoFlow?: ISSOFlow; } +@replaceableComponent("structures.auth.Registration") export default class Registration extends React.Component { loginLogic: Login; @@ -179,7 +181,7 @@ export default class Registration extends React.Component { } const {hsUrl, isUrl} = serverConfig; - const cli = Matrix.createClient({ + const cli = createClient({ baseUrl: hsUrl, idBaseUrl: isUrl, }); @@ -276,6 +278,7 @@ export default class Registration extends React.Component { response.data.admin_contact, { 'monthly_active_user': _td("This homeserver has hit its Monthly Active User limit."), + 'hs_blocked': _td("This homeserver has been blocked by it's administrator."), '': _td("This homeserver has exceeded one of its resource limits."), }, ); @@ -433,6 +436,8 @@ export default class Registration extends React.Component { // ok fine, there's still no session: really go to the login page this.props.onLoginClick(); } + + return sessionLoaded; }; private renderRegisterComponent() { @@ -554,7 +559,12 @@ export default class Registration extends React.Component { loggedInUserId: this.state.differentLoggedInUserId, }, )}

    -

    +

    { + const sessionLoaded = await this.onLoginClickWithCheck(event); + if (sessionLoaded) { + dis.dispatch({action: "view_welcome_page"}); + } + }}> {_t("Continue with previous account")}

    ; diff --git a/src/components/structures/auth/SetupEncryptionBody.js b/src/components/structures/auth/SetupEncryptionBody.js index 3e7264dfec..803df19d00 100644 --- a/src/components/structures/auth/SetupEncryptionBody.js +++ b/src/components/structures/auth/SetupEncryptionBody.js @@ -17,17 +17,20 @@ limitations under the License. import React from 'react'; import PropTypes from 'prop-types'; import { _t } from '../../../languageHandler'; -import SdkConfig from '../../../SdkConfig'; import { MatrixClientPeg } from '../../../MatrixClientPeg'; +import Modal from '../../../Modal'; +import VerificationRequestDialog from '../../views/dialogs/VerificationRequestDialog'; import * as sdk from '../../../index'; import { SetupEncryptionStore, + PHASE_LOADING, PHASE_INTRO, PHASE_BUSY, PHASE_DONE, PHASE_CONFIRM_SKIP, PHASE_FINISHED, } from '../../../stores/SetupEncryptionStore'; +import {replaceableComponent} from "../../../utils/replaceableComponent"; function keyHasPassphrase(keyInfo) { return ( @@ -37,6 +40,7 @@ function keyHasPassphrase(keyInfo) { ); } +@replaceableComponent("structures.auth.SetupEncryptionBody") export default class SetupEncryptionBody extends React.Component { static propTypes = { onFinished: PropTypes.func.isRequired, @@ -81,6 +85,22 @@ export default class SetupEncryptionBody extends React.Component { store.usePassPhrase(); } + _onVerifyClick = () => { + const cli = MatrixClientPeg.get(); + const userId = cli.getUserId(); + const requestPromise = cli.requestVerification(userId); + + this.props.onFinished(true); + Modal.createTrackedDialog('New Session Verification', 'Starting dialog', VerificationRequestDialog, { + verificationRequestPromise: requestPromise, + member: cli.getUser(userId), + onFinished: async () => { + const request = await requestPromise; + request.cancel(); + }, + }); + } + onSkipClick = () => { const store = SetupEncryptionStore.sharedInstance(); store.skip(); @@ -132,32 +152,21 @@ export default class SetupEncryptionBody extends React.Component { ; } - const brand = SdkConfig.get().brand; + let verifyButton; + if (store.hasDevicesToVerifyAgainst) { + verifyButton = + { _t("Use another login") } + ; + } return (

    {_t( - "Confirm your identity by verifying this login from one of your other sessions, " + - "granting it access to encrypted messages.", + "Verify your identity to access encrypted messages and prove your identity to others.", )}

    -

    {_t( - "This requires the latest %(brand)s on your other devices:", - { brand }, - )}

    - -
    -
    -
    {_t("%(brand)s Web", { brand })}
    -
    {_t("%(brand)s Desktop", { brand })}
    -
    -
    -
    {_t("%(brand)s iOS", { brand })}
    -
    {_t("%(brand)s Android", { brand })}
    -
    -

    {_t("or another cross-signing capable Matrix client")}

    -
    + {verifyButton} {useRecoveryKeyButton} {_t("Skip")} @@ -195,8 +204,8 @@ export default class SetupEncryptionBody extends React.Component { return (

    {_t( - "Without completing security on this session, it won’t have " + - "access to encrypted messages.", + "Without verifying, you won’t have access to all your messages " + + "and may appear as untrusted to others.", )}

    ); - } else if (phase === PHASE_BUSY) { + } else if (phase === PHASE_BUSY || phase === PHASE_LOADING) { const Spinner = sdk.getComponent('views.elements.Spinner'); return ; } else { diff --git a/src/components/structures/auth/SoftLogout.js b/src/components/structures/auth/SoftLogout.tsx similarity index 91% rename from src/components/structures/auth/SoftLogout.js rename to src/components/structures/auth/SoftLogout.tsx index a7fe340457..fa9207efdd 100644 --- a/src/components/structures/auth/SoftLogout.js +++ b/src/components/structures/auth/SoftLogout.tsx @@ -1,5 +1,5 @@ /* -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2019-2021 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -15,17 +15,17 @@ limitations under the License. */ import React from 'react'; -import PropTypes from 'prop-types'; import {_t} from '../../../languageHandler'; import * as sdk from '../../../index'; import dis from '../../../dispatcher/dispatcher'; import * as Lifecycle from '../../../Lifecycle'; import Modal from '../../../Modal'; import {MatrixClientPeg} from "../../../MatrixClientPeg"; -import {sendLoginRequest} from "../../../Login"; +import {ISSOFlow, LoginFlow, sendLoginRequest} from "../../../Login"; import AuthPage from "../../views/auth/AuthPage"; import {SSO_HOMESERVER_URL_KEY, SSO_ID_SERVER_URL_KEY} from "../../../BasePlatform"; import SSOButtons from "../../views/elements/SSOButtons"; +import {replaceableComponent} from "../../../utils/replaceableComponent"; const LOGIN_VIEW = { LOADING: 1, @@ -41,25 +41,38 @@ const FLOWS_TO_VIEWS = { "m.login.sso": LOGIN_VIEW.SSO, }; -export default class SoftLogout extends React.Component { - static propTypes = { - // Query parameters from MatrixChat - realQueryParams: PropTypes.object, // {loginToken} - - // Called when the SSO login completes - onTokenLoginCompleted: PropTypes.func, +interface IProps { + // Query parameters from MatrixChat + realQueryParams: { + loginToken?: string; }; + fragmentAfterLogin?: string; - constructor() { - super(); + // Called when the SSO login completes + onTokenLoginCompleted: () => void, +} + +interface IState { + loginView: number; + keyBackupNeeded: boolean; + busy: boolean; + password: string; + errorText: string; + flows: LoginFlow[]; +} + +@replaceableComponent("structures.auth.SoftLogout") +export default class SoftLogout extends React.Component { + constructor(props) { + super(props); this.state = { loginView: LOGIN_VIEW.LOADING, keyBackupNeeded: true, // assume we do while we figure it out (see componentDidMount) - busy: false, password: "", errorText: "", + flows: [], }; } @@ -70,7 +83,7 @@ export default class SoftLogout extends React.Component { return; } - this._initLogin(); + this.initLogin(); const cli = MatrixClientPeg.get(); if (cli.isCryptoEnabled()) { @@ -92,7 +105,7 @@ export default class SoftLogout extends React.Component { }); }; - async _initLogin() { + private async initLogin() { const queryParams = this.props.realQueryParams; const hasAllParams = queryParams && queryParams['loginToken']; if (hasAllParams) { @@ -187,7 +200,7 @@ export default class SoftLogout extends React.Component { }); } - _renderSignInSection() { + private renderSignInSection() { if (this.state.loginView === LOGIN_VIEW.LOADING) { const Spinner = sdk.getComponent("elements.Spinner"); return ; @@ -245,7 +258,7 @@ export default class SoftLogout extends React.Component { } // else we already have a message and should use it (key backup warning) const loginType = this.state.loginView === LOGIN_VIEW.CAS ? "cas" : "sso"; - const flow = this.state.flows.find(flow => flow.type === "m.login." + loginType); + const flow = this.state.flows.find(flow => flow.type === "m.login." + loginType) as ISSOFlow; return (
    @@ -287,7 +300,7 @@ export default class SoftLogout extends React.Component {

    {_t("Sign in")}

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

    {_t("Clear personal data")}

    diff --git a/src/components/views/auth/AuthBody.js b/src/components/views/auth/AuthBody.js index 9a078efb52..2cb72b5e1d 100644 --- a/src/components/views/auth/AuthBody.js +++ b/src/components/views/auth/AuthBody.js @@ -14,10 +14,10 @@ See the License for the specific language governing permissions and limitations under the License. */ -'use strict'; - import React from 'react'; +import {replaceableComponent} from "../../../utils/replaceableComponent"; +@replaceableComponent("views.auth.AuthBody") export default class AuthBody extends React.PureComponent { render() { return
    diff --git a/src/components/views/auth/AuthFooter.js b/src/components/views/auth/AuthFooter.js index 3de5a19350..f167e16283 100644 --- a/src/components/views/auth/AuthFooter.js +++ b/src/components/views/auth/AuthFooter.js @@ -18,7 +18,9 @@ limitations under the License. import { _t } from '../../../languageHandler'; import React from 'react'; +import {replaceableComponent} from "../../../utils/replaceableComponent"; +@replaceableComponent("views.auth.AuthFooter") export default class AuthFooter extends React.Component { render() { return ( diff --git a/src/components/views/auth/AuthHeader.js b/src/components/views/auth/AuthHeader.js index 57499e397c..323299b3a8 100644 --- a/src/components/views/auth/AuthHeader.js +++ b/src/components/views/auth/AuthHeader.js @@ -18,7 +18,9 @@ limitations under the License. import React from 'react'; import PropTypes from 'prop-types'; import * as sdk from '../../../index'; +import {replaceableComponent} from "../../../utils/replaceableComponent"; +@replaceableComponent("views.auth.AuthHeader") export default class AuthHeader extends React.Component { static propTypes = { disableLanguageSelector: PropTypes.bool, diff --git a/src/components/views/auth/AuthHeaderLogo.js b/src/components/views/auth/AuthHeaderLogo.js index 9edf149a83..b4e04799bb 100644 --- a/src/components/views/auth/AuthHeaderLogo.js +++ b/src/components/views/auth/AuthHeaderLogo.js @@ -14,10 +14,10 @@ See the License for the specific language governing permissions and limitations under the License. */ -'use strict'; - import React from 'react'; +import {replaceableComponent} from "../../../utils/replaceableComponent"; +@replaceableComponent("views.auth.AuthHeaderLogo") export default class AuthHeaderLogo extends React.PureComponent { render() { return
    diff --git a/src/components/views/auth/CaptchaForm.js b/src/components/views/auth/CaptchaForm.js index e2d7d594fa..50de24d403 100644 --- a/src/components/views/auth/CaptchaForm.js +++ b/src/components/views/auth/CaptchaForm.js @@ -18,12 +18,14 @@ import React, {createRef} from 'react'; import PropTypes from 'prop-types'; import { _t } from '../../../languageHandler'; import CountlyAnalytics from "../../../CountlyAnalytics"; +import {replaceableComponent} from "../../../utils/replaceableComponent"; const DIV_ID = 'mx_recaptcha'; /** * A pure UI component which displays a captcha form. */ +@replaceableComponent("views.auth.CaptchaForm") export default class CaptchaForm extends React.Component { static propTypes = { sitePublicKey: PropTypes.string, diff --git a/src/components/views/auth/CompleteSecurityBody.js b/src/components/views/auth/CompleteSecurityBody.js index d757de9fe0..6647bb1200 100644 --- a/src/components/views/auth/CompleteSecurityBody.js +++ b/src/components/views/auth/CompleteSecurityBody.js @@ -14,10 +14,10 @@ See the License for the specific language governing permissions and limitations under the License. */ -'use strict'; - import React from 'react'; +import {replaceableComponent} from "../../../utils/replaceableComponent"; +@replaceableComponent("views.auth.CompleteSecurityBody") export default class CompleteSecurityBody extends React.PureComponent { render() { return
    diff --git a/src/components/views/auth/CountryDropdown.js b/src/components/views/auth/CountryDropdown.js index 3296b574a4..e21f112865 100644 --- a/src/components/views/auth/CountryDropdown.js +++ b/src/components/views/auth/CountryDropdown.js @@ -22,6 +22,7 @@ import * as sdk from '../../../index'; import {COUNTRIES, getEmojiFlag} from '../../../phonenumber'; import SdkConfig from "../../../SdkConfig"; import { _t } from "../../../languageHandler"; +import {replaceableComponent} from "../../../utils/replaceableComponent"; const COUNTRIES_BY_ISO2 = {}; for (const c of COUNTRIES) { @@ -40,6 +41,7 @@ function countryMatchesSearchQuery(query, country) { return false; } +@replaceableComponent("views.auth.CountryDropdown") export default class CountryDropdown extends React.Component { constructor(props) { super(props); diff --git a/src/components/views/auth/InteractiveAuthEntryComponents.js b/src/components/views/auth/InteractiveAuthEntryComponents.js index 7dc1976641..6cbecd22ee 100644 --- a/src/components/views/auth/InteractiveAuthEntryComponents.js +++ b/src/components/views/auth/InteractiveAuthEntryComponents.js @@ -26,6 +26,7 @@ import SettingsStore from "../../../settings/SettingsStore"; import AccessibleButton from "../elements/AccessibleButton"; import Spinner from "../elements/Spinner"; import CountlyAnalytics from "../../../CountlyAnalytics"; +import {replaceableComponent} from "../../../utils/replaceableComponent"; /* This file contains a collection of components which are used by the * InteractiveAuth to prompt the user to enter the information needed @@ -75,6 +76,7 @@ import CountlyAnalytics from "../../../CountlyAnalytics"; export const DEFAULT_PHASE = 0; +@replaceableComponent("views.auth.PasswordAuthEntry") export class PasswordAuthEntry extends React.Component { static LOGIN_TYPE = "m.login.password"; @@ -173,6 +175,7 @@ export class PasswordAuthEntry extends React.Component { } } +@replaceableComponent("views.auth.RecaptchaAuthEntry") export class RecaptchaAuthEntry extends React.Component { static LOGIN_TYPE = "m.login.recaptcha"; @@ -235,6 +238,7 @@ export class RecaptchaAuthEntry extends React.Component { } } +@replaceableComponent("views.auth.TermsAuthEntry") export class TermsAuthEntry extends React.Component { static LOGIN_TYPE = "m.login.terms"; @@ -385,6 +389,7 @@ export class TermsAuthEntry extends React.Component { } } +@replaceableComponent("views.auth.EmailIdentityAuthEntry") export class EmailIdentityAuthEntry extends React.Component { static LOGIN_TYPE = "m.login.email.identity"; @@ -432,6 +437,7 @@ export class EmailIdentityAuthEntry extends React.Component { } } +@replaceableComponent("views.auth.MsisdnAuthEntry") export class MsisdnAuthEntry extends React.Component { static LOGIN_TYPE = "m.login.msisdn"; @@ -578,6 +584,7 @@ export class MsisdnAuthEntry extends React.Component { } } +@replaceableComponent("views.auth.SSOAuthEntry") export class SSOAuthEntry extends React.Component { static propTypes = { matrixClient: PropTypes.object.isRequired, @@ -708,6 +715,7 @@ export class SSOAuthEntry extends React.Component { } } +@replaceableComponent("views.auth.FallbackAuthEntry") export class FallbackAuthEntry extends React.Component { static propTypes = { matrixClient: PropTypes.object.isRequired, diff --git a/src/components/views/auth/PassphraseField.tsx b/src/components/views/auth/PassphraseField.tsx index e240ad61ca..274c244b2a 100644 --- a/src/components/views/auth/PassphraseField.tsx +++ b/src/components/views/auth/PassphraseField.tsx @@ -22,6 +22,7 @@ import SdkConfig from "../../../SdkConfig"; import withValidation, {IFieldState, IValidationResult} from "../elements/Validation"; import {_t, _td} from "../../../languageHandler"; import Field, {IInputProps} from "../elements/Field"; +import {replaceableComponent} from "../../../utils/replaceableComponent"; interface IProps extends Omit { autoFocus?: boolean; @@ -40,6 +41,7 @@ interface IProps extends Omit { onValidate(result: IValidationResult); } +@replaceableComponent("views.auth.PassphraseField") class PassphraseField extends PureComponent { static defaultProps = { label: _td("Password"), diff --git a/src/components/views/auth/PasswordLogin.tsx b/src/components/views/auth/PasswordLogin.tsx index b2a3d62f55..2a42804a61 100644 --- a/src/components/views/auth/PasswordLogin.tsx +++ b/src/components/views/auth/PasswordLogin.tsx @@ -26,6 +26,7 @@ import withValidation from "../elements/Validation"; import * as Email from "../../../email"; import Field from "../elements/Field"; import CountryDropdown from "./CountryDropdown"; +import {replaceableComponent} from "../../../utils/replaceableComponent"; // For validating phone numbers without country codes const PHONE_NUMBER_REGEX = /^[0-9()\-\s]*$/; @@ -66,6 +67,7 @@ enum LoginField { * A pure UI component which displays a username/password form. * The email/username/phone fields are fully-controlled, the password field is not. */ +@replaceableComponent("views.auth.PasswordLogin") export default class PasswordLogin extends React.PureComponent { static defaultProps = { onUsernameChanged: function() {}, diff --git a/src/components/views/auth/RegistrationForm.tsx b/src/components/views/auth/RegistrationForm.tsx index e42ed88f99..8f0a293a3c 100644 --- a/src/components/views/auth/RegistrationForm.tsx +++ b/src/components/views/auth/RegistrationForm.tsx @@ -30,6 +30,7 @@ import PassphraseField from "./PassphraseField"; import CountlyAnalytics from "../../../CountlyAnalytics"; import Field from '../elements/Field'; import RegistrationEmailPromptDialog from '../dialogs/RegistrationEmailPromptDialog'; +import {replaceableComponent} from "../../../utils/replaceableComponent"; enum RegistrationField { Email = "field_email", @@ -39,7 +40,7 @@ enum RegistrationField { PasswordConfirm = "field_password_confirm", } -const PASSWORD_MIN_SCORE = 3; // safely unguessable: moderate protection from offline slow-hash scenario. +export const PASSWORD_MIN_SCORE = 3; // safely unguessable: moderate protection from offline slow-hash scenario. interface IProps { // Values pre-filled in the input boxes when the component loads @@ -80,6 +81,7 @@ interface IState { /* * A pure UI component which displays a registration form. */ +@replaceableComponent("views.auth.RegistrationForm") export default class RegistrationForm extends React.PureComponent { static defaultProps = { onValidationChange: console.error, diff --git a/src/components/views/auth/Welcome.js b/src/components/views/auth/Welcome.js index 0205f4e0b9..fca66fcf9b 100644 --- a/src/components/views/auth/Welcome.js +++ b/src/components/views/auth/Welcome.js @@ -24,10 +24,12 @@ import {_td} from "../../../languageHandler"; import SettingsStore from "../../../settings/SettingsStore"; import {UIFeature} from "../../../settings/UIFeature"; import CountlyAnalytics from "../../../CountlyAnalytics"; +import {replaceableComponent} from "../../../utils/replaceableComponent"; // translatable strings for Welcome pages _td("Sign in with SSO"); +@replaceableComponent("views.auth.Welcome") export default class Welcome extends React.PureComponent { constructor(props) { super(props); diff --git a/src/components/views/avatars/BaseAvatar.tsx b/src/components/views/avatars/BaseAvatar.tsx index 799a559263..5ecdd4ec5a 100644 --- a/src/components/views/avatars/BaseAvatar.tsx +++ b/src/components/views/avatars/BaseAvatar.tsx @@ -25,6 +25,8 @@ import AccessibleButton from '../elements/AccessibleButton'; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import {useEventEmitter} from "../../../hooks/useEventEmitter"; import {toPx} from "../../../utils/units"; +import {ResizeMethod} from "../../../Avatar"; +import { _t } from '../../../languageHandler'; interface IProps { name: string; // The name (first initial used as default) @@ -35,7 +37,7 @@ interface IProps { width?: number; height?: number; // XXX: resizeMethod not actually used. - resizeMethod?: string; + resizeMethod?: ResizeMethod; defaultToInitialLetter?: boolean; // true to add default url onClick?: React.MouseEventHandler; inputRef?: React.RefObject; @@ -139,6 +141,7 @@ const BaseAvatar = (props: IProps) => { if (onClick) { return ( { private _dmUser: User; private isUnmounted = false; diff --git a/src/components/views/avatars/GroupAvatar.tsx b/src/components/views/avatars/GroupAvatar.tsx index 51327605c0..3734ba9504 100644 --- a/src/components/views/avatars/GroupAvatar.tsx +++ b/src/components/views/avatars/GroupAvatar.tsx @@ -1,5 +1,5 @@ /* -Copyright 2017 Vector Creations Ltd +Copyright 2017, 2021 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -15,8 +15,10 @@ limitations under the License. */ import React from 'react'; -import {MatrixClientPeg} from '../../../MatrixClientPeg'; import BaseAvatar from './BaseAvatar'; +import {replaceableComponent} from "../../../utils/replaceableComponent"; +import {mediaFromMxc} from "../../../customisations/Media"; +import {ResizeMethod} from "../../../Avatar"; export interface IProps { groupId?: string; @@ -24,10 +26,11 @@ export interface IProps { groupAvatarUrl?: string; width?: number; height?: number; - resizeMethod?: string; + resizeMethod?: ResizeMethod; onClick?: React.MouseEventHandler; } +@replaceableComponent("views.avatars.GroupAvatar") export default class GroupAvatar extends React.Component { public static defaultProps = { width: 36, @@ -36,8 +39,8 @@ export default class GroupAvatar extends React.Component { }; getGroupAvatarUrl() { - return MatrixClientPeg.get().mxcUrlToHttp( - this.props.groupAvatarUrl, + if (!this.props.groupAvatarUrl) return null; + return mediaFromMxc(this.props.groupAvatarUrl).getThumbnailOfSourceHttp( this.props.width, this.props.height, this.props.resizeMethod, diff --git a/src/components/views/avatars/MemberAvatar.tsx b/src/components/views/avatars/MemberAvatar.tsx index 60b043016b..3205ca372c 100644 --- a/src/components/views/avatars/MemberAvatar.tsx +++ b/src/components/views/avatars/MemberAvatar.tsx @@ -20,15 +20,17 @@ import {RoomMember} from "matrix-js-sdk/src/models/room-member"; import dis from "../../../dispatcher/dispatcher"; import {Action} from "../../../dispatcher/actions"; -import {MatrixClientPeg} from "../../../MatrixClientPeg"; import BaseAvatar from "./BaseAvatar"; +import {replaceableComponent} from "../../../utils/replaceableComponent"; +import {mediaFromMxc} from "../../../customisations/Media"; +import {ResizeMethod} from "../../../Avatar"; interface IProps extends Omit, "name" | "idName" | "url"> { member: RoomMember; fallbackUserId?: string; width: number; height: number; - resizeMethod?: string; + resizeMethod?: ResizeMethod; // The onClick to give the avatar onClick?: React.MouseEventHandler; // Whether the onClick of the avatar should be overriden to dispatch `Action.ViewUser` @@ -42,6 +44,7 @@ interface IState { imageUrl?: string; } +@replaceableComponent("views.avatars.MemberAvatar") export default class MemberAvatar extends React.Component { public static defaultProps = { width: 40, @@ -61,18 +64,19 @@ export default class MemberAvatar extends React.Component { } private static getState(props: IProps): IState { - if (props.member && props.member.name) { + if (props.member?.name) { + let imageUrl = null; + if (props.member.getMxcAvatarUrl()) { + imageUrl = mediaFromMxc(props.member.getMxcAvatarUrl()).getThumbnailOfSourceHttp( + props.width, + props.height, + props.resizeMethod, + ); + } return { name: props.member.name, title: props.title || props.member.userId, - imageUrl: props.member.getAvatarUrl( - MatrixClientPeg.get().getHomeserverUrl(), - Math.floor(props.width * window.devicePixelRatio), - Math.floor(props.height * window.devicePixelRatio), - props.resizeMethod, - false, - false, - ), + imageUrl: imageUrl, }; } else if (props.fallbackUserId) { return { diff --git a/src/components/views/avatars/MemberStatusMessageAvatar.js b/src/components/views/avatars/MemberStatusMessageAvatar.js index d5d927106c..acf190f17f 100644 --- a/src/components/views/avatars/MemberStatusMessageAvatar.js +++ b/src/components/views/avatars/MemberStatusMessageAvatar.js @@ -23,7 +23,9 @@ import classNames from 'classnames'; import StatusMessageContextMenu from "../context_menus/StatusMessageContextMenu"; import SettingsStore from "../../../settings/SettingsStore"; import {ContextMenu, ContextMenuButton} from "../../structures/ContextMenu"; +import {replaceableComponent} from "../../../utils/replaceableComponent"; +@replaceableComponent("views.avatars.MemberStatusMessageAvatar") export default class MemberStatusMessageAvatar extends React.Component { static propTypes = { member: PropTypes.object.isRequired, diff --git a/src/components/views/avatars/RoomAvatar.tsx b/src/components/views/avatars/RoomAvatar.tsx index 952b9d4cb6..4693d907ba 100644 --- a/src/components/views/avatars/RoomAvatar.tsx +++ b/src/components/views/avatars/RoomAvatar.tsx @@ -15,7 +15,6 @@ limitations under the License. */ import React, {ComponentProps} from 'react'; import Room from 'matrix-js-sdk/src/models/room'; -import {getHttpUriForMxc} from 'matrix-js-sdk/src/content-repo'; import BaseAvatar from './BaseAvatar'; import ImageView from '../elements/ImageView'; @@ -23,6 +22,8 @@ import {MatrixClientPeg} from '../../../MatrixClientPeg'; import Modal from '../../../Modal'; import * as Avatar from '../../../Avatar'; import {ResizeMethod} from "../../../Avatar"; +import {replaceableComponent} from "../../../utils/replaceableComponent"; +import {mediaFromMxc} from "../../../customisations/Media"; interface IProps extends Omit, "name" | "idName" | "url" | "onClick"> { // Room may be left unset here, but if it is, @@ -42,6 +43,7 @@ interface IState { urls: string[]; } +@replaceableComponent("views.avatars.RoomAvatar") export default class RoomAvatar extends React.Component { public static defaultProps = { width: 36, @@ -88,16 +90,16 @@ export default class RoomAvatar extends React.Component { }; private static getImageUrls(props: IProps): string[] { - return [ - getHttpUriForMxc( - MatrixClientPeg.get().getHomeserverUrl(), - // Default props don't play nicely with getDerivedStateFromProps - //props.oobData !== undefined ? props.oobData.avatarUrl : {}, - props.oobData.avatarUrl, - Math.floor(props.width * window.devicePixelRatio), - Math.floor(props.height * window.devicePixelRatio), + let oobAvatar = null; + if (props.oobData.avatarUrl) { + oobAvatar = mediaFromMxc(props.oobData.avatarUrl).getThumbnailOfSourceHttp( + props.width, + props.height, props.resizeMethod, - ), // highest priority + ); + } + return [ + oobAvatar, // highest priority RoomAvatar.getRoomAvatarUrl(props), ].filter(function(url) { return (url !== null && url !== ""); @@ -107,12 +109,7 @@ export default class RoomAvatar extends React.Component { private static getRoomAvatarUrl(props: IProps): string { if (!props.room) return null; - return Avatar.avatarUrlForRoom( - props.room, - Math.floor(props.width * window.devicePixelRatio), - Math.floor(props.height * window.devicePixelRatio), - props.resizeMethod, - ); + return Avatar.avatarUrlForRoom(props.room, props.width, props.height, props.resizeMethod); } private onRoomAvatarClick = () => { @@ -127,7 +124,7 @@ export default class RoomAvatar extends React.Component { name: this.props.room.name, }; - Modal.createDialog(ImageView, params, "mx_Dialog_lightbox"); + Modal.createDialog(ImageView, params, "mx_Dialog_lightbox", null, true); }; public render() { diff --git a/src/components/views/avatars/WidgetAvatar.tsx b/src/components/views/avatars/WidgetAvatar.tsx index 04cfce7670..cca158269e 100644 --- a/src/components/views/avatars/WidgetAvatar.tsx +++ b/src/components/views/avatars/WidgetAvatar.tsx @@ -14,21 +14,18 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, {ComponentProps, useContext} from 'react'; +import React, {ComponentProps} from 'react'; import classNames from 'classnames'; -import {getHttpUriForMxc} from "matrix-js-sdk/src/content-repo"; -import MatrixClientContext from "../../../contexts/MatrixClientContext"; import {IApp} from "../../../stores/WidgetStore"; import BaseAvatar, {BaseAvatarType} from "./BaseAvatar"; +import {mediaFromMxc} from "../../../customisations/Media"; interface IProps extends Omit, "name" | "url" | "urls"> { app: IApp; } const WidgetAvatar: React.FC = ({ app, className, width = 20, height = 20, ...props }) => { - const cli = useContext(MatrixClientContext); - let iconUrls = [require("../../../../res/img/element-icons/room/default_app.svg")]; // heuristics for some better icons until Widgets support their own icons if (app.type.includes("jitsi")) { @@ -47,7 +44,7 @@ const WidgetAvatar: React.FC = ({ app, className, width = 20, height = 2 name={app.id} className={classNames("mx_WidgetAvatar", className)} // MSC2765 - url={app.avatar_url ? getHttpUriForMxc(cli.getHomeserverUrl(), app.avatar_url, 20, 20, "crop") : undefined} + url={app.avatar_url ? mediaFromMxc(app.avatar_url).getSquareThumbnailHttp(20) : undefined} urls={iconUrls} width={width} height={height} diff --git a/src/components/views/context_menus/CallContextMenu.tsx b/src/components/views/context_menus/CallContextMenu.tsx index 3557976326..97473059a6 100644 --- a/src/components/views/context_menus/CallContextMenu.tsx +++ b/src/components/views/context_menus/CallContextMenu.tsx @@ -22,11 +22,13 @@ import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call'; import CallHandler from '../../../CallHandler'; import InviteDialog, { KIND_CALL_TRANSFER } from '../dialogs/InviteDialog'; import Modal from '../../../Modal'; +import {replaceableComponent} from "../../../utils/replaceableComponent"; interface IProps extends IContextMenuProps { call: MatrixCall; } +@replaceableComponent("views.context_menus.CallContextMenu") export default class CallContextMenu extends React.Component { static propTypes = { // js-sdk User object. Not required because it might not exist. diff --git a/src/components/views/context_menus/DialpadContextMenu.tsx b/src/components/views/context_menus/DialpadContextMenu.tsx index e3aed0179b..17abce0c61 100644 --- a/src/components/views/context_menus/DialpadContextMenu.tsx +++ b/src/components/views/context_menus/DialpadContextMenu.tsx @@ -19,6 +19,7 @@ import { _t } from '../../../languageHandler'; import { ContextMenu, IProps as IContextMenuProps } from '../../structures/ContextMenu'; import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call'; import Dialpad from '../voip/DialPad'; +import {replaceableComponent} from "../../../utils/replaceableComponent"; interface IProps extends IContextMenuProps { call: MatrixCall; @@ -28,6 +29,7 @@ interface IState { value: string; } +@replaceableComponent("views.context_menus.DialpadContextMenu") export default class DialpadContextMenu extends React.Component { constructor(props) { super(props); diff --git a/src/components/views/context_menus/GenericElementContextMenu.js b/src/components/views/context_menus/GenericElementContextMenu.js index cea684b663..e04e3f7695 100644 --- a/src/components/views/context_menus/GenericElementContextMenu.js +++ b/src/components/views/context_menus/GenericElementContextMenu.js @@ -16,6 +16,7 @@ limitations under the License. import React from 'react'; import PropTypes from 'prop-types'; +import {replaceableComponent} from "../../../utils/replaceableComponent"; /* * This component can be used to display generic HTML content in a contextual @@ -23,6 +24,7 @@ import PropTypes from 'prop-types'; */ +@replaceableComponent("views.context_menus.GenericElementContextMenu") export default class GenericElementContextMenu extends React.Component { static propTypes = { element: PropTypes.element.isRequired, diff --git a/src/components/views/context_menus/GenericTextContextMenu.js b/src/components/views/context_menus/GenericTextContextMenu.js index 068f83be5f..3d3add006f 100644 --- a/src/components/views/context_menus/GenericTextContextMenu.js +++ b/src/components/views/context_menus/GenericTextContextMenu.js @@ -16,7 +16,9 @@ limitations under the License. import React from 'react'; import PropTypes from 'prop-types'; +import {replaceableComponent} from "../../../utils/replaceableComponent"; +@replaceableComponent("views.context_menus.GenericTextContextMenu") export default class GenericTextContextMenu extends React.Component { static propTypes = { message: PropTypes.string.isRequired, diff --git a/src/components/views/context_menus/GroupInviteTileContextMenu.js b/src/components/views/context_menus/GroupInviteTileContextMenu.js index 27ef76452f..15078326b3 100644 --- a/src/components/views/context_menus/GroupInviteTileContextMenu.js +++ b/src/components/views/context_menus/GroupInviteTileContextMenu.js @@ -20,10 +20,12 @@ import PropTypes from 'prop-types'; import * as sdk from '../../../index'; import { _t } from '../../../languageHandler'; import Modal from '../../../Modal'; -import {Group} from 'matrix-js-sdk'; +import {Group} from 'matrix-js-sdk/src/models/group'; import GroupStore from "../../../stores/GroupStore"; import {MenuItem} from "../../structures/ContextMenu"; +import {replaceableComponent} from "../../../utils/replaceableComponent"; +@replaceableComponent("views.context_menus.GroupInviteTileContextMenu") export default class GroupInviteTileContextMenu extends React.Component { static propTypes = { group: PropTypes.instanceOf(Group).isRequired, diff --git a/src/components/views/context_menus/MessageContextMenu.js b/src/components/views/context_menus/MessageContextMenu.js index 6b871e4f24..142b8c80a8 100644 --- a/src/components/views/context_menus/MessageContextMenu.js +++ b/src/components/views/context_menus/MessageContextMenu.js @@ -1,8 +1,6 @@ /* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2018 New Vector Ltd Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2015, 2016, 2018, 2019, 2021 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -19,7 +17,7 @@ limitations under the License. import React from 'react'; import PropTypes from 'prop-types'; -import {EventStatus} from 'matrix-js-sdk'; +import {EventStatus} from 'matrix-js-sdk/src/models/event'; import {MatrixClientPeg} from '../../../MatrixClientPeg'; import dis from '../../../dispatcher/dispatcher'; @@ -32,11 +30,13 @@ import { isUrlPermitted } from '../../../HtmlUtils'; import { isContentActionable } from '../../../utils/EventUtils'; import {MenuItem} from "../../structures/ContextMenu"; import {EventType} from "matrix-js-sdk/src/@types/event"; +import {replaceableComponent} from "../../../utils/replaceableComponent"; -function canCancel(eventStatus) { +export function canCancel(eventStatus) { return eventStatus === EventStatus.QUEUED || eventStatus === EventStatus.NOT_SENT; } +@replaceableComponent("views.context_menus.MessageContextMenu") export default class MessageContextMenu extends React.Component { static propTypes = { /* the MatrixEvent associated with the context menu */ @@ -50,6 +50,9 @@ export default class MessageContextMenu extends React.Component { /* callback called when the menu is dismissed */ onFinished: PropTypes.func, + + /* if the menu is inside a dialog, we sometimes need to close that dialog after click (forwarding) */ + onCloseDialog: PropTypes.func, }; state = { @@ -93,21 +96,6 @@ export default class MessageContextMenu extends React.Component { return content.pinned && Array.isArray(content.pinned) && content.pinned.includes(this.props.mxEvent.getId()); } - onResendClick = () => { - Resend.resend(this.props.mxEvent); - this.closeMenu(); - }; - - onResendEditClick = () => { - Resend.resend(this.props.mxEvent.replacingEvent()); - this.closeMenu(); - }; - - onResendRedactionClick = () => { - Resend.resend(this.props.mxEvent.localRedactionEvent()); - this.closeMenu(); - }; - onResendReactionsClick = () => { for (const reaction of this._getUnsentReactions()) { Resend.resend(reaction); @@ -124,24 +112,9 @@ export default class MessageContextMenu extends React.Component { }; onViewSourceClick = () => { - const ev = this.props.mxEvent.replacingEvent() || this.props.mxEvent; const ViewSource = sdk.getComponent('structures.ViewSource'); Modal.createTrackedDialog('View Event Source', '', ViewSource, { - roomId: ev.getRoomId(), - eventId: ev.getId(), - content: ev.event, - }, 'mx_Dialog_viewsource'); - this.closeMenu(); - }; - - onViewClearSourceClick = () => { - const ev = this.props.mxEvent.replacingEvent() || this.props.mxEvent; - const ViewSource = sdk.getComponent('structures.ViewSource'); - Modal.createTrackedDialog('View Clear Event Source', '', ViewSource, { - roomId: ev.getRoomId(), - eventId: ev.getId(), - // FIXME: _clearEvent is private - content: ev._clearEvent, + mxEvent: this.props.mxEvent, }, 'mx_Dialog_viewsource'); this.closeMenu(); }; @@ -154,6 +127,7 @@ export default class MessageContextMenu extends React.Component { const cli = MatrixClientPeg.get(); try { + if (this.props.onCloseDialog) this.props.onCloseDialog(); await cli.redactEvent( this.props.mxEvent.getRoomId(), this.props.mxEvent.getId(), @@ -179,30 +153,8 @@ export default class MessageContextMenu extends React.Component { this.closeMenu(); }; - onCancelSendClick = () => { - const mxEvent = this.props.mxEvent; - const editEvent = mxEvent.replacingEvent(); - const redactEvent = mxEvent.localRedactionEvent(); - const pendingReactions = this._getPendingReactions(); - - if (editEvent && canCancel(editEvent.status)) { - Resend.removeFromQueue(editEvent); - } - if (redactEvent && canCancel(redactEvent.status)) { - Resend.removeFromQueue(redactEvent); - } - if (pendingReactions.length) { - for (const reaction of pendingReactions) { - Resend.removeFromQueue(reaction); - } - } - if (canCancel(mxEvent.status)) { - Resend.removeFromQueue(this.props.mxEvent); - } - this.closeMenu(); - }; - onForwardClick = () => { + if (this.props.onCloseDialog) this.props.onCloseDialog(); dis.dispatch({ action: 'forward_event', event: this.props.mxEvent, @@ -293,23 +245,11 @@ export default class MessageContextMenu extends React.Component { const me = cli.getUserId(); const mxEvent = this.props.mxEvent; const eventStatus = mxEvent.status; - const editStatus = mxEvent.replacingEvent() && mxEvent.replacingEvent().status; - const redactStatus = mxEvent.localRedactionEvent() && mxEvent.localRedactionEvent().status; const unsentReactionsCount = this._getUnsentReactions().length; - const pendingReactionsCount = this._getPendingReactions().length; - const allowCancel = canCancel(mxEvent.status) || - canCancel(editStatus) || - canCancel(redactStatus) || - pendingReactionsCount !== 0; - let resendButton; - let resendEditButton; let resendReactionsButton; - let resendRedactionButton; let redactButton; - let cancelButton; let forwardButton; let pinButton; - let viewClearSourceButton; let unhidePreviewButton; let externalURLButton; let quoteButton; @@ -318,22 +258,6 @@ export default class MessageContextMenu extends React.Component { // status is SENT before remote-echo, null after const isSent = !eventStatus || eventStatus === EventStatus.SENT; if (!mxEvent.isRedacted()) { - if (eventStatus === EventStatus.NOT_SENT) { - resendButton = ( - - { _t('Resend') } - - ); - } - - if (editStatus === EventStatus.NOT_SENT) { - resendEditButton = ( - - { _t('Resend edit') } - - ); - } - if (unsentReactionsCount !== 0) { resendReactionsButton = ( @@ -343,14 +267,6 @@ export default class MessageContextMenu extends React.Component { } } - if (redactStatus === EventStatus.NOT_SENT) { - resendRedactionButton = ( - - { _t('Resend removal') } - - ); - } - if (isSent && this.state.canRedact) { redactButton = ( @@ -359,14 +275,6 @@ export default class MessageContextMenu extends React.Component { ); } - if (allowCancel) { - cancelButton = ( - - { _t('Cancel Sending') } - - ); - } - if (isContentActionable(mxEvent)) { forwardButton = ( @@ -389,14 +297,6 @@ export default class MessageContextMenu extends React.Component { ); - if (mxEvent.getType() !== mxEvent.getWireType()) { - viewClearSourceButton = ( - - { _t('View Decrypted Source') } - - ); - } - if (this.props.eventTileOps) { if (this.props.eventTileOps.isWidgetHidden()) { unhidePreviewButton = ( @@ -472,16 +372,11 @@ export default class MessageContextMenu extends React.Component { return (
    - { resendButton } - { resendEditButton } { resendReactionsButton } - { resendRedactionButton } { redactButton } - { cancelButton } { forwardButton } { pinButton } { viewSourceButton } - { viewClearSourceButton } { unhidePreviewButton } { permalinkButton } { quoteButton } diff --git a/src/components/views/context_menus/StatusMessageContextMenu.js b/src/components/views/context_menus/StatusMessageContextMenu.js index 5e6f06dd5d..41f0e0ba61 100644 --- a/src/components/views/context_menus/StatusMessageContextMenu.js +++ b/src/components/views/context_menus/StatusMessageContextMenu.js @@ -20,7 +20,9 @@ import { _t } from '../../../languageHandler'; import {MatrixClientPeg} from '../../../MatrixClientPeg'; import * as sdk from '../../../index'; import AccessibleButton from '../elements/AccessibleButton'; +import {replaceableComponent} from "../../../utils/replaceableComponent"; +@replaceableComponent("views.context_menus.StatusMessageContextMenu") export default class StatusMessageContextMenu extends React.Component { static propTypes = { // js-sdk User object. Not required because it might not exist. diff --git a/src/components/views/context_menus/TagTileContextMenu.js b/src/components/views/context_menus/TagTileContextMenu.js index 8d690483a8..8dea62690c 100644 --- a/src/components/views/context_menus/TagTileContextMenu.js +++ b/src/components/views/context_menus/TagTileContextMenu.js @@ -22,7 +22,9 @@ import dis from '../../../dispatcher/dispatcher'; import TagOrderActions from '../../../actions/TagOrderActions'; import {MenuItem} from "../../structures/ContextMenu"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; +import {replaceableComponent} from "../../../utils/replaceableComponent"; +@replaceableComponent("views.context_menus.TagTileContextMenu") export default class TagTileContextMenu extends React.Component { static propTypes = { tag: PropTypes.string.isRequired, diff --git a/src/components/views/context_menus/WidgetContextMenu.tsx b/src/components/views/context_menus/WidgetContextMenu.tsx index c1af86eae6..623fe04f2f 100644 --- a/src/components/views/context_menus/WidgetContextMenu.tsx +++ b/src/components/views/context_menus/WidgetContextMenu.tsx @@ -28,9 +28,11 @@ import dis from "../../../dispatcher/dispatcher"; import SettingsStore from "../../../settings/SettingsStore"; import Modal from "../../../Modal"; import QuestionDialog from "../dialogs/QuestionDialog"; +import ErrorDialog from "../dialogs/ErrorDialog"; import {WidgetType} from "../../../widgets/WidgetType"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import { Container, WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore"; +import { getConfigLivestreamUrl, startJitsiAudioLivestream } from "../../../Livestream"; interface IProps extends React.ComponentProps { app: IApp; @@ -54,6 +56,27 @@ const WidgetContextMenu: React.FC = ({ const widgetMessaging = WidgetMessagingStore.instance.getMessagingForId(app.id); const canModify = userWidget || WidgetUtils.canUserModifyWidgets(roomId); + let streamAudioStreamButton; + if (getConfigLivestreamUrl() && WidgetType.JITSI.matches(app.type)) { + const onStreamAudioClick = async () => { + try { + await startJitsiAudioLivestream(widgetMessaging, roomId); + } catch (err) { + console.error("Failed to start livestream", err); + // XXX: won't i18n well, but looks like widget api only support 'message'? + const message = err.message || _t("Unable to start audio streaming."); + Modal.createTrackedDialog('WidgetContext Menu', 'Livestream failed', ErrorDialog, { + title: _t('Failed to start livestream'), + description: message, + }); + } + onFinished(); + }; + streamAudioStreamButton = ; + } + let unpinButton; if (showUnpin) { const onUnpinClick = () => { @@ -163,6 +186,7 @@ const WidgetContextMenu: React.FC = ({ return + { streamAudioStreamButton } { editButton } { revokeButton } { deleteButton } @@ -175,4 +199,3 @@ const WidgetContextMenu: React.FC = ({ }; export default WidgetContextMenu; - diff --git a/src/components/views/dialogs/AddExistingToSpaceDialog.tsx b/src/components/views/dialogs/AddExistingToSpaceDialog.tsx index 66efaefd9d..1f45a76008 100644 --- a/src/components/views/dialogs/AddExistingToSpaceDialog.tsx +++ b/src/components/views/dialogs/AddExistingToSpaceDialog.tsx @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, {useState} from "react"; +import React, {useContext, useState} from "react"; import classNames from "classnames"; import {Room} from "matrix-js-sdk/src/models/room"; import {MatrixClient} from "matrix-js-sdk/src/client"; @@ -22,7 +22,6 @@ import {MatrixClient} from "matrix-js-sdk/src/client"; import {_t} from '../../../languageHandler'; import {IDialogProps} from "./IDialogProps"; import BaseDialog from "./BaseDialog"; -import FormButton from "../elements/FormButton"; import Dropdown from "../elements/Dropdown"; import SearchBox from "../../structures/SearchBox"; import SpaceStore from "../../../stores/SpaceStore"; @@ -34,6 +33,7 @@ import {allSettled} from "../../../utils/promise"; import DMRoomMap from "../../../utils/DMRoomMap"; import {calculateRoomVia} from "../../../utils/permalinks/Permalinks"; import StyledCheckbox from "../elements/StyledCheckbox"; +import MatrixClientContext from "../../../contexts/MatrixClientContext"; interface IProps extends IDialogProps { matrixClient: MatrixClient; @@ -42,42 +42,122 @@ interface IProps extends IDialogProps { } const Entry = ({ room, checked, onChange }) => { - return
    + return ; +}; + +interface IAddExistingToSpaceProps { + space: Room; + selected: Set; + onChange(checked: boolean, room: Room): void; +} + +export const AddExistingToSpace: React.FC = ({ space, selected, onChange }) => { + const cli = useContext(MatrixClientContext); + const [query, setQuery] = useState(""); + const lcQuery = query.toLowerCase(); + + const existingSubspaces = SpaceStore.instance.getChildSpaces(space.roomId); + const existingSubspacesSet = new Set(existingSubspaces); + const existingRoomsSet = new Set(SpaceStore.instance.getChildRooms(space.roomId)); + + const joinRule = space.getJoinRule(); + const [spaces, rooms, dms] = cli.getVisibleRooms().reduce((arr, room) => { + if (room.getMyMembership() !== "join") return arr; + if (!room.name.toLowerCase().includes(lcQuery)) return arr; + + if (room.isSpaceRoom()) { + if (room !== space && !existingSubspacesSet.has(room)) { + arr[0].push(room); + } + } else if (!existingRoomsSet.has(room)) { + if (!DMRoomMap.shared().getUserIdForRoomId(room.roomId)) { + arr[1].push(room); + } else if (joinRule !== "public") { + // Only show DMs for non-public spaces as they make very little sense in spaces other than "Just Me" ones. + arr[2].push(room); + } + } + return arr; + }, [[], [], []]); + + return
    + + + { rooms.length > 0 ? ( +
    +

    { _t("Rooms") }

    + { rooms.map(room => { + return { + onChange(checked, room); + }} + />; + }) } +
    + ) : undefined } + + { spaces.length > 0 ? ( +
    +

    { _t("Spaces") }

    + { spaces.map(space => { + return { + onChange(checked, space); + }} + />; + }) } +
    + ) : null } + + { dms.length > 0 ? ( +
    +

    { _t("Direct Messages") }

    + { dms.map(room => { + return { + onChange(checked, room); + }} + />; + }) } +
    + ) : null } + + { spaces.length + rooms.length + dms.length < 1 ? + { _t("No results") } + : undefined } +
    ; }; const AddExistingToSpaceDialog: React.FC = ({ matrixClient: cli, space, onCreateRoomClick, onFinished }) => { - const [query, setQuery] = useState(""); - const lcQuery = query.toLowerCase(); - const [selectedSpace, setSelectedSpace] = useState(space); - const [selectedToAdd, setSelectedToAdd] = useState(new Set()); - const existingSubspaces = SpaceStore.instance.getChildSpaces(space.roomId); - const existingSubspacesSet = new Set(existingSubspaces); - const spaces = SpaceStore.instance.getSpaces().filter(s => { - return !existingSubspacesSet.has(s) // not already in space - && space !== s // not the top-level space - && selectedSpace !== s // not the selected space - && s.name.toLowerCase().includes(lcQuery); // contains query - }); - - const existingRooms = SpaceStore.instance.getChildRooms(space.roomId); - const existingRoomsSet = new Set(existingRooms); - const rooms = cli.getVisibleRooms().filter(room => { - return !existingRoomsSet.has(room) // not already in space - && room.name.toLowerCase().includes(lcQuery) // contains query - && !DMRoomMap.shared().getUserIdForRoomId(room.roomId); // not a DM - }); + const [selectedToAdd, setSelectedToAdd] = useState(new Set()); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); let spaceOptionSection; - if (existingSubspacesSet.size > 0) { + if (existingSubspaces.length > 0) { const options = [space, ...existingSubspaces].map((space) => { const classes = classNames("mx_AddExistingToSpaceDialog_dropdownOption", { mx_AddExistingToSpaceDialog_dropdownOptionActive: space === selectedSpace, @@ -109,7 +189,7 @@ const AddExistingToSpaceDialog: React.FC = ({ matrixClient: cli, space, const title =
    -

    { _t("Add existing spaces/rooms") }

    +

    { _t("Add existing rooms") }

    { spaceOptionSection }
    ; @@ -117,64 +197,26 @@ const AddExistingToSpaceDialog: React.FC = ({ matrixClient: cli, space, return { error &&
    { error }
    } - - - { spaces.length > 0 ? ( -
    -

    { _t("Spaces") }

    - { spaces.map(space => { - return { - if (checked) { - selectedToAdd.add(space); - } else { - selectedToAdd.delete(space); - } - setSelectedToAdd(new Set(selectedToAdd)); - }} - />; - }) } -
    - ) : null } - - { rooms.length > 0 ? ( -
    -

    { _t("Rooms") }

    - { rooms.map(room => { - return { - if (checked) { - selectedToAdd.add(room); - } else { - selectedToAdd.delete(room); - } - setSelectedToAdd(new Set(selectedToAdd)); - }} - />; - }) } -
    - ) : undefined } - - { spaces.length + rooms.length < 1 ? - { _t("No results") } - : undefined } -
    + + { + if (checked) { + selectedToAdd.add(room); + } else { + selectedToAdd.delete(room); + } + setSelectedToAdd(new Set(selectedToAdd)); + }} + /> +
    @@ -184,10 +226,11 @@ const AddExistingToSpaceDialog: React.FC = ({ matrixClient: cli, space, - { + // TODO rate limiting setBusy(true); try { await allSettled(Array.from(selectedToAdd).map((room) => @@ -199,7 +242,9 @@ const AddExistingToSpaceDialog: React.FC = ({ matrixClient: cli, space, } setBusy(false); }} - /> + > + { busy ? _t("Adding...") : _t("Add") } +
    ; }; diff --git a/src/components/views/dialogs/AddressPickerDialog.js b/src/components/views/dialogs/AddressPickerDialog.js index 2cd09874b2..929d688e47 100644 --- a/src/components/views/dialogs/AddressPickerDialog.js +++ b/src/components/views/dialogs/AddressPickerDialog.js @@ -33,6 +33,7 @@ import { abbreviateUrl } from '../../../utils/UrlUtils'; import {sleep} from "../../../utils/promise"; import {Key} from "../../../Keyboard"; import {Action} from "../../../dispatcher/actions"; +import {replaceableComponent} from "../../../utils/replaceableComponent"; const TRUNCATE_QUERY_LIST = 40; const QUERY_USER_DIRECTORY_DEBOUNCE_MS = 200; @@ -43,7 +44,7 @@ const addressTypeName = { 'email': _td("email address"), }; - +@replaceableComponent("views.dialogs.AddressPickerDialog") export default class AddressPickerDialog extends React.Component { static propTypes = { title: PropTypes.string.isRequired, diff --git a/src/components/views/dialogs/AskInviteAnywayDialog.js b/src/components/views/dialogs/AskInviteAnywayDialog.js index c69400977a..e6cd45ba6b 100644 --- a/src/components/views/dialogs/AskInviteAnywayDialog.js +++ b/src/components/views/dialogs/AskInviteAnywayDialog.js @@ -20,7 +20,9 @@ import * as sdk from '../../../index'; import { _t } from '../../../languageHandler'; import SettingsStore from "../../../settings/SettingsStore"; import {SettingLevel} from "../../../settings/SettingLevel"; +import {replaceableComponent} from "../../../utils/replaceableComponent"; +@replaceableComponent("views.dialogs.AskInviteAnywayDialog") export default class AskInviteAnywayDialog extends React.Component { static propTypes = { unknownProfileUsers: PropTypes.array.isRequired, // [ {userId, errorText}... ] diff --git a/src/components/views/dialogs/BaseDialog.js b/src/components/views/dialogs/BaseDialog.js index 9ba5368ee5..0858e53e50 100644 --- a/src/components/views/dialogs/BaseDialog.js +++ b/src/components/views/dialogs/BaseDialog.js @@ -26,6 +26,7 @@ import AccessibleButton from '../elements/AccessibleButton'; import {MatrixClientPeg} from '../../../MatrixClientPeg'; import { _t } from "../../../languageHandler"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; +import {replaceableComponent} from "../../../utils/replaceableComponent"; /* * Basic container for modal dialogs. @@ -33,6 +34,7 @@ import MatrixClientContext from "../../../contexts/MatrixClientContext"; * Includes a div for the title, and a keypress handler which cancels the * dialog on escape. */ +@replaceableComponent("views.dialogs.BaseDialog") export default class BaseDialog extends React.Component { static propTypes = { // onFinished callback to call when Escape is pressed diff --git a/src/components/views/dialogs/BugReportDialog.js b/src/components/views/dialogs/BugReportDialog.js index c4dd0a1430..8948c14c7c 100644 --- a/src/components/views/dialogs/BugReportDialog.js +++ b/src/components/views/dialogs/BugReportDialog.js @@ -25,7 +25,9 @@ import Modal from '../../../Modal'; import { _t } from '../../../languageHandler'; import sendBugReport, {downloadBugReport} from '../../../rageshake/submit-rageshake'; import AccessibleButton from "../elements/AccessibleButton"; +import {replaceableComponent} from "../../../utils/replaceableComponent"; +@replaceableComponent("views.dialogs.BugReportDialog") export default class BugReportDialog extends React.Component { constructor(props) { super(props); diff --git a/src/components/views/dialogs/CommunityPrototypeInviteDialog.tsx b/src/components/views/dialogs/CommunityPrototypeInviteDialog.tsx index 1c8a4ad6f6..2635f95bb7 100644 --- a/src/components/views/dialogs/CommunityPrototypeInviteDialog.tsx +++ b/src/components/views/dialogs/CommunityPrototypeInviteDialog.tsx @@ -26,11 +26,12 @@ import SdkConfig from "../../../SdkConfig"; import { RoomMember } from "matrix-js-sdk/src/models/room-member"; import InviteDialog from "./InviteDialog"; import BaseAvatar from "../avatars/BaseAvatar"; -import {getHttpUriForMxc} from "matrix-js-sdk/src/content-repo"; import {inviteMultipleToRoom, showAnyInviteErrors} from "../../../RoomInvite"; import StyledCheckbox from "../elements/StyledCheckbox"; import Modal from "../../../Modal"; import ErrorDialog from "./ErrorDialog"; +import {replaceableComponent} from "../../../utils/replaceableComponent"; +import {mediaFromMxc} from "../../../customisations/Media"; interface IProps extends IDialogProps { roomId: string; @@ -52,6 +53,7 @@ interface IState { busy: boolean; } +@replaceableComponent("views.dialogs.CommunityPrototypeInviteDialog") export default class CommunityPrototypeInviteDialog extends React.PureComponent { constructor(props: IProps) { super(props); @@ -140,12 +142,14 @@ export default class CommunityPrototypeInviteDialog extends React.PureComponent< private renderPerson(person: IPerson, key: any) { const avatarSize = 36; + let avatarUrl = null; + if (person.user.getMxcAvatarUrl()) { + avatarUrl = mediaFromMxc(person.user.getMxcAvatarUrl()).getSquareThumbnailHttp(avatarSize); + } return (
    ; diff --git a/src/components/views/dialogs/ConfirmWipeDeviceDialog.js b/src/components/views/dialogs/ConfirmWipeDeviceDialog.js index 41ef9131fa..4faaad0f7e 100644 --- a/src/components/views/dialogs/ConfirmWipeDeviceDialog.js +++ b/src/components/views/dialogs/ConfirmWipeDeviceDialog.js @@ -18,7 +18,9 @@ import React from 'react'; import PropTypes from 'prop-types'; import {_t} from "../../../languageHandler"; import * as sdk from "../../../index"; +import {replaceableComponent} from "../../../utils/replaceableComponent"; +@replaceableComponent("views.dialogs.ConfirmWipeDeviceDialog") export default class ConfirmWipeDeviceDialog extends React.Component { static propTypes = { onFinished: PropTypes.func.isRequired, diff --git a/src/components/views/dialogs/CreateCommunityPrototypeDialog.tsx b/src/components/views/dialogs/CreateCommunityPrototypeDialog.tsx index 1d9d92b9c9..9b4484d661 100644 --- a/src/components/views/dialogs/CreateCommunityPrototypeDialog.tsx +++ b/src/components/views/dialogs/CreateCommunityPrototypeDialog.tsx @@ -25,6 +25,7 @@ import InfoTooltip from "../elements/InfoTooltip"; import dis from "../../../dispatcher/dispatcher"; import {showCommunityRoomInviteDialog} from "../../../RoomInvite"; import GroupStore from "../../../stores/GroupStore"; +import {replaceableComponent} from "../../../utils/replaceableComponent"; interface IProps extends IDialogProps { } @@ -38,6 +39,7 @@ interface IState { avatarPreview: string; } +@replaceableComponent("views.dialogs.CreateCommunityPrototypeDialog") export default class CreateCommunityPrototypeDialog extends React.PureComponent { private avatarUploadRef: React.RefObject = React.createRef(); diff --git a/src/components/views/dialogs/CreateGroupDialog.js b/src/components/views/dialogs/CreateGroupDialog.js index 6636153c98..e6c7a67aca 100644 --- a/src/components/views/dialogs/CreateGroupDialog.js +++ b/src/components/views/dialogs/CreateGroupDialog.js @@ -20,7 +20,9 @@ import * as sdk from '../../../index'; import dis from '../../../dispatcher/dispatcher'; import { _t } from '../../../languageHandler'; import {MatrixClientPeg} from '../../../MatrixClientPeg'; +import {replaceableComponent} from "../../../utils/replaceableComponent"; +@replaceableComponent("views.dialogs.CreateGroupDialog") export default class CreateGroupDialog extends React.Component { static propTypes = { onFinished: PropTypes.func.isRequired, diff --git a/src/components/views/dialogs/CreateRoomDialog.js b/src/components/views/dialogs/CreateRoomDialog.js index 0771b0ec45..e9dc6e2be0 100644 --- a/src/components/views/dialogs/CreateRoomDialog.js +++ b/src/components/views/dialogs/CreateRoomDialog.js @@ -27,7 +27,9 @@ import {MatrixClientPeg} from '../../../MatrixClientPeg'; import {Key} from "../../../Keyboard"; import {privateShouldBeEncrypted} from "../../../createRoom"; import {CommunityPrototypeStore} from "../../../stores/CommunityPrototypeStore"; +import {replaceableComponent} from "../../../utils/replaceableComponent"; +@replaceableComponent("views.dialogs.CreateRoomDialog") export default class CreateRoomDialog extends React.Component { static propTypes = { onFinished: PropTypes.func.isRequired, diff --git a/src/components/views/dialogs/DeactivateAccountDialog.js b/src/components/views/dialogs/DeactivateAccountDialog.js index fca8c42546..4e52549d51 100644 --- a/src/components/views/dialogs/DeactivateAccountDialog.js +++ b/src/components/views/dialogs/DeactivateAccountDialog.js @@ -26,7 +26,9 @@ import { _t } from '../../../languageHandler'; import InteractiveAuth, {ERROR_USER_CANCELLED} from "../../structures/InteractiveAuth"; import {DEFAULT_PHASE, PasswordAuthEntry, SSOAuthEntry} from "../auth/InteractiveAuthEntryComponents"; import StyledCheckbox from "../elements/StyledCheckbox"; +import {replaceableComponent} from "../../../utils/replaceableComponent"; +@replaceableComponent("views.dialogs.DeactivateAccountDialog") export default class DeactivateAccountDialog extends React.Component { constructor(props) { super(props); diff --git a/src/components/views/dialogs/DevtoolsDialog.js b/src/components/views/dialogs/DevtoolsDialog.js index 814378bb51..9f5513e0a3 100644 --- a/src/components/views/dialogs/DevtoolsDialog.js +++ b/src/components/views/dialogs/DevtoolsDialog.js @@ -19,7 +19,6 @@ import PropTypes from 'prop-types'; import * as sdk from '../../../index'; import SyntaxHighlight from '../elements/SyntaxHighlight'; import { _t } from '../../../languageHandler'; -import { Room, MatrixEvent } from "matrix-js-sdk"; import Field from "../elements/Field"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import {useEventEmitter} from "../../../hooks/useEventEmitter"; @@ -38,6 +37,9 @@ import {SETTINGS} from "../../../settings/Settings"; import SettingsStore, {LEVEL_ORDER} from "../../../settings/SettingsStore"; import Modal from "../../../Modal"; import ErrorDialog from "./ErrorDialog"; +import {replaceableComponent} from "../../../utils/replaceableComponent"; +import {Room} from "matrix-js-sdk/src/models/room"; +import {MatrixEvent} from "matrix-js-sdk/src/models/event"; class GenericEditor extends React.PureComponent { // static propTypes = {onBack: PropTypes.func.isRequired}; @@ -73,13 +75,14 @@ class GenericEditor extends React.PureComponent { } } -class SendCustomEvent extends GenericEditor { +export class SendCustomEvent extends GenericEditor { static getLabel() { return _t('Send Custom Event'); } static propTypes = { onBack: PropTypes.func.isRequired, room: PropTypes.instanceOf(Room).isRequired, forceStateEvent: PropTypes.bool, + forceGeneralEvent: PropTypes.bool, inputs: PropTypes.object, }; @@ -140,6 +143,8 @@ class SendCustomEvent extends GenericEditor {
    ; } + const showTglFlip = !this.state.message && !this.props.forceStateEvent && !this.props.forceGeneralEvent; + return
    @@ -155,7 +160,7 @@ class SendCustomEvent extends GenericEditor {
    { !this.state.message && } - { !this.state.message && !this.props.forceStateEvent &&
    + { showTglFlip &&
    } @@ -1089,6 +1094,7 @@ const Entries = [ SettingsExplorer, ]; +@replaceableComponent("views.dialogs.DevtoolsDialog") export default class DevtoolsDialog extends React.PureComponent { static propTypes = { roomId: PropTypes.string.isRequired, diff --git a/src/components/views/dialogs/EditCommunityPrototypeDialog.tsx b/src/components/views/dialogs/EditCommunityPrototypeDialog.tsx index 3071854b3e..ee3696b427 100644 --- a/src/components/views/dialogs/EditCommunityPrototypeDialog.tsx +++ b/src/components/views/dialogs/EditCommunityPrototypeDialog.tsx @@ -23,6 +23,8 @@ import AccessibleButton from "../elements/AccessibleButton"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { CommunityPrototypeStore } from "../../../stores/CommunityPrototypeStore"; import FlairStore from "../../../stores/FlairStore"; +import {replaceableComponent} from "../../../utils/replaceableComponent"; +import {mediaFromMxc} from "../../../customisations/Media"; interface IProps extends IDialogProps { communityId: string; @@ -38,6 +40,7 @@ interface IState { } // XXX: This is a lot of duplication from the create dialog, just in a different shape +@replaceableComponent("views.dialogs.EditCommunityPrototypeDialog") export default class EditCommunityPrototypeDialog extends React.PureComponent { private avatarUploadRef: React.RefObject = React.createRef(); @@ -116,7 +119,7 @@ export default class EditCommunityPrototypeDialog extends React.PureComponent; if (!this.state.avatarPreview) { if (this.state.currentAvatarUrl) { - const url = MatrixClientPeg.get().mxcUrlToHttp(this.state.currentAvatarUrl); + const url = mediaFromMxc(this.state.currentAvatarUrl).srcHttp; preview = ; } else { preview =
    diff --git a/src/components/views/dialogs/ErrorDialog.js b/src/components/views/dialogs/ErrorDialog.js index 3bfa635adf..5197c68b5a 100644 --- a/src/components/views/dialogs/ErrorDialog.js +++ b/src/components/views/dialogs/ErrorDialog.js @@ -29,7 +29,9 @@ import React from 'react'; import PropTypes from 'prop-types'; import * as sdk from '../../../index'; import { _t } from '../../../languageHandler'; +import {replaceableComponent} from "../../../utils/replaceableComponent"; +@replaceableComponent("views.dialogs.ErrorDialog") export default class ErrorDialog extends React.Component { static propTypes = { title: PropTypes.string, diff --git a/src/components/views/dialogs/FeedbackDialog.js b/src/components/views/dialogs/FeedbackDialog.js index cbe26af6cc..d80a935573 100644 --- a/src/components/views/dialogs/FeedbackDialog.js +++ b/src/components/views/dialogs/FeedbackDialog.js @@ -100,6 +100,20 @@ export default (props) => { ); } + let bugReports = null; + if (SdkConfig.get().bug_report_endpoint_url) { + bugReports = ( +

    { + _t("PRO TIP: If you start a bug, please submit debug logs " + + "to help us track down the problem.", {}, { + debugLogsLink: sub => ( + {sub} + ), + }) + }

    + ); + } + return ( { }, }) }

    -

    { - _t("PRO TIP: If you start a bug, please submit debug logs " + - "to help us track down the problem.", {}, { - debugLogsLink: sub => ( - {sub} - ), - }) - }

    + {bugReports}
    { countlyFeedbackSection } } diff --git a/src/components/views/dialogs/HostSignupDialog.tsx b/src/components/views/dialogs/HostSignupDialog.tsx index 45a03b7cf0..c8bc907136 100644 --- a/src/components/views/dialogs/HostSignupDialog.tsx +++ b/src/components/views/dialogs/HostSignupDialog.tsx @@ -31,6 +31,7 @@ import { IPostmessageResponseData, PostmessageAction, } from "./HostSignupDialogTypes"; +import {replaceableComponent} from "../../../utils/replaceableComponent"; const HOST_SIGNUP_KEY = "host_signup"; @@ -42,6 +43,7 @@ interface IState { minimized: boolean; } +@replaceableComponent("views.dialogs.HostSignupDialog") export default class HostSignupDialog extends React.PureComponent { private iframeRef: React.RefObject = React.createRef(); private readonly config: IHostSignupConfig; diff --git a/src/components/views/dialogs/IncomingSasDialog.js b/src/components/views/dialogs/IncomingSasDialog.js index 2a4ff9cec3..5df02d7a6f 100644 --- a/src/components/views/dialogs/IncomingSasDialog.js +++ b/src/components/views/dialogs/IncomingSasDialog.js @@ -19,6 +19,8 @@ import PropTypes from 'prop-types'; import {MatrixClientPeg} from '../../../MatrixClientPeg'; import * as sdk from '../../../index'; import { _t } from '../../../languageHandler'; +import {replaceableComponent} from "../../../utils/replaceableComponent"; +import {mediaFromMxc} from "../../../customisations/Media"; const PHASE_START = 0; const PHASE_SHOW_SAS = 1; @@ -26,6 +28,7 @@ const PHASE_WAIT_FOR_PARTNER_TO_CONFIRM = 2; const PHASE_VERIFIED = 3; const PHASE_CANCELLED = 4; +@replaceableComponent("views.dialogs.IncomingSasDialog") export default class IncomingSasDialog extends React.Component { static propTypes = { verifier: PropTypes.object.isRequired, @@ -121,22 +124,21 @@ export default class IncomingSasDialog extends React.Component { const Spinner = sdk.getComponent("views.elements.Spinner"); const BaseAvatar = sdk.getComponent("avatars.BaseAvatar"); - const isSelf = this.props.verifier.userId == MatrixClientPeg.get().getUserId(); + const isSelf = this.props.verifier.userId === MatrixClientPeg.get().getUserId(); let profile; - if (this.state.opponentProfile) { + const oppProfile = this.state.opponentProfile; + if (oppProfile) { + const url = oppProfile.avatar_url + ? mediaFromMxc(oppProfile.avatar_url).getSquareThumbnailHttp(48) + : null; profile =
    - -

    {this.state.opponentProfile.displayname}

    +

    {oppProfile.displayname}

    ; } else if (this.state.opponentProfileError) { profile =
    diff --git a/src/components/views/dialogs/InfoDialog.js b/src/components/views/dialogs/InfoDialog.js index 6dc9fc01b0..8207d334d3 100644 --- a/src/components/views/dialogs/InfoDialog.js +++ b/src/components/views/dialogs/InfoDialog.js @@ -27,7 +27,7 @@ export default class InfoDialog extends React.Component { className: PropTypes.string, title: PropTypes.string, description: PropTypes.node, - button: PropTypes.oneOfType(PropTypes.string, PropTypes.bool), + button: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]), onFinished: PropTypes.func, hasCloseButton: PropTypes.bool, onKeyDown: PropTypes.func, diff --git a/src/components/views/dialogs/IntegrationsDisabledDialog.js b/src/components/views/dialogs/IntegrationsDisabledDialog.js index 7c996fbeab..0e9878f4bc 100644 --- a/src/components/views/dialogs/IntegrationsDisabledDialog.js +++ b/src/components/views/dialogs/IntegrationsDisabledDialog.js @@ -20,7 +20,9 @@ import {_t} from "../../../languageHandler"; import * as sdk from "../../../index"; import dis from '../../../dispatcher/dispatcher'; import {Action} from "../../../dispatcher/actions"; +import {replaceableComponent} from "../../../utils/replaceableComponent"; +@replaceableComponent("views.dialogs.IntegrationsDisabledDialog") export default class IntegrationsDisabledDialog extends React.Component { static propTypes = { onFinished: PropTypes.func.isRequired, diff --git a/src/components/views/dialogs/IntegrationsImpossibleDialog.js b/src/components/views/dialogs/IntegrationsImpossibleDialog.js index 68bedc711d..9bc9d02ba6 100644 --- a/src/components/views/dialogs/IntegrationsImpossibleDialog.js +++ b/src/components/views/dialogs/IntegrationsImpossibleDialog.js @@ -19,7 +19,9 @@ import PropTypes from 'prop-types'; import {_t} from "../../../languageHandler"; import SdkConfig from "../../../SdkConfig"; import * as sdk from "../../../index"; +import {replaceableComponent} from "../../../utils/replaceableComponent"; +@replaceableComponent("views.dialogs.IntegrationsImpossibleDialog") export default class IntegrationsImpossibleDialog extends React.Component { static propTypes = { onFinished: PropTypes.func.isRequired, diff --git a/src/components/views/dialogs/InteractiveAuthDialog.js b/src/components/views/dialogs/InteractiveAuthDialog.js index 22291225ad..28a9bf673a 100644 --- a/src/components/views/dialogs/InteractiveAuthDialog.js +++ b/src/components/views/dialogs/InteractiveAuthDialog.js @@ -25,7 +25,9 @@ import { _t } from '../../../languageHandler'; import AccessibleButton from '../elements/AccessibleButton'; import {ERROR_USER_CANCELLED} from "../../structures/InteractiveAuth"; import {SSOAuthEntry} from "../auth/InteractiveAuthEntryComponents"; +import {replaceableComponent} from "../../../utils/replaceableComponent"; +@replaceableComponent("views.dialogs.InteractiveAuthDialog") export default class InteractiveAuthDialog extends React.Component { static propTypes = { // matrix client to use for UI auth requests diff --git a/src/components/views/dialogs/InviteDialog.tsx b/src/components/views/dialogs/InviteDialog.tsx index 9bc5b6476f..2ebc84ec7c 100644 --- a/src/components/views/dialogs/InviteDialog.tsx +++ b/src/components/views/dialogs/InviteDialog.tsx @@ -22,7 +22,6 @@ import {makeRoomPermalink, makeUserPermalink} from "../../../utils/permalinks/Pe import DMRoomMap from "../../../utils/DMRoomMap"; import {RoomMember} from "matrix-js-sdk/src/models/room-member"; import SdkConfig from "../../../SdkConfig"; -import {getHttpUriForMxc} from "matrix-js-sdk/src/content-repo"; import * as Email from "../../../email"; import {getDefaultIdentityServerUrl, useDefaultIdentityServer} from "../../../utils/IdentityServerUtils"; import {abbreviateUrl} from "../../../utils/UrlUtils"; @@ -30,7 +29,10 @@ import dis from "../../../dispatcher/dispatcher"; import IdentityAuthClient from "../../../IdentityAuthClient"; import Modal from "../../../Modal"; import {humanizeTime} from "../../../utils/humanize"; -import createRoom, {canEncryptToAllUsers, findDMForUser, privateShouldBeEncrypted} from "../../../createRoom"; +import createRoom, { + canEncryptToAllUsers, ensureDMExists, findDMForUser, privateShouldBeEncrypted, + IInvite3PID, +} from "../../../createRoom"; import {inviteMultipleToRoom, showCommunityInviteDialog} from "../../../RoomInvite"; import {Key} from "../../../Keyboard"; import {Action} from "../../../dispatcher/actions"; @@ -42,13 +44,15 @@ import {UIFeature} from "../../../settings/UIFeature"; import CountlyAnalytics from "../../../CountlyAnalytics"; import {Room} from "matrix-js-sdk/src/models/room"; import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call'; +import {replaceableComponent} from "../../../utils/replaceableComponent"; +import {mediaFromMxc} from "../../../customisations/Media"; +import {getAddressType} from "../../../UserAddress"; // we have a number of types defined from the Matrix spec which can't reasonably be altered here. /* eslint-disable camelcase */ export const KIND_DM = "dm"; export const KIND_INVITE = "invite"; -export const KIND_SPACE_INVITE = "space_invite"; export const KIND_CALL_TRANSFER = "call_transfer"; const INITIAL_ROOMS_SHOWN = 3; // Number of rooms to show at first @@ -160,9 +164,9 @@ class DMUserTile extends React.PureComponent { width={avatarSize} height={avatarSize} /> : { src={require("../../../../res/img/icon-email-pill-avatar.svg")} width={avatarSize} height={avatarSize} /> : { static defaultProps = { kind: KIND_DM, @@ -349,8 +355,8 @@ export default class InviteDialog extends React.PureComponent { + this.setState({consultFirst: ev.target.checked}); + } + static buildRecents(excludedTargetIds: Set): {userId: string, user: RoomMember, lastActive: number}[] { const rooms = DMRoomMap.shared().getUniqueRoomsWithIndividuals(); // map of userId => js-sdk Room @@ -608,13 +619,14 @@ export default class InviteDialog extends React.PureComponent { this.setState({busy: true}); + const client = MatrixClientPeg.get(); const targets = this._convertFilter(); const targetIds = targets.map(t => t.userId); // Check if there is already a DM with these people and reuse it if possible. let existingRoom: Room; if (targetIds.length === 1) { - existingRoom = findDMForUser(MatrixClientPeg.get(), targetIds[0]); + existingRoom = findDMForUser(client, targetIds[0]); } else { existingRoom = DMRoomMap.shared().getDMRoomForIdentifiers(targetIds); } @@ -636,7 +648,6 @@ export default class InviteDialog extends React.PureComponent t instanceof ThreepidMember); if (!has3PidMembers) { - const client = MatrixClientPeg.get(); const allHaveDeviceKeys = await canEncryptToAllUsers(client, targetIds); if (allHaveDeviceKeys) { createRoomOptions.encryption = true; @@ -646,45 +657,52 @@ export default class InviteDialog extends React.PureComponent; - const isSelf = targetIds.length === 1 && targetIds[0] === MatrixClientPeg.get().getUserId(); - if (targetIds.length === 1 && !isSelf) { - createRoomOptions.dmUserId = targetIds[0]; - createRoomPromise = createRoom(createRoomOptions); - } else if (isSelf) { - createRoomPromise = createRoom(createRoomOptions); - } else { - // Create a boring room and try to invite the targets manually. - createRoomPromise = createRoom(createRoomOptions).then(roomId => { - return inviteMultipleToRoom(roomId, targetIds); - }).then(result => { - if (this._shouldAbortAfterInviteError(result)) { - return true; // abort - } - }); - } + try { + const isSelf = targetIds.length === 1 && targetIds[0] === client.getUserId(); + if (targetIds.length === 1 && !isSelf) { + createRoomOptions.dmUserId = targetIds[0]; + } - // the createRoom call will show the room for us, so we don't need to worry about that. - createRoomPromise.then(abort => { - if (abort === true) return; // only abort on true booleans, not roomIds or something + if (targetIds.length > 1) { + createRoomOptions.createOpts = targetIds.reduce( + (roomOptions, address) => { + const type = getAddressType(address); + if (type === 'email') { + const invite: IInvite3PID = { + id_server: client.getIdentityServerUrl(true), + medium: 'email', + address, + }; + roomOptions.invite_3pid.push(invite); + } else if (type === 'mx-user-id') { + roomOptions.invite.push(address); + } + return roomOptions; + }, + { invite: [], invite_3pid: [] }, + ) + } + + await createRoom(createRoomOptions); this.props.onFinished(); - }).catch(err => { + } catch (err) { console.error(err); this.setState({ busy: false, - errorText: _t("We couldn't create your DM. Please check the users you want to invite and try again."), + errorText: _t("We couldn't create your DM."), }); - }); + } }; - _inviteUsers = () => { + _inviteUsers = async () => { const startTime = CountlyAnalytics.getTimestamp(); this.setState({busy: true}); this._convertFilter(); const targets = this._convertFilter(); const targetIds = targets.map(t => t.userId); - const room = MatrixClientPeg.get().getRoom(this.props.roomId); + const cli = MatrixClientPeg.get(); + const room = cli.getRoom(this.props.roomId); if (!room) { console.error("Failed to find the room to invite users to"); this.setState({ @@ -694,12 +712,33 @@ export default class InviteDialog extends React.PureComponent { + try { + const result = await inviteMultipleToRoom(this.props.roomId, targetIds) CountlyAnalytics.instance.trackSendInvite(startTime, this.props.roomId, targetIds.length); if (!this._shouldAbortAfterInviteError(result)) { // handles setting error message too this.props.onFinished(); } - }).catch(err => { + + if (cli.isRoomEncrypted(this.props.roomId)) { + const visibilityEvent = room.currentState.getStateEvents( + "m.room.history_visibility", "", + ); + const visibility = visibilityEvent && visibilityEvent.getContent() && + visibilityEvent.getContent().history_visibility; + if (visibility == "world_readable" || visibility == "shared") { + const invitedUsers = []; + for (const [addr, state] of Object.entries(result.states)) { + if (state === "invited" && getAddressType(addr) === "mx-user-id") { + invitedUsers.push(addr); + } + } + console.log("Sharing history with", invitedUsers); + cli.sendSharedHistoryKeys( + this.props.roomId, invitedUsers, + ); + } + } + } catch (err) { console.error(err); this.setState({ busy: false, @@ -707,7 +746,7 @@ export default class InviteDialog extends React.PureComponent { @@ -720,16 +759,34 @@ export default class InviteDialog extends React.PureComponent { - let filterText = this.state.filterText; - const targets = this.state.targets.map(t => t); // cheap clone for mutation - const idx = targets.indexOf(member); - if (idx >= 0) { - targets.splice(idx, 1); - } else { - targets.push(member); - filterText = ""; // clear the filter when the user accepts a suggestion - } - this.setState({targets, filterText}); + if (!this.state.busy) { + let filterText = this.state.filterText; + const targets = this.state.targets.map(t => t); // cheap clone for mutation + const idx = targets.indexOf(member); + if (idx >= 0) { + targets.splice(idx, 1); + } else { + targets.push(member); + filterText = ""; // clear the filter when the user accepts a suggestion + } + this.setState({targets, filterText}); - if (this._editorRef && this._editorRef.current) { - this._editorRef.current.focus(); + if (this._editorRef && this._editorRef.current) { + this._editorRef.current.focus(); + } } }; @@ -1027,7 +1086,7 @@ export default class InviteDialog extends React.PureComponent; const identityServersEnabled = SettingsStore.getValue(UIFeature.IdentityServer); - const userId = MatrixClientPeg.get().getUserId(); + const cli = MatrixClientPeg.get(); + const userId = cli.getUserId(); if (this.props.kind === KIND_DM) { title = _t("Direct Messages"); @@ -1248,25 +1310,33 @@ export default class InviteDialog extends React.PureComponent) or share this room."); + "(like ) or share this space."); } else { helpTextUntranslated = _td("Invite someone using their name, username " + - "(like ) or share this room."); + "(like ) or share this space."); } - } else { // KIND_SPACE_INVITE + } else { if (identityServersEnabled) { helpTextUntranslated = _td("Invite someone using their name, email address, username " + - "(like ) or share this space."); + "(like ) or share this room."); } else { helpTextUntranslated = _td("Invite someone using their name, username " + - "(like ) or share this space."); + "(like ) or share this room."); } } @@ -1279,10 +1349,34 @@ export default class InviteDialog extends React.PureComponent + + {" " + _t("Invited people will be able to read old messages.")} +

    ; + } + } } else if (this.props.kind === KIND_CALL_TRANSFER) { title = _t("Transfer"); buttonText = _t("Transfer"); goButtonFn = this._transferCall; + consultSection =
    + +
    ; } else { console.error("Unknown kind of InviteDialog: " + this.props.kind); } @@ -1312,12 +1406,14 @@ export default class InviteDialog extends React.PureComponent
    + {keySharingWarning} {this._renderIdentityServerWarning()}
    {this.state.errorText}
    {this._renderSection('recents')} {this._renderSection('suggestions')}
    + {consultSection}
    ); diff --git a/src/components/views/dialogs/LogoutDialog.js b/src/components/views/dialogs/LogoutDialog.js index af36dba2b6..7bced46d43 100644 --- a/src/components/views/dialogs/LogoutDialog.js +++ b/src/components/views/dialogs/LogoutDialog.js @@ -22,7 +22,9 @@ import dis from '../../../dispatcher/dispatcher'; import { _t } from '../../../languageHandler'; import { MatrixClientPeg } from '../../../MatrixClientPeg'; import RestoreKeyBackupDialog from './security/RestoreKeyBackupDialog'; +import {replaceableComponent} from "../../../utils/replaceableComponent"; +@replaceableComponent("views.dialogs.LogoutDialog") export default class LogoutDialog extends React.Component { defaultProps = { onFinished: function() {}, diff --git a/src/components/views/dialogs/ManualDeviceKeyVerificationDialog.js b/src/components/views/dialogs/ManualDeviceKeyVerificationDialog.js index 4b9d7239e6..3151edd796 100644 --- a/src/components/views/dialogs/ManualDeviceKeyVerificationDialog.js +++ b/src/components/views/dialogs/ManualDeviceKeyVerificationDialog.js @@ -24,7 +24,9 @@ import {MatrixClientPeg} from '../../../MatrixClientPeg'; import * as sdk from '../../../index'; import * as FormattingUtils from '../../../utils/FormattingUtils'; import { _t } from '../../../languageHandler'; +import {replaceableComponent} from "../../../utils/replaceableComponent"; +@replaceableComponent("views.dialogs.ManualDeviceKeyVerificationDialog") export default class ManualDeviceKeyVerificationDialog extends React.Component { static propTypes = { userId: PropTypes.string.isRequired, diff --git a/src/components/views/dialogs/MessageEditHistoryDialog.js b/src/components/views/dialogs/MessageEditHistoryDialog.js index 2bdf2be35c..7585561c0c 100644 --- a/src/components/views/dialogs/MessageEditHistoryDialog.js +++ b/src/components/views/dialogs/MessageEditHistoryDialog.js @@ -21,7 +21,9 @@ import { _t } from '../../../languageHandler'; import * as sdk from "../../../index"; import {wantsDateSeparator} from '../../../DateUtils'; import SettingsStore from '../../../settings/SettingsStore'; +import {replaceableComponent} from "../../../utils/replaceableComponent"; +@replaceableComponent("views.dialogs.MessageEditHistoryDialog") export default class MessageEditHistoryDialog extends React.PureComponent { static propTypes = { mxEvent: PropTypes.object.isRequired, diff --git a/src/components/views/dialogs/ModalWidgetDialog.tsx b/src/components/views/dialogs/ModalWidgetDialog.tsx index 92fb406965..59eaab7b81 100644 --- a/src/components/views/dialogs/ModalWidgetDialog.tsx +++ b/src/components/views/dialogs/ModalWidgetDialog.tsx @@ -38,6 +38,7 @@ import {MatrixClientPeg} from "../../../MatrixClientPeg"; import {OwnProfileStore} from "../../../stores/OwnProfileStore"; import { arrayFastClone } from "../../../utils/arrays"; import { ElementWidget } from "../../../stores/widgets/StopGapWidget"; +import {replaceableComponent} from "../../../utils/replaceableComponent"; interface IProps { widgetDefinition: IModalWidgetOpenRequestData; @@ -53,6 +54,7 @@ interface IState { const MAX_BUTTONS = 3; +@replaceableComponent("views.dialogs.ModalWidgetDialog") export default class ModalWidgetDialog extends React.PureComponent { private readonly widget: Widget; private readonly possibleButtons: ModalButtonID[]; diff --git a/src/components/views/dialogs/NewSessionReviewDialog.js b/src/components/views/dialogs/NewSessionReviewDialog.js index e17501da40..5172f29405 100644 --- a/src/components/views/dialogs/NewSessionReviewDialog.js +++ b/src/components/views/dialogs/NewSessionReviewDialog.js @@ -66,6 +66,10 @@ export default class NewSessionReviewDialog extends React.PureComponent { Modal.createTrackedDialog('New Session Verification', 'Starting dialog', VerificationRequestDialog, { verificationRequestPromise: requestPromise, member: cli.getUser(userId), + onFinished: async () => { + const request = await requestPromise; + request.cancel(); + }, }); } diff --git a/src/components/views/dialogs/ReportEventDialog.js b/src/components/views/dialogs/ReportEventDialog.js index f5509dec4d..5454b97287 100644 --- a/src/components/views/dialogs/ReportEventDialog.js +++ b/src/components/views/dialogs/ReportEventDialog.js @@ -18,14 +18,16 @@ import React, {PureComponent} from 'react'; import * as sdk from '../../../index'; import { _t } from '../../../languageHandler'; import PropTypes from "prop-types"; -import {MatrixEvent} from "matrix-js-sdk"; +import {MatrixEvent} from "matrix-js-sdk/src/models/event"; import {MatrixClientPeg} from "../../../MatrixClientPeg"; import SdkConfig from '../../../SdkConfig'; import Markdown from '../../../Markdown'; +import {replaceableComponent} from "../../../utils/replaceableComponent"; /* * A dialog for reporting an event. */ +@replaceableComponent("views.dialogs.ReportEventDialog") export default class ReportEventDialog extends PureComponent { static propTypes = { mxEvent: PropTypes.instanceOf(MatrixEvent).isRequired, diff --git a/src/components/views/dialogs/RoomSettingsDialog.js b/src/components/views/dialogs/RoomSettingsDialog.js index 9d9313f08f..9c2f23ef22 100644 --- a/src/components/views/dialogs/RoomSettingsDialog.js +++ b/src/components/views/dialogs/RoomSettingsDialog.js @@ -30,6 +30,7 @@ import {MatrixClientPeg} from "../../../MatrixClientPeg"; import dis from "../../../dispatcher/dispatcher"; import SettingsStore from "../../../settings/SettingsStore"; import {UIFeature} from "../../../settings/UIFeature"; +import {replaceableComponent} from "../../../utils/replaceableComponent"; export const ROOM_GENERAL_TAB = "ROOM_GENERAL_TAB"; export const ROOM_SECURITY_TAB = "ROOM_SECURITY_TAB"; @@ -38,6 +39,7 @@ export const ROOM_NOTIFICATIONS_TAB = "ROOM_NOTIFICATIONS_TAB"; export const ROOM_BRIDGES_TAB = "ROOM_BRIDGES_TAB"; export const ROOM_ADVANCED_TAB = "ROOM_ADVANCED_TAB"; +@replaceableComponent("views.dialogs.RoomSettingsDialog") export default class RoomSettingsDialog extends React.Component { static propTypes = { roomId: PropTypes.string.isRequired, @@ -116,7 +118,7 @@ export default class RoomSettingsDialog extends React.Component { return ( -
    +
    diff --git a/src/components/views/dialogs/RoomUpgradeDialog.js b/src/components/views/dialogs/RoomUpgradeDialog.js index 85e97444ed..8f9ed42ada 100644 --- a/src/components/views/dialogs/RoomUpgradeDialog.js +++ b/src/components/views/dialogs/RoomUpgradeDialog.js @@ -20,7 +20,9 @@ import * as sdk from '../../../index'; import {MatrixClientPeg} from '../../../MatrixClientPeg'; import Modal from '../../../Modal'; import { _t } from '../../../languageHandler'; +import {replaceableComponent} from "../../../utils/replaceableComponent"; +@replaceableComponent("views.dialogs.RoomUpgradeDialog") export default class RoomUpgradeDialog extends React.Component { static propTypes = { room: PropTypes.object.isRequired, diff --git a/src/components/views/dialogs/RoomUpgradeWarningDialog.js b/src/components/views/dialogs/RoomUpgradeWarningDialog.js index c83528c5ba..c6ef2c6ae2 100644 --- a/src/components/views/dialogs/RoomUpgradeWarningDialog.js +++ b/src/components/views/dialogs/RoomUpgradeWarningDialog.js @@ -22,7 +22,9 @@ import * as sdk from "../../../index"; import LabelledToggleSwitch from "../elements/LabelledToggleSwitch"; import {MatrixClientPeg} from "../../../MatrixClientPeg"; import Modal from "../../../Modal"; +import {replaceableComponent} from "../../../utils/replaceableComponent"; +@replaceableComponent("views.dialogs.RoomUpgradeWarningDialog") export default class RoomUpgradeWarningDialog extends React.Component { static propTypes = { onFinished: PropTypes.func.isRequired, @@ -80,6 +82,33 @@ export default class RoomUpgradeWarningDialog extends React.Component { const title = this.state.isPrivate ? _t("Upgrade private room") : _t("Upgrade public room"); + let bugReports = ( +

    + {_t( + "This usually only affects how the room is processed on the server. If you're " + + "having problems with your %(brand)s, please report a bug.", {brand}, + )} +

    + ); + if (SdkConfig.get().bug_report_endpoint_url) { + bugReports = ( +

    + {_t( + "This usually only affects how the room is processed on the server. If you're " + + "having problems with your %(brand)s, please report a bug.", + { + brand, + }, + { + "a": (sub) => { + return {sub}; + }, + }, + )} +

    + ); + } + return ( -

    - {_t( - "This usually only affects how the room is processed on the server. If you're " + - "having problems with your %(brand)s, please report a bug.", - { - brand, - }, - { - "a": (sub) => { - return {sub}; - }, - }, - )} -

    + {bugReports}

    {_t( "You'll upgrade this room from to .", diff --git a/src/components/views/dialogs/ServerOfflineDialog.tsx b/src/components/views/dialogs/ServerOfflineDialog.tsx index 81f628343b..52ff056907 100644 --- a/src/components/views/dialogs/ServerOfflineDialog.tsx +++ b/src/components/views/dialogs/ServerOfflineDialog.tsx @@ -28,10 +28,12 @@ import AccessibleButton from "../elements/AccessibleButton"; import { UPDATE_EVENT } from "../../../stores/AsyncStore"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { IDialogProps } from "./IDialogProps"; +import {replaceableComponent} from "../../../utils/replaceableComponent"; interface IProps extends IDialogProps { } +@replaceableComponent("views.dialogs.ServerOfflineDialog") export default class ServerOfflineDialog extends React.PureComponent { public componentDidMount() { EchoStore.instance.on(UPDATE_EVENT, this.onEchosUpdated); diff --git a/src/components/views/dialogs/ServerPickerDialog.tsx b/src/components/views/dialogs/ServerPickerDialog.tsx index 7ca115760e..62a2b95c68 100644 --- a/src/components/views/dialogs/ServerPickerDialog.tsx +++ b/src/components/views/dialogs/ServerPickerDialog.tsx @@ -1,5 +1,5 @@ /* -Copyright 2020 The Matrix.org Foundation C.I.C. +Copyright 2020-2021 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -26,6 +26,7 @@ import Field from "../elements/Field"; import StyledRadioButton from "../elements/StyledRadioButton"; import TextWithTooltip from "../elements/TextWithTooltip"; import withValidation, {IFieldState} from "../elements/Validation"; +import {replaceableComponent} from "../../../utils/replaceableComponent"; interface IProps { title?: string; @@ -38,6 +39,7 @@ interface IState { otherHomeserver: string; } +@replaceableComponent("views.dialogs.ServerPickerDialog") export default class ServerPickerDialog extends React.PureComponent { private readonly defaultServer: ValidatedServerConfig; private readonly fieldRef = createRef(); @@ -108,7 +110,7 @@ export default class ServerPickerDialog extends React.PureComponent diff --git a/src/components/views/dialogs/SeshatResetDialog.tsx b/src/components/views/dialogs/SeshatResetDialog.tsx new file mode 100644 index 0000000000..63654ca949 --- /dev/null +++ b/src/components/views/dialogs/SeshatResetDialog.tsx @@ -0,0 +1,54 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from 'react'; +import {_t} from "../../../languageHandler"; +import {replaceableComponent} from "../../../utils/replaceableComponent"; + +import BaseDialog from "./BaseDialog"; +import DialogButtons from "../elements/DialogButtons"; + +import {IDialogProps} from "./IDialogProps"; + +@replaceableComponent("views.dialogs.SeshatResetDialog") +export default class SeshatResetDialog extends React.PureComponent { + render() { + return ( + +

    +

    + {_t("You most likely do not want to reset your event index store")} +
    + {_t("If you do, please note that none of your messages will be deleted, " + + "but the search experience might be degraded for a few moments " + + "whilst the index is recreated", + )} +

    +
    + +
    + ); + } +} diff --git a/src/components/views/dialogs/SessionRestoreErrorDialog.js b/src/components/views/dialogs/SessionRestoreErrorDialog.js index bae6b19fbe..50d7fbea09 100644 --- a/src/components/views/dialogs/SessionRestoreErrorDialog.js +++ b/src/components/views/dialogs/SessionRestoreErrorDialog.js @@ -22,8 +22,9 @@ import * as sdk from '../../../index'; import SdkConfig from '../../../SdkConfig'; import Modal from '../../../Modal'; import { _t } from '../../../languageHandler'; +import {replaceableComponent} from "../../../utils/replaceableComponent"; - +@replaceableComponent("views.dialogs.SessionRestoreErrorDialog") export default class SessionRestoreErrorDialog extends React.Component { static propTypes = { error: PropTypes.string.isRequired, diff --git a/src/components/views/dialogs/SetEmailDialog.js b/src/components/views/dialogs/SetEmailDialog.js index 6514d94dc9..0f8f410a6a 100644 --- a/src/components/views/dialogs/SetEmailDialog.js +++ b/src/components/views/dialogs/SetEmailDialog.js @@ -22,6 +22,7 @@ import * as Email from '../../../email'; import AddThreepid from '../../../AddThreepid'; import { _t } from '../../../languageHandler'; import Modal from '../../../Modal'; +import {replaceableComponent} from "../../../utils/replaceableComponent"; /* @@ -29,6 +30,7 @@ import Modal from '../../../Modal'; * * On success, `onFinished(true)` is called. */ +@replaceableComponent("views.dialogs.SetEmailDialog") export default class SetEmailDialog extends React.Component { static propTypes = { onFinished: PropTypes.func.isRequired, diff --git a/src/components/views/dialogs/ShareDialog.tsx b/src/components/views/dialogs/ShareDialog.tsx index 5264031cc6..df1206a4f0 100644 --- a/src/components/views/dialogs/ShareDialog.tsx +++ b/src/components/views/dialogs/ShareDialog.tsx @@ -34,6 +34,7 @@ import AccessibleTooltipButton from '../elements/AccessibleTooltipButton'; import { IDialogProps } from "./IDialogProps"; import SettingsStore from "../../../settings/SettingsStore"; import {UIFeature} from "../../../settings/UIFeature"; +import {replaceableComponent} from "../../../utils/replaceableComponent"; const socials = [ { @@ -73,6 +74,7 @@ interface IState { permalinkCreator: RoomPermalinkCreator; } +@replaceableComponent("views.dialogs.ShareDialog") export default class ShareDialog extends React.PureComponent { static propTypes = { onFinished: PropTypes.func.isRequired, diff --git a/src/components/views/dialogs/SpaceSettingsDialog.tsx b/src/components/views/dialogs/SpaceSettingsDialog.tsx index f6bf5b87e6..83f5d7141b 100644 --- a/src/components/views/dialogs/SpaceSettingsDialog.tsx +++ b/src/components/views/dialogs/SpaceSettingsDialog.tsx @@ -28,7 +28,6 @@ import {getTopic} from "../elements/RoomTopic"; import {avatarUrlForRoom} from "../../../Avatar"; import ToggleSwitch from "../elements/ToggleSwitch"; import AccessibleButton from "../elements/AccessibleButton"; -import FormButton from "../elements/FormButton"; import Modal from "../../../Modal"; import defaultDispatcher from "../../../dispatcher/dispatcher"; import {allSettled} from "../../../utils/promise"; @@ -127,23 +126,24 @@ const SpaceSettingsDialog: React.FC = ({ matrixClient: cli, space, onFin
    { _t("Make this space private") } setJoinRule(checked ? "private" : "invite")} + checked={joinRule !== "public"} + onChange={checked => setJoinRule(checked ? "invite" : "public")} disabled={!canSetJoinRule} aria-label={_t("Make this space private")} />
    - { defaultDispatcher.dispatch({ action: "leave_room", room_id: space.roomId, }); }} - /> + > + { _t("Leave Space") } +
    Modal.createDialog(DevtoolsDialog, {roomId: space.roomId})}> @@ -152,7 +152,9 @@ const SpaceSettingsDialog: React.FC = ({ matrixClient: cli, space, onFin { _t("Cancel") } - + + { busy ? _t("Saving...") : _t("Save Changes") } +
    ; diff --git a/src/components/views/dialogs/StorageEvictedDialog.js b/src/components/views/dialogs/StorageEvictedDialog.js index a22f302807..15c5347644 100644 --- a/src/components/views/dialogs/StorageEvictedDialog.js +++ b/src/components/views/dialogs/StorageEvictedDialog.js @@ -20,7 +20,9 @@ import * as sdk from '../../../index'; import SdkConfig from '../../../SdkConfig'; import Modal from '../../../Modal'; import { _t } from '../../../languageHandler'; +import {replaceableComponent} from "../../../utils/replaceableComponent"; +@replaceableComponent("views.dialogs.StorageEvictedDialog") export default class StorageEvictedDialog extends React.Component { static propTypes = { onFinished: PropTypes.func.isRequired, diff --git a/src/components/views/dialogs/TabbedIntegrationManagerDialog.js b/src/components/views/dialogs/TabbedIntegrationManagerDialog.js index 9f5c9f6a11..618b0b4347 100644 --- a/src/components/views/dialogs/TabbedIntegrationManagerDialog.js +++ b/src/components/views/dialogs/TabbedIntegrationManagerDialog.js @@ -17,12 +17,14 @@ limitations under the License. import React from 'react'; import PropTypes from 'prop-types'; import {IntegrationManagers} from "../../../integrations/IntegrationManagers"; -import {Room} from "matrix-js-sdk"; +import {Room} from "matrix-js-sdk/src/models/room"; import * as sdk from '../../../index'; import {dialogTermsInteractionCallback, TermsNotSignedError} from "../../../Terms"; import classNames from 'classnames'; import * as ScalarMessaging from "../../../ScalarMessaging"; +import {replaceableComponent} from "../../../utils/replaceableComponent"; +@replaceableComponent("views.dialogs.TabbedIntegrationManagerDialog") export default class TabbedIntegrationManagerDialog extends React.Component { static propTypes = { /** diff --git a/src/components/views/dialogs/TermsDialog.js b/src/components/views/dialogs/TermsDialog.js index 402605c545..e8625ec6cb 100644 --- a/src/components/views/dialogs/TermsDialog.js +++ b/src/components/views/dialogs/TermsDialog.js @@ -20,7 +20,8 @@ import PropTypes from 'prop-types'; import * as sdk from '../../../index'; import { _t, pickBestLanguage } from '../../../languageHandler'; -import Matrix from 'matrix-js-sdk'; +import {replaceableComponent} from "../../../utils/replaceableComponent"; +import {SERVICE_TYPES} from "matrix-js-sdk/src/service-types"; class TermsCheckbox extends React.PureComponent { static propTypes = { @@ -41,6 +42,7 @@ class TermsCheckbox extends React.PureComponent { } } +@replaceableComponent("views.dialogs.TermsDialog") export default class TermsDialog extends React.PureComponent { static propTypes = { /** @@ -83,22 +85,22 @@ export default class TermsDialog extends React.PureComponent { _nameForServiceType(serviceType, host) { switch (serviceType) { - case Matrix.SERVICE_TYPES.IS: + case SERVICE_TYPES.IS: return
    {_t("Identity Server")}
    ({host})
    ; - case Matrix.SERVICE_TYPES.IM: + case SERVICE_TYPES.IM: return
    {_t("Integration Manager")}
    ({host})
    ; } } _summaryForServiceType(serviceType) { switch (serviceType) { - case Matrix.SERVICE_TYPES.IS: + case SERVICE_TYPES.IS: return
    {_t("Find others by phone or email")}
    {_t("Be found by phone or email")}
    ; - case Matrix.SERVICE_TYPES.IM: + case SERVICE_TYPES.IM: return
    {_t("Use bots, bridges, widgets and sticker packs")}
    ; diff --git a/src/components/views/dialogs/TextInputDialog.js b/src/components/views/dialogs/TextInputDialog.js index 69cc4390be..97abd209c0 100644 --- a/src/components/views/dialogs/TextInputDialog.js +++ b/src/components/views/dialogs/TextInputDialog.js @@ -19,7 +19,9 @@ import PropTypes from 'prop-types'; import * as sdk from '../../../index'; import Field from "../elements/Field"; import { _t, _td } from '../../../languageHandler'; +import {replaceableComponent} from "../../../utils/replaceableComponent"; +@replaceableComponent("views.dialogs.TextInputDialog") export default class TextInputDialog extends React.Component { static propTypes = { title: PropTypes.string, diff --git a/src/components/views/dialogs/UploadConfirmDialog.js b/src/components/views/dialogs/UploadConfirmDialog.js index e3521eb282..2ff16b9440 100644 --- a/src/components/views/dialogs/UploadConfirmDialog.js +++ b/src/components/views/dialogs/UploadConfirmDialog.js @@ -20,7 +20,9 @@ import PropTypes from 'prop-types'; import * as sdk from '../../../index'; import { _t } from '../../../languageHandler'; import filesize from "filesize"; +import {replaceableComponent} from "../../../utils/replaceableComponent"; +@replaceableComponent("views.dialogs.UploadConfirmDialog") export default class UploadConfirmDialog extends React.Component { static propTypes = { file: PropTypes.object.isRequired, diff --git a/src/components/views/dialogs/UploadFailureDialog.js b/src/components/views/dialogs/UploadFailureDialog.js index 4be1656f66..d220d6c684 100644 --- a/src/components/views/dialogs/UploadFailureDialog.js +++ b/src/components/views/dialogs/UploadFailureDialog.js @@ -21,12 +21,14 @@ import PropTypes from 'prop-types'; import * as sdk from '../../../index'; import { _t } from '../../../languageHandler'; import ContentMessages from '../../../ContentMessages'; +import {replaceableComponent} from "../../../utils/replaceableComponent"; /* * Tells the user about files we know cannot be uploaded before we even try uploading * them. This is named fairly generically but the only thing we check right now is * the size of the file. */ +@replaceableComponent("views.dialogs.UploadFailureDialog") export default class UploadFailureDialog extends React.Component { static propTypes = { badFiles: PropTypes.arrayOf(PropTypes.object).isRequired, diff --git a/src/components/views/dialogs/UserSettingsDialog.js b/src/components/views/dialogs/UserSettingsDialog.js index 7164540aea..eb9eaeb5dd 100644 --- a/src/components/views/dialogs/UserSettingsDialog.js +++ b/src/components/views/dialogs/UserSettingsDialog.js @@ -33,6 +33,7 @@ import * as sdk from "../../../index"; import SdkConfig from "../../../SdkConfig"; import MjolnirUserSettingsTab from "../settings/tabs/user/MjolnirUserSettingsTab"; import {UIFeature} from "../../../settings/UIFeature"; +import {replaceableComponent} from "../../../utils/replaceableComponent"; export const USER_GENERAL_TAB = "USER_GENERAL_TAB"; export const USER_APPEARANCE_TAB = "USER_APPEARANCE_TAB"; @@ -45,6 +46,7 @@ export const USER_LABS_TAB = "USER_LABS_TAB"; export const USER_MJOLNIR_TAB = "USER_MJOLNIR_TAB"; export const USER_HELP_TAB = "USER_HELP_TAB"; +@replaceableComponent("views.dialogs.UserSettingsDialog") export default class UserSettingsDialog extends React.Component { static propTypes = { onFinished: PropTypes.func.isRequired, @@ -155,7 +157,7 @@ export default class UserSettingsDialog extends React.Component { return ( -
    +
    diff --git a/src/components/views/dialogs/VerificationRequestDialog.js b/src/components/views/dialogs/VerificationRequestDialog.js index 3a6e9a2d10..205597a1c4 100644 --- a/src/components/views/dialogs/VerificationRequestDialog.js +++ b/src/components/views/dialogs/VerificationRequestDialog.js @@ -19,17 +19,19 @@ import PropTypes from 'prop-types'; import {MatrixClientPeg} from '../../../MatrixClientPeg'; import * as sdk from '../../../index'; import { _t } from '../../../languageHandler'; +import {replaceableComponent} from "../../../utils/replaceableComponent"; +@replaceableComponent("views.dialogs.VerificationRequestDialog") export default class VerificationRequestDialog extends React.Component { static propTypes = { verificationRequest: PropTypes.object, verificationRequestPromise: PropTypes.object, onFinished: PropTypes.func.isRequired, + member: PropTypes.string, }; constructor(...args) { super(...args); - this.onFinished = this.onFinished.bind(this); this.state = {}; if (this.props.verificationRequest) { this.state.verificationRequest = this.props.verificationRequest; @@ -48,9 +50,9 @@ export default class VerificationRequestDialog extends React.Component { const member = this.props.member || otherUserId && MatrixClientPeg.get().getUser(otherUserId); const title = request && request.isSelfVerification ? - _t("Verify other session") : _t("Verification Request"); + _t("Verify other login") : _t("Verification Request"); - return ; } - - async onFinished() { - this.props.onFinished(); - let request = this.props.verificationRequest; - if (!request && this.props.verificationRequestPromise) { - request = await this.props.verificationRequestPromise; - } - request.cancel(); - } } diff --git a/src/components/views/dialogs/WidgetCapabilitiesPromptDialog.tsx b/src/components/views/dialogs/WidgetCapabilitiesPromptDialog.tsx index 535e0b7b8e..70fe7fe5e3 100644 --- a/src/components/views/dialogs/WidgetCapabilitiesPromptDialog.tsx +++ b/src/components/views/dialogs/WidgetCapabilitiesPromptDialog.tsx @@ -29,6 +29,7 @@ import StyledCheckbox from "../elements/StyledCheckbox"; import DialogButtons from "../elements/DialogButtons"; import LabelledToggleSwitch from "../elements/LabelledToggleSwitch"; import { CapabilityText } from "../../../widgets/CapabilityText"; +import {replaceableComponent} from "../../../utils/replaceableComponent"; export function getRememberedCapabilitiesForWidget(widget: Widget): Capability[] { return JSON.parse(localStorage.getItem(`widget_${widget.id}_approved_caps`) || "[]"); @@ -54,6 +55,7 @@ interface IState { rememberSelection: boolean; } +@replaceableComponent("views.dialogs.WidgetCapabilitiesPromptDialog") export default class WidgetCapabilitiesPromptDialog extends React.PureComponent { private eventPermissionsMap = new Map(); diff --git a/src/components/views/dialogs/WidgetOpenIDPermissionsDialog.js b/src/components/views/dialogs/WidgetOpenIDPermissionsDialog.js index c01d3d39b8..f45adf9738 100644 --- a/src/components/views/dialogs/WidgetOpenIDPermissionsDialog.js +++ b/src/components/views/dialogs/WidgetOpenIDPermissionsDialog.js @@ -21,7 +21,9 @@ import * as sdk from "../../../index"; import LabelledToggleSwitch from "../elements/LabelledToggleSwitch"; import {Widget} from "matrix-widget-api"; import {OIDCState, WidgetPermissionStore} from "../../../stores/widgets/WidgetPermissionStore"; +import {replaceableComponent} from "../../../utils/replaceableComponent"; +@replaceableComponent("views.dialogs.WidgetOpenIDPermissionsDialog") export default class WidgetOpenIDPermissionsDialog extends React.Component { static propTypes = { onFinished: PropTypes.func.isRequired, diff --git a/src/components/views/dialogs/security/AccessSecretStorageDialog.js b/src/components/views/dialogs/security/AccessSecretStorageDialog.tsx similarity index 61% rename from src/components/views/dialogs/security/AccessSecretStorageDialog.js rename to src/components/views/dialogs/security/AccessSecretStorageDialog.tsx index f54a053984..ffe513581b 100644 --- a/src/components/views/dialogs/security/AccessSecretStorageDialog.js +++ b/src/components/views/dialogs/security/AccessSecretStorageDialog.tsx @@ -1,6 +1,5 @@ /* -Copyright 2018, 2019 New Vector Ltd -Copyright 2019, 2020 The Matrix.org Foundation C.I.C. +Copyright 2018-2021 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -17,14 +16,17 @@ limitations under the License. import {debounce} from "lodash"; import classNames from 'classnames'; -import React from 'react'; -import PropTypes from "prop-types"; +import React, {ChangeEvent, FormEvent} from 'react'; +import {ISecretStorageKeyInfo} from "matrix-js-sdk/src"; + import * as sdk from '../../../../index'; import {MatrixClientPeg} from '../../../../MatrixClientPeg'; import Field from '../../elements/Field'; import AccessibleButton from '../../elements/AccessibleButton'; - -import { _t } from '../../../../languageHandler'; +import {_t} from '../../../../languageHandler'; +import {IDialogProps} from "../IDialogProps"; +import {accessSecretStorage} from "../../../../SecurityManager"; +import Modal from "../../../../Modal"; // Maximum acceptable size of a key file. It's 59 characters including the spaces we encode, // so this should be plenty and allow for people putting extra whitespace in the file because @@ -34,22 +36,31 @@ const KEY_FILE_MAX_SIZE = 128; // Don't shout at the user that their key is invalid every time they type a key: wait a short time const VALIDATION_THROTTLE_MS = 200; +interface IProps extends IDialogProps { + keyInfo: ISecretStorageKeyInfo; + checkPrivateKey: (k: {passphrase?: string, recoveryKey?: string}) => boolean; +} + +interface IState { + recoveryKey: string; + recoveryKeyValid: boolean | null; + recoveryKeyCorrect: boolean | null; + recoveryKeyFileError: boolean | null; + forceRecoveryKey: boolean; + passPhrase: string; + keyMatches: boolean | null; + resetting: boolean; +} + /* * Access Secure Secret Storage by requesting the user's passphrase. */ -export default class AccessSecretStorageDialog extends React.PureComponent { - static propTypes = { - // { passphrase, pubkey } - keyInfo: PropTypes.object.isRequired, - // Function from one of { passphrase, recoveryKey } -> boolean - checkPrivateKey: PropTypes.func.isRequired, - } +export default class AccessSecretStorageDialog extends React.PureComponent { + private fileUpload = React.createRef(); constructor(props) { super(props); - this._fileUpload = React.createRef(); - this.state = { recoveryKey: "", recoveryKeyValid: null, @@ -58,24 +69,28 @@ export default class AccessSecretStorageDialog extends React.PureComponent { forceRecoveryKey: false, passPhrase: '', keyMatches: null, + resetting: false, }; } - _onCancel = () => { + private onCancel = () => { + if (this.state.resetting) { + this.setState({resetting: false}); + } this.props.onFinished(false); - } + }; - _onUseRecoveryKeyClick = () => { + private onUseRecoveryKeyClick = () => { this.setState({ forceRecoveryKey: true, }); - } + }; - _validateRecoveryKeyOnChange = debounce(() => { - this._validateRecoveryKey(); + private validateRecoveryKeyOnChange = debounce(async () => { + await this.validateRecoveryKey(); }, VALIDATION_THROTTLE_MS); - async _validateRecoveryKey() { + private async validateRecoveryKey() { if (this.state.recoveryKey === '') { this.setState({ recoveryKeyValid: null, @@ -102,27 +117,27 @@ export default class AccessSecretStorageDialog extends React.PureComponent { } } - _onRecoveryKeyChange = (e) => { + private onRecoveryKeyChange = (ev: ChangeEvent) => { this.setState({ - recoveryKey: e.target.value, + recoveryKey: ev.target.value, recoveryKeyFileError: null, }); // also clear the file upload control so that the user can upload the same file // the did before (otherwise the onchange wouldn't fire) - if (this._fileUpload.current) this._fileUpload.current.value = null; + if (this.fileUpload.current) this.fileUpload.current.value = null; // We don't use Field's validation here because a) we want it in a separate place rather // than in a tooltip and b) we want it to display feedback based on the uploaded file // as well as the text box. Ideally we would refactor Field's validation logic so we could // re-use some of it. - this._validateRecoveryKeyOnChange(); - } + this.validateRecoveryKeyOnChange(); + }; - _onRecoveryKeyFileChange = async e => { - if (e.target.files.length === 0) return; + private onRecoveryKeyFileChange = async (ev: ChangeEvent) => { + if (ev.target.files.length === 0) return; - const f = e.target.files[0]; + const f = ev.target.files[0]; if (f.size > KEY_FILE_MAX_SIZE) { this.setState({ @@ -140,7 +155,7 @@ export default class AccessSecretStorageDialog extends React.PureComponent { recoveryKeyFileError: null, recoveryKey: contents.trim(), }); - this._validateRecoveryKey(); + await this.validateRecoveryKey(); } else { this.setState({ recoveryKeyFileError: true, @@ -150,14 +165,14 @@ export default class AccessSecretStorageDialog extends React.PureComponent { }); } } + }; + + private onRecoveryKeyFileUploadClick = () => { + this.fileUpload.current.click(); } - _onRecoveryKeyFileUploadClick = () => { - this._fileUpload.current.click(); - } - - _onPassPhraseNext = async (e) => { - e.preventDefault(); + private onPassPhraseNext = async (ev: FormEvent) => { + ev.preventDefault(); if (this.state.passPhrase.length <= 0) return; @@ -169,10 +184,10 @@ export default class AccessSecretStorageDialog extends React.PureComponent { } else { this.setState({ keyMatches }); } - } + }; - _onRecoveryKeyNext = async (e) => { - e.preventDefault(); + private onRecoveryKeyNext = async (ev: FormEvent) => { + ev.preventDefault(); if (!this.state.recoveryKeyValid) return; @@ -184,16 +199,65 @@ export default class AccessSecretStorageDialog extends React.PureComponent { } else { this.setState({ keyMatches }); } - } + }; - _onPassPhraseChange = (e) => { + private onPassPhraseChange = (ev: ChangeEvent) => { this.setState({ - passPhrase: e.target.value, + passPhrase: ev.target.value, keyMatches: null, }); - } + }; - getKeyValidationText() { + private onResetAllClick = (ev: React.MouseEvent) => { + ev.preventDefault(); + this.setState({resetting: true}); + }; + + private onConfirmResetAllClick = async () => { + // Hide ourselves so the user can interact with the reset dialogs. + // We don't conclude the promise chain (onFinished) yet to avoid confusing + // any upstream code flows. + // + // Note: this will unmount us, so don't call `setState` or anything in the + // rest of this function. + Modal.toggleCurrentDialogVisibility(); + + try { + // Force reset secret storage (which resets the key backup) + await accessSecretStorage(async () => { + // Now reset cross-signing so everything Just Works™ again. + const cli = MatrixClientPeg.get(); + await cli.bootstrapCrossSigning({ + authUploadDeviceSigningKeys: async (makeRequest) => { + // XXX: Making this an import breaks the app. + const InteractiveAuthDialog = sdk.getComponent("views.dialogs.InteractiveAuthDialog"); + const {finished} = Modal.createTrackedDialog( + 'Cross-signing keys dialog', '', InteractiveAuthDialog, + { + title: _t("Setting up keys"), + matrixClient: cli, + makeRequest, + }, + ); + const [confirmed] = await finished; + if (!confirmed) { + throw new Error("Cross-signing key upload auth canceled"); + } + }, + setupNewCrossSigning: true, + }); + + // Now we can indicate that the user is done pressing buttons, finally. + // Upstream flows will detect the new secret storage, key backup, etc and use it. + this.props.onFinished(true); + }, true); + } catch (e) { + console.error(e); + this.props.onFinished(false); + } + }; + + private getKeyValidationText(): string { if (this.state.recoveryKeyFileError) { return _t("Wrong file type"); } else if (this.state.recoveryKeyCorrect) { @@ -208,7 +272,9 @@ export default class AccessSecretStorageDialog extends React.PureComponent { } render() { - const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); + // Caution: Making these an import will break tests. + const BaseDialog = sdk.getComponent("views.dialogs.BaseDialog"); + const DialogButtons = sdk.getComponent("views.elements.DialogButtons"); const hasPassphrase = ( this.props.keyInfo && @@ -217,11 +283,36 @@ export default class AccessSecretStorageDialog extends React.PureComponent { this.props.keyInfo.passphrase.iterations ); + const resetButton = ( +
    + {_t("Forgotten or lost all recovery methods? Reset all", null, { + a: (sub) => {sub}, + })} +
    + ); + let content; let title; let titleClass; - if (hasPassphrase && !this.state.forceRecoveryKey) { - const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); + if (this.state.resetting) { + title = _t("Reset everything"); + titleClass = ['mx_AccessSecretStorageDialog_titleWithIcon mx_AccessSecretStorageDialog_resetBadge']; + content =
    +

    {_t("Only do this if you have no other device to complete verification with.")}

    +

    {_t("If you reset everything, you will restart with no trusted sessions, no trusted users, and " + + "might not be able to see past messages.")}

    + +
    ; + } else if (hasPassphrase && !this.state.forceRecoveryKey) { const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); title = _t("Security Phrase"); titleClass = ['mx_AccessSecretStorageDialog_titleWithIcon mx_AccessSecretStorageDialog_securePhraseTitle']; @@ -244,18 +335,18 @@ export default class AccessSecretStorageDialog extends React.PureComponent { { button: s => {s} , }, )}

    -
    +
    ; } else { title = _t("Security Key"); titleClass = ['mx_AccessSecretStorageDialog_titleWithIcon mx_AccessSecretStorageDialog_secureBackupTitle']; - const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); const feedbackClasses = classNames({ 'mx_AccessSecretStorageDialog_recoveryKeyFeedback': true, @@ -291,7 +382,7 @@ export default class AccessSecretStorageDialog extends React.PureComponent {
    @@ -301,7 +392,7 @@ export default class AccessSecretStorageDialog extends React.PureComponent { type="password" label={_t('Security Key')} value={this.state.recoveryKey} - onChange={this._onRecoveryKeyChange} + onChange={this.onRecoveryKeyChange} forceValidity={this.state.recoveryKeyCorrect} autoComplete="off" /> @@ -312,10 +403,10 @@ export default class AccessSecretStorageDialog extends React.PureComponent {
    - + {_t("Upload")}
    @@ -323,13 +414,14 @@ export default class AccessSecretStorageDialog extends React.PureComponent { {recoveryKeyFeedback}
    ; @@ -341,9 +433,9 @@ export default class AccessSecretStorageDialog extends React.PureComponent { title={title} titleClass={titleClass} > -
    - {content} -
    +
    + {content} +
    ); } diff --git a/src/components/views/dialogs/security/ConfirmDestroyCrossSigningDialog.js b/src/components/views/dialogs/security/ConfirmDestroyCrossSigningDialog.js index abc1586205..43fb25f152 100644 --- a/src/components/views/dialogs/security/ConfirmDestroyCrossSigningDialog.js +++ b/src/components/views/dialogs/security/ConfirmDestroyCrossSigningDialog.js @@ -18,7 +18,9 @@ import React from 'react'; import PropTypes from 'prop-types'; import {_t} from "../../../../languageHandler"; import * as sdk from "../../../../index"; +import {replaceableComponent} from "../../../../utils/replaceableComponent"; +@replaceableComponent("views.dialogs.security.ConfirmDestroyCrossSigningDialog") export default class ConfirmDestroyCrossSigningDialog extends React.Component { static propTypes = { onFinished: PropTypes.func.isRequired, diff --git a/src/components/views/dialogs/security/CreateCrossSigningDialog.js b/src/components/views/dialogs/security/CreateCrossSigningDialog.js index be546d2616..fedcc02f89 100644 --- a/src/components/views/dialogs/security/CreateCrossSigningDialog.js +++ b/src/components/views/dialogs/security/CreateCrossSigningDialog.js @@ -25,12 +25,14 @@ import DialogButtons from '../../elements/DialogButtons'; import BaseDialog from '../BaseDialog'; import Spinner from '../../elements/Spinner'; import InteractiveAuthDialog from '../InteractiveAuthDialog'; +import {replaceableComponent} from "../../../../utils/replaceableComponent"; /* * Walks the user through the process of creating a cross-signing keys. In most * cases, only a spinner is shown, but for more complex auth like SSO, the user * may need to complete some steps to proceed. */ +@replaceableComponent("views.dialogs.security.CreateCrossSigningDialog") export default class CreateCrossSigningDialog extends React.PureComponent { static propTypes = { accountPassword: PropTypes.string, diff --git a/src/components/views/dialogs/security/RestoreKeyBackupDialog.js b/src/components/views/dialogs/security/RestoreKeyBackupDialog.js index ca28ca094c..1fafe03d95 100644 --- a/src/components/views/dialogs/security/RestoreKeyBackupDialog.js +++ b/src/components/views/dialogs/security/RestoreKeyBackupDialog.js @@ -19,7 +19,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import * as sdk from '../../../../index'; import {MatrixClientPeg} from '../../../../MatrixClientPeg'; -import { MatrixClient } from 'matrix-js-sdk'; +import { MatrixClient } from 'matrix-js-sdk/src/client'; import { _t } from '../../../../languageHandler'; import { accessSecretStorage } from '../../../../SecurityManager'; diff --git a/src/components/views/dialogs/security/SetupEncryptionDialog.js b/src/components/views/dialogs/security/SetupEncryptionDialog.js index 9ce3144534..3c15ea9f1d 100644 --- a/src/components/views/dialogs/security/SetupEncryptionDialog.js +++ b/src/components/views/dialogs/security/SetupEncryptionDialog.js @@ -20,6 +20,7 @@ import SetupEncryptionBody from '../../../structures/auth/SetupEncryptionBody'; import BaseDialog from '../BaseDialog'; import { _t } from '../../../../languageHandler'; import { SetupEncryptionStore, PHASE_DONE } from '../../../../stores/SetupEncryptionStore'; +import {replaceableComponent} from "../../../../utils/replaceableComponent"; function iconFromPhase(phase) { if (phase === PHASE_DONE) { @@ -29,6 +30,7 @@ function iconFromPhase(phase) { } } +@replaceableComponent("views.dialogs.security.SetupEncryptionDialog") export default class SetupEncryptionDialog extends React.Component { static propTypes = { onFinished: PropTypes.func.isRequired, diff --git a/src/components/views/elements/AccessibleTooltipButton.tsx b/src/components/views/elements/AccessibleTooltipButton.tsx index b7c7b78e63..3bb264fb3e 100644 --- a/src/components/views/elements/AccessibleTooltipButton.tsx +++ b/src/components/views/elements/AccessibleTooltipButton.tsx @@ -20,6 +20,7 @@ import classNames from 'classnames'; import AccessibleButton from "./AccessibleButton"; import Tooltip from './Tooltip'; +import {replaceableComponent} from "../../../utils/replaceableComponent"; interface ITooltipProps extends React.ComponentProps { title: string; @@ -33,6 +34,7 @@ interface IState { hover: boolean; } +@replaceableComponent("views.elements.AccessibleTooltipButton") export default class AccessibleTooltipButton extends React.PureComponent { constructor(props: ITooltipProps) { super(props); diff --git a/src/components/views/elements/ActionButton.js b/src/components/views/elements/ActionButton.js index bec016bce0..1714891cb5 100644 --- a/src/components/views/elements/ActionButton.js +++ b/src/components/views/elements/ActionButton.js @@ -20,7 +20,9 @@ import AccessibleButton from './AccessibleButton'; import dis from '../../../dispatcher/dispatcher'; import * as sdk from '../../../index'; import Analytics from '../../../Analytics'; +import {replaceableComponent} from "../../../utils/replaceableComponent"; +@replaceableComponent("views.elements.ActionButton") export default class ActionButton extends React.Component { static propTypes = { size: PropTypes.string, diff --git a/src/components/views/elements/AddressSelector.js b/src/components/views/elements/AddressSelector.js index 2a71622bb8..33b2906870 100644 --- a/src/components/views/elements/AddressSelector.js +++ b/src/components/views/elements/AddressSelector.js @@ -20,7 +20,9 @@ import PropTypes from 'prop-types'; import * as sdk from '../../../index'; import classNames from 'classnames'; import { UserAddressType } from '../../../UserAddress'; +import {replaceableComponent} from "../../../utils/replaceableComponent"; +@replaceableComponent("views.elements.AddressSelector") export default class AddressSelector extends React.Component { static propTypes = { onSelected: PropTypes.func.isRequired, diff --git a/src/components/views/elements/AddressTile.js b/src/components/views/elements/AddressTile.js index dc6c6b2914..df66d10a71 100644 --- a/src/components/views/elements/AddressTile.js +++ b/src/components/views/elements/AddressTile.js @@ -19,11 +19,12 @@ import React from 'react'; import PropTypes from 'prop-types'; import classNames from 'classnames'; import * as sdk from "../../../index"; -import {MatrixClientPeg} from "../../../MatrixClientPeg"; import { _t } from '../../../languageHandler'; import { UserAddressType } from '../../../UserAddress.js'; +import {replaceableComponent} from "../../../utils/replaceableComponent"; +import {mediaFromMxc} from "../../../customisations/Media"; - +@replaceableComponent("views.elements.AddressTile") export default class AddressTile extends React.Component { static propTypes = { address: UserAddressType.isRequired, @@ -46,9 +47,7 @@ export default class AddressTile extends React.Component { const isMatrixAddress = ['mx-user-id', 'mx-room-id'].includes(address.addressType); if (isMatrixAddress && address.avatarMxc) { - imgUrls.push(MatrixClientPeg.get().mxcUrlToHttp( - address.avatarMxc, 25, 25, 'crop', - )); + imgUrls.push(mediaFromMxc(address.avatarMxc).getSquareThumbnailHttp(25)); } else if (address.addressType === 'email') { imgUrls.push(require("../../../../res/img/icon-email-user.svg")); } diff --git a/src/components/views/elements/AppPermission.js b/src/components/views/elements/AppPermission.js index ec8bffc32f..65e40ef19a 100644 --- a/src/components/views/elements/AppPermission.js +++ b/src/components/views/elements/AppPermission.js @@ -24,7 +24,9 @@ import { _t } from '../../../languageHandler'; import SdkConfig from '../../../SdkConfig'; import WidgetUtils from "../../../utils/WidgetUtils"; import {MatrixClientPeg} from "../../../MatrixClientPeg"; +import {replaceableComponent} from "../../../utils/replaceableComponent"; +@replaceableComponent("views.elements.AppPermission") export default class AppPermission extends React.Component { static propTypes = { url: PropTypes.string.isRequired, diff --git a/src/components/views/elements/AppTile.js b/src/components/views/elements/AppTile.js index 213351889f..e206fda797 100644 --- a/src/components/views/elements/AppTile.js +++ b/src/components/views/elements/AppTile.js @@ -38,7 +38,9 @@ import {ElementWidgetActions} from "../../../stores/widgets/ElementWidgetActions import {MatrixCapabilities} from "matrix-widget-api"; import RoomWidgetContextMenu from "../context_menus/WidgetContextMenu"; import WidgetAvatar from "../avatars/WidgetAvatar"; +import {replaceableComponent} from "../../../utils/replaceableComponent"; +@replaceableComponent("views.elements.AppTile") export default class AppTile extends React.Component { constructor(props) { super(props); @@ -325,9 +327,13 @@ export default class AppTile extends React.Component { // Additional iframe feature pemissions // (see - https://sites.google.com/a/chromium.org/dev/Home/chromium-security/deprecating-permissions-in-cross-origin-iframes and https://wicg.github.io/feature-policy/) - const iframeFeatures = "microphone; camera; encrypted-media; autoplay; display-capture;"; + const iframeFeatures = "microphone; camera; encrypted-media; autoplay; display-capture; clipboard-write;"; const appTileBodyClass = 'mx_AppTileBody' + (this.props.miniMode ? '_mini ' : ' '); + const appTileBodyStyles = {}; + if (this.props.pointerEvents) { + appTileBodyStyles['pointer-events'] = this.props.pointerEvents; + } const loadingElement = (
    @@ -338,7 +344,7 @@ export default class AppTile extends React.Component { // only possible for room widgets, can assert this.props.room here const isEncrypted = MatrixClientPeg.get().isRoomEncrypted(this.props.room.roomId); appTileBody = ( -
    +
    +
    { loadingElement }
    ); } else { if (this.isMixedContent()) { appTileBody = ( -
    +
    ); } else { appTileBody = ( -
    +
    { this.state.loading && loadingElement }