diff --git a/.eslintignore.errorfiles b/.eslintignore.errorfiles index 1faffbbdf7..aa2a6b7f0b 100644 --- a/.eslintignore.errorfiles +++ b/.eslintignore.errorfiles @@ -1,51 +1,31 @@ # autogenerated file: run scripts/generate-eslint-error-ignore-file to update. -src/components/structures/RoomDirectory.js -src/components/structures/RoomStatusBar.js -src/components/structures/RoomView.js -src/components/structures/ScrollPanel.js -src/components/structures/SearchBox.js -src/components/structures/UploadBar.js -src/components/views/avatars/MemberAvatar.js -src/components/views/create_room/RoomAlias.js -src/components/views/dialogs/SetPasswordDialog.js -src/components/views/elements/AddressSelector.js -src/components/views/elements/DirectorySearchBox.js -src/components/views/elements/MemberEventListSummary.js -src/components/views/elements/UserSelector.js -src/components/views/globals/NewVersionBar.js -src/components/views/messages/MFileBody.js -src/components/views/messages/TextualBody.js -src/components/views/room_settings/ColorSettings.js -src/components/views/rooms/Autocomplete.js -src/components/views/rooms/AuxPanel.js -src/components/views/rooms/LinkPreviewWidget.js -src/components/views/rooms/MemberInfo.js -src/components/views/rooms/MemberList.js -src/components/views/rooms/RoomList.js -src/components/views/rooms/RoomPreviewBar.js -src/components/views/rooms/SearchResultTile.js -src/components/views/settings/ChangeAvatar.js -src/components/views/settings/ChangePassword.js -src/components/views/settings/DevicesPanel.js -src/components/views/settings/Notifications.js -src/HtmlUtils.js src/ImageUtils.js src/Markdown.js -src/notifications/ContentRules.js -src/notifications/PushRuleVectorState.js -src/PlatformPeg.js -src/rageshake/rageshake.js -src/ratelimitedfunc.js src/Rooms.js src/Unread.js +src/Velociraptor.js +src/components/structures/RoomDirectory.js +src/components/structures/ScrollPanel.js +src/components/structures/UploadBar.js +src/components/views/elements/AddressSelector.js +src/components/views/elements/DirectorySearchBox.js +src/components/views/messages/MFileBody.js +src/components/views/messages/TextualBody.js +src/components/views/rooms/AuxPanel.js +src/components/views/rooms/LinkPreviewWidget.js +src/components/views/rooms/MemberList.js +src/components/views/rooms/RoomPreviewBar.js +src/components/views/settings/ChangeAvatar.js +src/components/views/settings/DevicesPanel.js +src/components/views/settings/Notifications.js +src/rageshake/rageshake.js +src/ratelimitedfunc.js +src/utils/DMRoomMap.js src/utils/DecryptFile.js src/utils/DirectoryUtils.js -src/utils/DMRoomMap.js -src/utils/FormattingUtils.js src/utils/MultiInviter.js src/utils/Receipt.js -src/Velociraptor.js test/components/structures/MessagePanel-test.js test/components/views/dialogs/InteractiveAuthDialog-test.js test/mock-clock.js diff --git a/.eslintrc.js b/.eslintrc.js index fc82e75ce2..bc2a142c2d 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -19,7 +19,7 @@ module.exports = { }, overrides: [{ - "files": ["src/**/*.{ts, tsx}"], + "files": ["src/**/*.{ts,tsx}"], "extends": ["matrix-org/ts"], "rules": { // We disable this while we're transitioning diff --git a/CHANGELOG.md b/CHANGELOG.md index 29cbb040f4..4a22954c3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,381 @@ +Changes in [3.6.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.6.0) (2020-10-12) +=================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.6.0-rc.1...v3.6.0) + + * Upgrade JS SDK to 8.5.0 + * [Release] Fix templating for v1 jitsi widgets + [\#5306](https://github.com/matrix-org/matrix-react-sdk/pull/5306) + * [Release] Use new preparing event for widget communications + [\#5304](https://github.com/matrix-org/matrix-react-sdk/pull/5304) + +Changes in [3.6.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.6.0-rc.1) (2020-10-07) +============================================================================================================= +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.5.0...v3.6.0-rc.1) + + * Upgrade JS SDK to 8.5.0-rc.1 + * Update from Weblate + [\#5297](https://github.com/matrix-org/matrix-react-sdk/pull/5297) + * Fix edited replies being wrongly treated as big emoji + [\#5295](https://github.com/matrix-org/matrix-react-sdk/pull/5295) + * Fix StopGapWidget infinitely recursing + [\#5294](https://github.com/matrix-org/matrix-react-sdk/pull/5294) + * Fix editing and redactions not updating the Reply Thread + [\#5281](https://github.com/matrix-org/matrix-react-sdk/pull/5281) + * Hide Jump to Read Receipt button for users who have not yet sent an RR + [\#5282](https://github.com/matrix-org/matrix-react-sdk/pull/5282) + * fix img tags not always being rendered correctly + [\#5279](https://github.com/matrix-org/matrix-react-sdk/pull/5279) + * Hopefully fix righhtpanel crash + [\#5293](https://github.com/matrix-org/matrix-react-sdk/pull/5293) + * Fix naive pinning limit and app tile widgetMessaging NPE + [\#5283](https://github.com/matrix-org/matrix-react-sdk/pull/5283) + * Show server errors from saving profile settings + [\#5272](https://github.com/matrix-org/matrix-react-sdk/pull/5272) + * Update copy for `redact` permission + [\#5273](https://github.com/matrix-org/matrix-react-sdk/pull/5273) + * Remove width limit on widgets + [\#5265](https://github.com/matrix-org/matrix-react-sdk/pull/5265) + * Fix call container avatar initial centering + [\#5280](https://github.com/matrix-org/matrix-react-sdk/pull/5280) + * Fix right panel for peeking rooms + [\#5268](https://github.com/matrix-org/matrix-react-sdk/pull/5268) + * Add support for dehydrated devices + [\#5239](https://github.com/matrix-org/matrix-react-sdk/pull/5239) + * Use Own Profile Store for the Profile Settings + [\#5277](https://github.com/matrix-org/matrix-react-sdk/pull/5277) + * null-guard defaultAvatarUrlForString + [\#5270](https://github.com/matrix-org/matrix-react-sdk/pull/5270) + * Choose first result on enter in the emoji picker + [\#5257](https://github.com/matrix-org/matrix-react-sdk/pull/5257) + * Fix room directory clipping links in the room's topic + [\#5276](https://github.com/matrix-org/matrix-react-sdk/pull/5276) + * Decorate failed e2ee downgrade attempts better + [\#5278](https://github.com/matrix-org/matrix-react-sdk/pull/5278) + * MELS use latest avatar rather than the first avatar + [\#5262](https://github.com/matrix-org/matrix-react-sdk/pull/5262) + * Fix Encryption Panel close button clashing with Base Card + [\#5261](https://github.com/matrix-org/matrix-react-sdk/pull/5261) + * Wrap canEncryptToAllUsers in a try/catch to handle server errors + [\#5275](https://github.com/matrix-org/matrix-react-sdk/pull/5275) + * Fix conditional on communities prototype room creation dialog + [\#5274](https://github.com/matrix-org/matrix-react-sdk/pull/5274) + * Fix ensureDmExists for encryption detection + [\#5271](https://github.com/matrix-org/matrix-react-sdk/pull/5271) + * Switch to using the Widget API SDK for widget messaging + [\#5171](https://github.com/matrix-org/matrix-react-sdk/pull/5171) + * Ensure package links exist when releasing + [\#5269](https://github.com/matrix-org/matrix-react-sdk/pull/5269) + * Fix the call preview when not in same room as the call + [\#5267](https://github.com/matrix-org/matrix-react-sdk/pull/5267) + * Make the hangup button do things for conference calls + [\#5223](https://github.com/matrix-org/matrix-react-sdk/pull/5223) + * Render Jitsi widget state events in a more obvious way + [\#5222](https://github.com/matrix-org/matrix-react-sdk/pull/5222) + * Make the PIP Jitsi look and feel like the 1:1 PIP + [\#5226](https://github.com/matrix-org/matrix-react-sdk/pull/5226) + * Trim range when formatting so that it excludes leading/trailing spaces + [\#5263](https://github.com/matrix-org/matrix-react-sdk/pull/5263) + * Fix button label on the Set Password Dialog + [\#5264](https://github.com/matrix-org/matrix-react-sdk/pull/5264) + * fix link to classic yarn's `yarn link` + [\#5259](https://github.com/matrix-org/matrix-react-sdk/pull/5259) + * Fix index mismatch between username colors styles and custom theming + [\#5256](https://github.com/matrix-org/matrix-react-sdk/pull/5256) + * Disable autocompletion on security key input during login + [\#5258](https://github.com/matrix-org/matrix-react-sdk/pull/5258) + * fix uninitialised state and eventlistener leak in RoomUpgradeWarningBar + [\#5255](https://github.com/matrix-org/matrix-react-sdk/pull/5255) + * Only set title when it changes + [\#5254](https://github.com/matrix-org/matrix-react-sdk/pull/5254) + * Convert CallHandler to typescript + [\#5248](https://github.com/matrix-org/matrix-react-sdk/pull/5248) + * Retry loading i18n language if it fails + [\#5209](https://github.com/matrix-org/matrix-react-sdk/pull/5209) + * Rework profile area for user and room settings to be more clear + [\#5243](https://github.com/matrix-org/matrix-react-sdk/pull/5243) + * Validation improve pattern for derived data + [\#5241](https://github.com/matrix-org/matrix-react-sdk/pull/5241) + +Changes in [3.5.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.5.0) (2020-09-28) +=================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.5.0-rc.1...v3.5.0) + + * Upgrade JS SDK to 8.4.1 + +Changes in [3.5.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.5.0-rc.1) (2020-09-23) +============================================================================================================= +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.4.1...v3.5.0-rc.1) + + * Upgrade JS SDK to 8.4.0-rc.1 + * Update from Weblate + [\#5246](https://github.com/matrix-org/matrix-react-sdk/pull/5246) + * Upgrade sanitize-html, set nesting limit + [\#5245](https://github.com/matrix-org/matrix-react-sdk/pull/5245) + * Add a note to use the desktop builds when seshat isn't available + [\#5225](https://github.com/matrix-org/matrix-react-sdk/pull/5225) + * Add some permission checks to the communities v2 prototype + [\#5240](https://github.com/matrix-org/matrix-react-sdk/pull/5240) + * Support HS-preferred Secure Backup setup methods + [\#5242](https://github.com/matrix-org/matrix-react-sdk/pull/5242) + * Only show User Info verify button if the other user has e2ee devices + [\#5234](https://github.com/matrix-org/matrix-react-sdk/pull/5234) + * Fix New Room List arrow key management + [\#5237](https://github.com/matrix-org/matrix-react-sdk/pull/5237) + * Fix Room Directory View & Preview actions for federated joins + [\#5235](https://github.com/matrix-org/matrix-react-sdk/pull/5235) + * Add a UI feature to disable advanced encryption options + [\#5238](https://github.com/matrix-org/matrix-react-sdk/pull/5238) + * UI Feature Flag: Communities + [\#5216](https://github.com/matrix-org/matrix-react-sdk/pull/5216) + * Rename apps back to widgets + [\#5236](https://github.com/matrix-org/matrix-react-sdk/pull/5236) + * Adjust layout and formatting of notifications / files cards + [\#5229](https://github.com/matrix-org/matrix-react-sdk/pull/5229) + * Fix Search Results Tile undefined variable access regression + [\#5232](https://github.com/matrix-org/matrix-react-sdk/pull/5232) + * Fix Cmd/Ctrl+Shift+U for File Upload + [\#5233](https://github.com/matrix-org/matrix-react-sdk/pull/5233) + * Disable the e2ee toggle when creating a room on a server with forced e2e + [\#5231](https://github.com/matrix-org/matrix-react-sdk/pull/5231) + * UI Feature Flag: Disable advanced options and tidy up some copy + [\#5215](https://github.com/matrix-org/matrix-react-sdk/pull/5215) + * UI Feature Flag: 3PIDs + [\#5228](https://github.com/matrix-org/matrix-react-sdk/pull/5228) + * Defer encryption setup until first E2EE room + [\#5219](https://github.com/matrix-org/matrix-react-sdk/pull/5219) + * Tidy devDeps, all the webpack stuff lives in the layer above + [\#5179](https://github.com/matrix-org/matrix-react-sdk/pull/5179) + * UI Feature Flag: Hide flair + [\#5214](https://github.com/matrix-org/matrix-react-sdk/pull/5214) + * UI Feature Flag: Identity server + [\#5218](https://github.com/matrix-org/matrix-react-sdk/pull/5218) + * UI Feature Flag: Share dialog QR code and social icons + [\#5221](https://github.com/matrix-org/matrix-react-sdk/pull/5221) + * UI Feature Flag: Registration, Password Reset, Deactivate + [\#5227](https://github.com/matrix-org/matrix-react-sdk/pull/5227) + * Retry joinRoom up to 5 times in the case of a 504 GATEWAY TIMEOUT + [\#5204](https://github.com/matrix-org/matrix-react-sdk/pull/5204) + * UI Feature Flag: Disable VoIP + [\#5217](https://github.com/matrix-org/matrix-react-sdk/pull/5217) + * Fix setState() usage in the constructor of RoomDirectory + [\#5224](https://github.com/matrix-org/matrix-react-sdk/pull/5224) + * Hide Analytics sections if piwik config is not provided + [\#5211](https://github.com/matrix-org/matrix-react-sdk/pull/5211) + * UI Feature Flag: Disable feedback button + [\#5213](https://github.com/matrix-org/matrix-react-sdk/pull/5213) + * Clean up UserInfo to not show a blank Power Selector for users not in room + [\#5220](https://github.com/matrix-org/matrix-react-sdk/pull/5220) + * Also hide bug reporting prompts from the Error Boundaries + [\#5212](https://github.com/matrix-org/matrix-react-sdk/pull/5212) + * Tactical improvements to 3PID invites + [\#5201](https://github.com/matrix-org/matrix-react-sdk/pull/5201) + * If no bug_report_endpoint_url, hide rageshaking from the App + [\#5210](https://github.com/matrix-org/matrix-react-sdk/pull/5210) + * Introduce a concept of UI features, using it for URL previews at first + [\#5208](https://github.com/matrix-org/matrix-react-sdk/pull/5208) + * Remove defunct "always show encryption icons" setting + [\#5207](https://github.com/matrix-org/matrix-react-sdk/pull/5207) + * Don't show Notifications Prompt Toast if user has master rule enabled + [\#5203](https://github.com/matrix-org/matrix-react-sdk/pull/5203) + * Fix Bridges tab crashing when the room does not have bridges + [\#5206](https://github.com/matrix-org/matrix-react-sdk/pull/5206) + * Don't count widgets which no longer exist towards pinned count + [\#5202](https://github.com/matrix-org/matrix-react-sdk/pull/5202) + * Fix crashes with cannot read isResizing of undefined + [\#5205](https://github.com/matrix-org/matrix-react-sdk/pull/5205) + * Prompt to remove the jitsi widget when pressing the call button + [\#5193](https://github.com/matrix-org/matrix-react-sdk/pull/5193) + * Show verification status in the room summary card + [\#5195](https://github.com/matrix-org/matrix-react-sdk/pull/5195) + * Fix user info scrolling in new card view + [\#5198](https://github.com/matrix-org/matrix-react-sdk/pull/5198) + * Fix sticker picker height + [\#5197](https://github.com/matrix-org/matrix-react-sdk/pull/5197) + * Call jitsi widgets 'group calls' + [\#5191](https://github.com/matrix-org/matrix-react-sdk/pull/5191) + * Don't show 'unpin' for persistent widgets + [\#5194](https://github.com/matrix-org/matrix-react-sdk/pull/5194) + * Split up cross-signing and secure backup settings + [\#5182](https://github.com/matrix-org/matrix-react-sdk/pull/5182) + * Fix onNewScreen to use replace when going from roomId->roomAlias + [\#5185](https://github.com/matrix-org/matrix-react-sdk/pull/5185) + * bring back 1.2M style badge counts rather than 99+ + [\#5192](https://github.com/matrix-org/matrix-react-sdk/pull/5192) + * Run the rageshake command through the bug report dialog + [\#5189](https://github.com/matrix-org/matrix-react-sdk/pull/5189) + * Account for via in pill matching regex + [\#5188](https://github.com/matrix-org/matrix-react-sdk/pull/5188) + * Remove now-unused create-react-class from lockfile + [\#5187](https://github.com/matrix-org/matrix-react-sdk/pull/5187) + * Fixed 1px jump upwards + [\#5163](https://github.com/matrix-org/matrix-react-sdk/pull/5163) + * Always allow widgets when using the local version + [\#5184](https://github.com/matrix-org/matrix-react-sdk/pull/5184) + * Migrate RoomView and RoomContext to Typescript + [\#5175](https://github.com/matrix-org/matrix-react-sdk/pull/5175) + +Changes in [3.4.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.4.1) (2020-09-14) +=================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.4.0...v3.4.1) + + * Don't count widgets which no longer exist towards pinned count + [\#5202](https://github.com/matrix-org/matrix-react-sdk/pull/5202) + * Fix crashes with cannot read isResizing of undefined + [\#5205](https://github.com/matrix-org/matrix-react-sdk/pull/5205) + +Changes in [3.4.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.4.0) (2020-09-14) +=================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.4.0-rc.1...v3.4.0) + + * Upgrade to JS SDK 8.3.0 + * [Release] Show verification status in the room summary card + [\#5196](https://github.com/matrix-org/matrix-react-sdk/pull/5196) + * Fix user info scrolling in new card view + [\#5200](https://github.com/matrix-org/matrix-react-sdk/pull/5200) + * Fix sticker picker height + [\#5199](https://github.com/matrix-org/matrix-react-sdk/pull/5199) + * [Release] Account for via in pill matching regex + [\#5190](https://github.com/matrix-org/matrix-react-sdk/pull/5190) + +Changes in [3.4.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.4.0-rc.1) (2020-09-09) +============================================================================================================= +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.3.0...v3.4.0-rc.1) + + * Upgrade to JS SDK 8.3.0-rc.1 + * Update from Weblate + [\#5183](https://github.com/matrix-org/matrix-react-sdk/pull/5183) + * Right Panel Room Summary and Widgets + [\#5167](https://github.com/matrix-org/matrix-react-sdk/pull/5167) + * null-guard roomId in RightPanel and pass Room to UserView + [\#5180](https://github.com/matrix-org/matrix-react-sdk/pull/5180) + * Fix create-react-class regression. + [\#5178](https://github.com/matrix-org/matrix-react-sdk/pull/5178) + * Fix WatchManager for global room watchers and tidy widget code a little + [\#5176](https://github.com/matrix-org/matrix-react-sdk/pull/5176) + * Fix permalink local linkification to not strip via servers + [\#5174](https://github.com/matrix-org/matrix-react-sdk/pull/5174) + * Support creation of Jitsi widgets with "openidtoken-jwt" auth + [\#5173](https://github.com/matrix-org/matrix-react-sdk/pull/5173) + * Fix create-react-class regression. + [\#5177](https://github.com/matrix-org/matrix-react-sdk/pull/5177) + * Update openid_credentials Widget API action for MSC1960 updates + [\#5172](https://github.com/matrix-org/matrix-react-sdk/pull/5172) + * Allow persistent resizing of the widget app drawer + [\#5138](https://github.com/matrix-org/matrix-react-sdk/pull/5138) + * add lenny face command + [\#5158](https://github.com/matrix-org/matrix-react-sdk/pull/5158) + * Prep work for Settings changes with cross-signing deferral + [\#5169](https://github.com/matrix-org/matrix-react-sdk/pull/5169) + * Small code clean ups and tweaks + [\#5168](https://github.com/matrix-org/matrix-react-sdk/pull/5168) + * Fix soft crash from TruncatedList in the createReactClass conversion + [\#5170](https://github.com/matrix-org/matrix-react-sdk/pull/5170) + * Remove create-react-class + [\#5157](https://github.com/matrix-org/matrix-react-sdk/pull/5157) + * Consolidate Lodash files in bundle + [\#5162](https://github.com/matrix-org/matrix-react-sdk/pull/5162) + * Communities v2 prototype: "In community" view + [\#5161](https://github.com/matrix-org/matrix-react-sdk/pull/5161) + * Respect user preference for whether pills should have an avatar or not + [\#5165](https://github.com/matrix-org/matrix-react-sdk/pull/5165) + * Communities v2 prototype: DM copy updates + [\#5153](https://github.com/matrix-org/matrix-react-sdk/pull/5153) + * Only wait for public keys during verification + [\#5164](https://github.com/matrix-org/matrix-react-sdk/pull/5164) + * Fix eslint ts override tsx matching and delint + [\#5155](https://github.com/matrix-org/matrix-react-sdk/pull/5155) + * Fix react error about functional components can't take refs + [\#5159](https://github.com/matrix-org/matrix-react-sdk/pull/5159) + * Remove redundant components and devDependencies + [\#5156](https://github.com/matrix-org/matrix-react-sdk/pull/5156) + * Add display-capture to iframe allow for widgets + [\#5154](https://github.com/matrix-org/matrix-react-sdk/pull/5154) + * Update create room dialog copy & community prototype home icon + [\#5151](https://github.com/matrix-org/matrix-react-sdk/pull/5151) + * Migrate to new, separate APIs for cross-signing and secret storage + [\#5149](https://github.com/matrix-org/matrix-react-sdk/pull/5149) + * Fix clicking the background of the tag panel not clearing the filter + [\#5152](https://github.com/matrix-org/matrix-react-sdk/pull/5152) + * Communities v2 prototype: Associate created rooms with the selected + community + [\#5147](https://github.com/matrix-org/matrix-react-sdk/pull/5147) + * Communities v2 prototype: Tag panel selection changes + [\#5145](https://github.com/matrix-org/matrix-react-sdk/pull/5145) + * Communities v2 prototype: Create community flow + [\#5144](https://github.com/matrix-org/matrix-react-sdk/pull/5144) + * Communities v2 prototype: Override invite aesthetics for community-as-room + invites + [\#5143](https://github.com/matrix-org/matrix-react-sdk/pull/5143) + +Changes in [3.3.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.3.0) (2020-09-01) +=================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.3.0-rc.1...v3.3.0) + + * Upgrade to JS SDK 8.2.0 + +Changes in [3.3.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.3.0-rc.1) (2020-08-26) +============================================================================================================= +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.2.0...v3.3.0-rc.1) + + * Upgrade to JS SDK 8.2.0-rc.1 + * Update from Weblate + [\#5146](https://github.com/matrix-org/matrix-react-sdk/pull/5146) + * BaseAvatar avoid initial render with default avatar + [\#5142](https://github.com/matrix-org/matrix-react-sdk/pull/5142) + * Enforce Secure Backup completion when requested by HS + [\#5130](https://github.com/matrix-org/matrix-react-sdk/pull/5130) + * Communities v2 prototype: Explore rooms, global state, and default room + [\#5139](https://github.com/matrix-org/matrix-react-sdk/pull/5139) + * Add communities v2 prototyping feature flag + initial tag panel prototypes + [\#5133](https://github.com/matrix-org/matrix-react-sdk/pull/5133) + * Remove some unused components + [\#5134](https://github.com/matrix-org/matrix-react-sdk/pull/5134) + * Allow avatar image view for 1:1 rooms + [\#5137](https://github.com/matrix-org/matrix-react-sdk/pull/5137) + * Send mx_local_settings in rageshake + [\#5136](https://github.com/matrix-org/matrix-react-sdk/pull/5136) + * Run all room leaving behaviour through a single function + [\#5132](https://github.com/matrix-org/matrix-react-sdk/pull/5132) + * Add clarifying comment in media device selection + [\#5131](https://github.com/matrix-org/matrix-react-sdk/pull/5131) + * Settings v3: Feature flag changes + [\#5124](https://github.com/matrix-org/matrix-react-sdk/pull/5124) + * Clear url previews if they all get edited out of the event + [\#5129](https://github.com/matrix-org/matrix-react-sdk/pull/5129) + * Consider tab completions as modifications for editing purposes to unlock + sending + [\#5128](https://github.com/matrix-org/matrix-react-sdk/pull/5128) + * Use matrix-doc for SAS emoji translations + [\#5125](https://github.com/matrix-org/matrix-react-sdk/pull/5125) + * Add a rageshake function to download the logs locally + [\#3849](https://github.com/matrix-org/matrix-react-sdk/pull/3849) + * Room List filtering visual tweaks + [\#5123](https://github.com/matrix-org/matrix-react-sdk/pull/5123) + * Make reply preview not an overlay so you can see new messages + [\#5072](https://github.com/matrix-org/matrix-react-sdk/pull/5072) + * Allow room tile context menu when minimized using right click + [\#5113](https://github.com/matrix-org/matrix-react-sdk/pull/5113) + * Add null guard to group inviter for corrupted groups + [\#5121](https://github.com/matrix-org/matrix-react-sdk/pull/5121) + * Room List styling tweaks + [\#5118](https://github.com/matrix-org/matrix-react-sdk/pull/5118) + * Fix corner rounding on images not always affecting right side + [\#5120](https://github.com/matrix-org/matrix-react-sdk/pull/5120) + * Change add room action for rooms to context menu + [\#5108](https://github.com/matrix-org/matrix-react-sdk/pull/5108) + * Switch out the globe icon and colour it depending on theme + [\#5106](https://github.com/matrix-org/matrix-react-sdk/pull/5106) + * Message Action Bar watch for event send changes + [\#5115](https://github.com/matrix-org/matrix-react-sdk/pull/5115) + * Put message previews for Emoji behind Labs + [\#5110](https://github.com/matrix-org/matrix-react-sdk/pull/5110) + * Fix styling for selected community marker + [\#5107](https://github.com/matrix-org/matrix-react-sdk/pull/5107) + * Fix action bar safe area regression + [\#5111](https://github.com/matrix-org/matrix-react-sdk/pull/5111) + * Fix /op slash command + [\#5109](https://github.com/matrix-org/matrix-react-sdk/pull/5109) + Changes in [3.2.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.2.0) (2020-08-17) =================================================================================================== [Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.2.0-rc.1...v3.2.0) diff --git a/README.md b/README.md index e468d272d0..4db02418ba 100644 --- a/README.md +++ b/README.md @@ -160,8 +160,8 @@ yarn link matrix-js-sdk yarn install ``` -See the [help for `yarn link`](https://yarnpkg.com/docs/cli/link) for more -details about this. +See the [help for `yarn link`](https://classic.yarnpkg.com/docs/cli/link) for +more details about this. Running tests ============= diff --git a/__mocks__/browser-request.js b/__mocks__/browser-request.js index 7d231fb9db..4c59e8a43a 100644 --- a/__mocks__/browser-request.js +++ b/__mocks__/browser-request.js @@ -1,5 +1,10 @@ const en = require("../src/i18n/strings/en_EN"); +const de = require("../src/i18n/strings/de_DE"); +// Mock the browser-request for the languageHandler tests to return +// Fake languages.json containing references to en_EN and de_DE +// en_EN.json +// de_DE.json module.exports = jest.fn((opts, cb) => { const url = opts.url || opts.uri; if (url && url.endsWith("languages.json")) { @@ -8,9 +13,15 @@ module.exports = jest.fn((opts, cb) => { "fileName": "en_EN.json", "label": "English", }, + "de": { + "fileName": "de_DE.json", + "label": "German", + }, })); } else if (url && url.endsWith("en_EN.json")) { cb(undefined, {status: 200}, JSON.stringify(en)); + } else if (url && url.endsWith("de_DE.json")) { + cb(undefined, {status: 200}, JSON.stringify(de)); } else { cb(true, {status: 404}, ""); } diff --git a/__test-utils__/environment.js b/__test-utils__/environment.js new file mode 100644 index 0000000000..9870c133a2 --- /dev/null +++ b/__test-utils__/environment.js @@ -0,0 +1,17 @@ +const BaseEnvironment = require("jest-environment-jsdom-sixteen"); + +class Environment extends BaseEnvironment { + constructor(config, options) { + super(Object.assign({}, config, { + globals: Object.assign({}, config.globals, { + // Explicitly specify the correct globals to workaround Jest bug + // https://github.com/facebook/jest/issues/7780 + Uint32Array: Uint32Array, + Uint8Array: Uint8Array, + ArrayBuffer: ArrayBuffer, + }), + }), options); + } +} + +module.exports = Environment; diff --git a/docs/settings.md b/docs/settings.md index 4172c72c15..891877a57a 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -120,6 +120,18 @@ Call `SettingsStore.getValue()` as you would for any other setting. Call `SettingsStore.setValue("feature_name", null, SettingLevel.DEVICE, true)`. +### A note on UI features + +UI features are a different concept to plain features. Instead of being representative of unstable or +unpredicatable behaviour, they are logical chunks of UI which can be disabled by deployments for ease +of understanding with users. They are simply represented as boring settings with a convention of being +named as `UIFeature.$location` where `$location` is a rough descriptor of what is toggled, such as +`URLPreviews` or `Communities`. + +UI features also tend to have their own setting controller (see below) to manipulate settings which might +be affected by the UI feature being disabled. For example, if URL previews are disabled as a UI feature +then the URL preview options will use the `UIFeatureController` to ensure they remain disabled while the +UI feature is disabled. ## Setting controllers @@ -226,4 +238,3 @@ In practice, handlers which rely on remote changes (account data, room events, e generalized `WatchManager` - a class specifically designed to deduplicate the logic of managing watchers. The handlers which are localized to the local client (device) generally just trigger the `WatchManager` when they manipulate the setting themselves as there's nothing to really 'watch'. - diff --git a/package.json b/package.json index ab71f68b08..3f073ce59c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-react-sdk", - "version": "3.2.0", + "version": "3.6.0", "description": "SDK for matrix.org using React", "author": "matrix.org", "repository": { @@ -61,7 +61,6 @@ "classnames": "^2.2.6", "commonmark": "^0.29.1", "counterpart": "^0.18.6", - "create-react-class": "^15.6.3", "diff-dom": "^4.1.6", "diff-match-patch": "^1.0.5", "emojibase-data": "^5.0.1", @@ -80,6 +79,7 @@ "linkifyjs": "^2.1.9", "lodash": "^4.17.19", "matrix-js-sdk": "github:matrix-org/matrix-js-sdk#develop", + "matrix-widget-api": "^0.1.0-beta.3", "minimist": "^1.2.5", "pako": "^1.0.11", "parse5": "^5.1.1", @@ -95,7 +95,8 @@ "react-focus-lock": "^2.4.1", "react-transition-group": "^4.4.1", "resize-observer-polyfill": "^1.5.1", - "sanitize-html": "^1.27.1", + "rfc4648": "^1.4.0", + "sanitize-html": "github:apostrophecms/sanitize-html#3c7f93f2058f696f5359e3e58d464161647226db", "tar-js": "^0.3.0", "text-encoding-utf-8": "^1.0.2", "url": "^0.11.0", @@ -120,7 +121,7 @@ "@babel/preset-typescript": "^7.10.4", "@babel/register": "^7.10.5", "@babel/traverse": "^7.11.0", - "@peculiar/webcrypto": "^1.1.2", + "@peculiar/webcrypto": "^1.1.3", "@types/classnames": "^2.2.10", "@types/counterpart": "^0.18.1", "@types/flux": "^3.1.9", @@ -149,25 +150,23 @@ "eslint-plugin-flowtype": "^2.50.3", "eslint-plugin-react": "^7.20.3", "eslint-plugin-react-hooks": "^2.5.1", - "file-loader": "^3.0.1", "glob": "^5.0.15", - "jest": "^24.9.0", - "jest-canvas-mock": "^2.2.0", + "jest": "^26.5.2", + "jest-canvas-mock": "^2.3.0", + "jest-environment-jsdom-sixteen": "^1.0.3", "lolex": "^5.1.2", "matrix-mock-request": "^1.2.3", "matrix-react-test-utils": "^0.2.2", "react-test-renderer": "^16.13.1", "rimraf": "^2.7.1", - "source-map-loader": "^0.2.4", "stylelint": "^9.10.1", "stylelint-config-standard": "^18.3.0", "stylelint-scss": "^3.18.0", "typescript": "^3.9.7", - "walk": "^2.3.14", - "webpack": "^4.43.0", - "webpack-cli": "^3.3.12" + "walk": "^2.3.14" }, "jest": { + "testEnvironment": "./__test-utils__/environment.js", "testMatch": [ "/test/**/*-test.js" ], diff --git a/release.sh b/release.sh index 23b8822041..e2cefcbe74 100755 --- a/release.sh +++ b/release.sh @@ -9,6 +9,9 @@ set -e cd `dirname $0` +# This link seems to get eaten by the release process, so ensure it exists. +yarn link matrix-js-sdk + for i in matrix-js-sdk do echo "Checking version of $i..." diff --git a/res/css/_common.scss b/res/css/_common.scss index a22d77f3d3..666129af34 100644 --- a/res/css/_common.scss +++ b/res/css/_common.scss @@ -18,6 +18,8 @@ limitations under the License. @import "./_font-sizes.scss"; +$hover-transition: 0.08s cubic-bezier(.46, .03, .52, .96); // quadratic + :root { font-size: 10px; } @@ -206,12 +208,6 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus { border: 0; } -/* applied to side-panels and messagepanel when in RoomSettings */ -.mx_fadable { - opacity: 1; - transition: opacity 0.2s ease-in-out; -} - // These are magic constants which are excluded from tinting, to let themes // (which only have CSS, unlike skins) tell the app what their non-tinted // colourscheme is by inspecting the stylesheet DOM. @@ -260,7 +256,7 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus { font-weight: 300; font-size: $font-15px; position: relative; - padding: 25px 30px 30px 30px; + padding: 24px; max-height: 80%; box-shadow: 2px 15px 30px 0 $dialog-shadow-color; border-radius: 8px; diff --git a/res/css/_components.scss b/res/css/_components.scss index 5145133127..18cf0c52d8 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -26,7 +26,7 @@ @import "./structures/_ScrollPanel.scss"; @import "./structures/_SearchBox.scss"; @import "./structures/_TabbedView.scss"; -@import "./structures/_TagPanel.scss"; +@import "./structures/_GroupFilterPanel.scss"; @import "./structures/_ToastContainer.scss"; @import "./structures/_UploadBar.scss"; @import "./structures/_UserMenu.scss"; @@ -61,11 +61,14 @@ @import "./views/dialogs/_BugReportDialog.scss"; @import "./views/dialogs/_ChangelogDialog.scss"; @import "./views/dialogs/_ChatCreateOrReuseChatDialog.scss"; +@import "./views/dialogs/_CommunityPrototypeInviteDialog.scss"; @import "./views/dialogs/_ConfirmUserActionDialog.scss"; +@import "./views/dialogs/_CreateCommunityPrototypeDialog.scss"; @import "./views/dialogs/_CreateGroupDialog.scss"; @import "./views/dialogs/_CreateRoomDialog.scss"; @import "./views/dialogs/_DeactivateAccountDialog.scss"; @import "./views/dialogs/_DevtoolsDialog.scss"; +@import "./views/dialogs/_EditCommunityPrototypeDialog.scss"; @import "./views/dialogs/_GroupAddressPicker.scss"; @import "./views/dialogs/_IncomingSasDialog.scss"; @import "./views/dialogs/_InviteDialog.scss"; @@ -78,8 +81,6 @@ @import "./views/dialogs/_RoomUpgradeWarningDialog.scss"; @import "./views/dialogs/_ServerOfflineDialog.scss"; @import "./views/dialogs/_SetEmailDialog.scss"; -@import "./views/dialogs/_SetMxIdDialog.scss"; -@import "./views/dialogs/_SetPasswordDialog.scss"; @import "./views/dialogs/_SettingsDialog.scss"; @import "./views/dialogs/_ShareDialog.scss"; @import "./views/dialogs/_SlashCommandHelpDialog.scss"; @@ -88,15 +89,17 @@ @import "./views/dialogs/_UploadConfirmDialog.scss"; @import "./views/dialogs/_UserSettingsDialog.scss"; @import "./views/dialogs/_WidgetOpenIDPermissionsDialog.scss"; -@import "./views/dialogs/keybackup/_CreateKeyBackupDialog.scss"; -@import "./views/dialogs/keybackup/_KeyBackupFailedDialog.scss"; -@import "./views/dialogs/keybackup/_RestoreKeyBackupDialog.scss"; -@import "./views/dialogs/secretstorage/_AccessSecretStorageDialog.scss"; -@import "./views/dialogs/secretstorage/_CreateSecretStorageDialog.scss"; +@import "./views/dialogs/security/_AccessSecretStorageDialog.scss"; +@import "./views/dialogs/security/_CreateCrossSigningDialog.scss"; +@import "./views/dialogs/security/_CreateKeyBackupDialog.scss"; +@import "./views/dialogs/security/_CreateSecretStorageDialog.scss"; +@import "./views/dialogs/security/_KeyBackupFailedDialog.scss"; +@import "./views/dialogs/security/_RestoreKeyBackupDialog.scss"; @import "./views/directory/_NetworkDropdown.scss"; @import "./views/elements/_AccessibleButton.scss"; @import "./views/elements/_AddressSelector.scss"; @import "./views/elements/_AddressTile.scss"; +@import "./views/elements/_DesktopBuildsNotice.scss"; @import "./views/elements/_DirectorySearchBox.scss"; @import "./views/elements/_Dropdown.scss"; @import "./views/elements/_EditableItemList.scss"; @@ -106,6 +109,7 @@ @import "./views/elements/_FormButton.scss"; @import "./views/elements/_IconButton.scss"; @import "./views/elements/_ImageView.scss"; +@import "./views/elements/_InfoTooltip.scss"; @import "./views/elements/_InlineSpinner.scss"; @import "./views/elements/_ManageIntegsButton.scss"; @import "./views/elements/_PowerSelector.scss"; @@ -135,6 +139,7 @@ @import "./views/messages/_MEmoteBody.scss"; @import "./views/messages/_MFileBody.scss"; @import "./views/messages/_MImageBody.scss"; +@import "./views/messages/_MJitsiWidgetEvent.scss"; @import "./views/messages/_MNoticeBody.scss"; @import "./views/messages/_MStickerBody.scss"; @import "./views/messages/_MTextBody.scss"; @@ -151,9 +156,12 @@ @import "./views/messages/_UnknownBody.scss"; @import "./views/messages/_ViewSourceEvent.scss"; @import "./views/messages/_common_CryptoEvent.scss"; +@import "./views/right_panel/_BaseCard.scss"; @import "./views/right_panel/_EncryptionInfo.scss"; +@import "./views/right_panel/_RoomSummaryCard.scss"; @import "./views/right_panel/_UserInfo.scss"; @import "./views/right_panel/_VerificationPanel.scss"; +@import "./views/right_panel/_WidgetCard.scss"; @import "./views/room_settings/_AliasSettings.scss"; @import "./views/rooms/_AppsDrawer.scss"; @import "./views/rooms/_Autocomplete.scss"; @@ -180,7 +188,6 @@ @import "./views/rooms/_RoomHeader.scss"; @import "./views/rooms/_RoomList.scss"; @import "./views/rooms/_RoomPreviewBar.scss"; -@import "./views/rooms/_RoomRecoveryReminder.scss"; @import "./views/rooms/_RoomSublist.scss"; @import "./views/rooms/_RoomTile.scss"; @import "./views/rooms/_RoomUpgradeWarningBar.scss"; @@ -195,10 +202,10 @@ @import "./views/settings/_E2eAdvancedPanel.scss"; @import "./views/settings/_EmailAddresses.scss"; @import "./views/settings/_IntegrationManager.scss"; -@import "./views/settings/_KeyBackupPanel.scss"; @import "./views/settings/_Notifications.scss"; @import "./views/settings/_PhoneNumbers.scss"; @import "./views/settings/_ProfileSettings.scss"; +@import "./views/settings/_SecureBackupPanel.scss"; @import "./views/settings/_SetIdServer.scss"; @import "./views/settings/_SetIntegrationManager.scss"; @import "./views/settings/_UpdateCheckButton.scss"; diff --git a/res/css/structures/_CustomRoomTagPanel.scss b/res/css/structures/_CustomRoomTagPanel.scss index 3feb2565be..96813cccea 100644 --- a/res/css/structures/_CustomRoomTagPanel.scss +++ b/res/css/structures/_CustomRoomTagPanel.scss @@ -22,7 +22,7 @@ limitations under the License. } .mx_CustomRoomTagPanel { - background-color: $tagpanel-bg-color; + background-color: $groupFilterPanel-bg-color; max-height: 40vh; } diff --git a/res/css/structures/_FilePanel.scss b/res/css/structures/_FilePanel.scss index 21b30d804a..2aa068b674 100644 --- a/res/css/structures/_FilePanel.scss +++ b/res/css/structures/_FilePanel.scss @@ -23,6 +23,13 @@ limitations under the License. .mx_FilePanel .mx_RoomView_messageListWrapper { margin-right: 20px; + flex-direction: row; + align-items: center; + justify-content: center; +} + +.mx_FilePanel .mx_RoomView_MessageList { + width: 100%; } .mx_FilePanel .mx_RoomView_MessageList h2 { diff --git a/res/css/structures/_TagPanel.scss b/res/css/structures/_GroupFilterPanel.scss similarity index 58% rename from res/css/structures/_TagPanel.scss rename to res/css/structures/_GroupFilterPanel.scss index b2d05ad7e6..e5a8ef6df2 100644 --- a/res/css/structures/_TagPanel.scss +++ b/res/css/structures/_GroupFilterPanel.scss @@ -14,9 +14,9 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_TagPanel { +.mx_GroupFilterPanel { flex: 1; - background-color: $tagpanel-bg-color; + background-color: $groupFilterPanel-bg-color; cursor: pointer; display: flex; @@ -26,63 +26,89 @@ limitations under the License. min-height: 0; } -.mx_TagPanel_items_selected { +.mx_GroupFilterPanel_items_selected { cursor: pointer; } -.mx_TagPanel .mx_TagPanel_clearButton_container { - /* Constant height within flex mx_TagPanel */ - height: 70px; - width: 56px; - - flex: none; - - justify-content: center; - align-items: flex-start; - - display: none; -} - -.mx_TagPanel .mx_TagPanel_clearButton object { - /* Same as .mx_SearchBox padding-top */ - margin-top: 24px; - pointer-events: none; -} - -.mx_TagPanel .mx_TagPanel_divider { +.mx_GroupFilterPanel .mx_GroupFilterPanel_divider { height: 0px; - width: 34px; - border-bottom: 1px solid $panel-divider-color; - display: none; + width: 90%; + border: none; + border-bottom: 1px solid $groupFilterPanel-divider-color; } -.mx_TagPanel .mx_TagPanel_scroller { +.mx_GroupFilterPanel .mx_GroupFilterPanel_scroller { flex-grow: 1; width: 100%; } -.mx_TagPanel .mx_TagPanel_tagTileContainer { +.mx_GroupFilterPanel .mx_GroupFilterPanel_tagTileContainer { display: flex; flex-direction: column; align-items: center; padding-top: 6px; } -.mx_TagPanel .mx_TagPanel_tagTileContainer > div { +.mx_GroupFilterPanel .mx_GroupFilterPanel_tagTileContainer > div { margin: 6px 0; } -.mx_TagPanel .mx_TagTile { +.mx_GroupFilterPanel .mx_TagTile { // opacity: 0.5; position: relative; } -.mx_TagPanel .mx_TagTile:focus, -.mx_TagPanel .mx_TagTile:hover, -.mx_TagPanel .mx_TagTile.mx_TagTile_selected { + +.mx_GroupFilterPanel .mx_TagTile.mx_TagTile_prototype { + padding: 3px; +} + +.mx_GroupFilterPanel .mx_TagTile:focus, +.mx_GroupFilterPanel .mx_TagTile:hover, +.mx_GroupFilterPanel .mx_TagTile.mx_TagTile_selected { // opacity: 1; } -.mx_TagPanel .mx_TagTile_plus { +.mx_GroupFilterPanel .mx_TagTile.mx_TagTile_selected_prototype { + background-color: $primary-bg-color; + border-radius: 6px; +} + +.mx_TagTile_selected_prototype { + .mx_TagTile_homeIcon::before { + background-color: $primary-fg-color; // dark-on-light + } +} + +.mx_TagTile:not(.mx_TagTile_selected_prototype) .mx_TagTile_homeIcon { + background-color: $roomheader-addroom-bg-color; + border-radius: 48px; + + &::before { + background-color: $roomheader-addroom-fg-color; + } +} + +.mx_TagTile_homeIcon { + width: 32px; + height: 32px; + position: relative; + + &::before { + mask-image: url('$(res)/img/element-icons/home.svg'); + mask-position: center; + mask-repeat: no-repeat; + mask-size: 21px; + content: ''; + display: inline-block; + width: 32px; + height: 32px; + position: absolute; + top: calc(50% - 16px); + left: calc(50% - 16px); + } +} + +.mx_GroupFilterPanel .mx_TagTile_plus { margin-bottom: 12px; height: 32px; width: 32px; @@ -106,7 +132,7 @@ limitations under the License. } } -.mx_TagPanel .mx_TagTile.mx_TagTile_selected::before { +.mx_GroupFilterPanel .mx_TagTile.mx_TagTile_selected::before { content: ''; height: 100%; background-color: $accent-color; @@ -116,7 +142,7 @@ limitations under the License. border-radius: 0 3px 3px 0; } -.mx_TagPanel .mx_TagTile.mx_AccessibleButton:focus { +.mx_GroupFilterPanel .mx_TagTile.mx_AccessibleButton:focus { filter: none; } diff --git a/res/css/structures/_HeaderButtons.scss b/res/css/structures/_HeaderButtons.scss index 9ef40e9d6a..72b663ef0e 100644 --- a/res/css/structures/_HeaderButtons.scss +++ b/res/css/structures/_HeaderButtons.scss @@ -18,6 +18,14 @@ limitations under the License. display: flex; } +.mx_RoomHeader_buttons + .mx_HeaderButtons { + // remove the | separator line for when next to RoomHeaderButtons + // TODO: remove this once when we redo communities and make the right panel similar to the new rooms one + &::before { + content: unset; + } +} + .mx_HeaderButtons::before { content: ""; background-color: $header-divider-color; diff --git a/res/css/structures/_LeftPanel.scss b/res/css/structures/_LeftPanel.scss index 5112d07c46..885dd77a84 100644 --- a/res/css/structures/_LeftPanel.scss +++ b/res/css/structures/_LeftPanel.scss @@ -14,29 +14,29 @@ See the License for the specific language governing permissions and limitations under the License. */ -$tagPanelWidth: 56px; // only applies in this file, used for calculations +$groupFilterPanelWidth: 56px; // only applies in this file, used for calculations .mx_LeftPanel { background-color: $roomlist-bg-color; min-width: 260px; max-width: 50%; - // Create a row-based flexbox for the TagPanel and the room list + // Create a row-based flexbox for the GroupFilterPanel and the room list display: flex; - .mx_LeftPanel_tagPanelContainer { + .mx_LeftPanel_GroupFilterPanelContainer { flex-grow: 0; flex-shrink: 0; - flex-basis: $tagPanelWidth; + flex-basis: $groupFilterPanelWidth; height: 100%; - // Create another flexbox so the TagPanel fills the container + // Create another flexbox so the GroupFilterPanel fills the container display: flex; - // TagPanel handles its own CSS + // GroupFilterPanel handles its own CSS } - &:not(.mx_LeftPanel_hasTagPanel) { + &:not(.mx_LeftPanel_hasGroupFilterPanel) { .mx_LeftPanel_roomListContainer { width: 100%; } @@ -45,7 +45,7 @@ $tagPanelWidth: 56px; // only applies in this file, used for calculations // Note: The 'room list' in this context is actually everything that isn't the tag // panel, such as the menu options, breadcrumbs, filtering, etc .mx_LeftPanel_roomListContainer { - width: calc(100% - $tagPanelWidth); + width: calc(100% - $groupFilterPanelWidth); background-color: $roomlist-bg-color; // Create another flexbox (this time a column) for the room list components @@ -169,10 +169,10 @@ $tagPanelWidth: 56px; // only applies in this file, used for calculations min-width: unset; // We have to forcefully set the width to override the resizer's style attribute. - &.mx_LeftPanel_hasTagPanel { - width: calc(68px + $tagPanelWidth) !important; + &.mx_LeftPanel_hasGroupFilterPanel { + width: calc(68px + $groupFilterPanelWidth) !important; } - &:not(.mx_LeftPanel_hasTagPanel) { + &:not(.mx_LeftPanel_hasGroupFilterPanel) { width: 68px !important; } diff --git a/res/css/structures/_MainSplit.scss b/res/css/structures/_MainSplit.scss index dc62cb8218..ad1656efbb 100644 --- a/res/css/structures/_MainSplit.scss +++ b/res/css/structures/_MainSplit.scss @@ -25,6 +25,7 @@ limitations under the License. padding: 5px; // margin left to not allow the handle to not encroach on the space for the scrollbar margin-left: 8px; + height: calc(100vh - 51px); // height of .mx_RoomHeader.light-panel &:hover .mx_RightPanel_ResizeHandle { // Need to use important to override element style attributes diff --git a/res/css/structures/_NotificationPanel.scss b/res/css/structures/_NotificationPanel.scss index 715a94fe2c..1258ace069 100644 --- a/res/css/structures/_NotificationPanel.scss +++ b/res/css/structures/_NotificationPanel.scss @@ -22,7 +22,13 @@ limitations under the License. } .mx_NotificationPanel .mx_RoomView_messageListWrapper { - margin-right: 20px; + flex-direction: row; + align-items: center; + justify-content: center; +} + +.mx_NotificationPanel .mx_RoomView_MessageList { + width: 100%; } .mx_NotificationPanel .mx_RoomView_MessageList h2 { @@ -35,11 +41,32 @@ limitations under the License. .mx_NotificationPanel .mx_EventTile { word-break: break-word; + position: relative; + padding-bottom: 18px; + + &:not(.mx_EventTile_last):not(.mx_EventTile_lastInSection)::after { + position: absolute; + bottom: 0; + left: 0; + right: 0; + background-color: $tertiary-fg-color; + height: 1px; + opacity: 0.4; + content: ''; + } } .mx_NotificationPanel .mx_EventTile_roomName { font-weight: bold; font-size: $font-14px; + + > * { + vertical-align: middle; + } + + > .mx_BaseAvatar { + margin-right: 8px; + } } .mx_NotificationPanel .mx_EventTile_roomName a { @@ -47,8 +74,7 @@ limitations under the License. } .mx_NotificationPanel .mx_EventTile_avatar { - top: 8px; - left: 0px; + display: none; // we don't need this in this view } .mx_NotificationPanel .mx_EventTile .mx_SenderProfile, @@ -60,8 +86,7 @@ limitations under the License. } .mx_NotificationPanel .mx_EventTile_senderDetails { - padding-left: 32px; - padding-top: 8px; + padding-left: 36px; // align with the room name position: relative; a { @@ -82,7 +107,7 @@ limitations under the License. .mx_NotificationPanel .mx_EventTile_line { margin-right: 0px; - padding-left: 32px; + padding-left: 36px; // align with the room name padding-top: 0px; padding-bottom: 0px; padding-right: 0px; diff --git a/res/css/structures/_RightPanel.scss b/res/css/structures/_RightPanel.scss index c7c0d6fac4..5bf0d953f3 100644 --- a/res/css/structures/_RightPanel.scss +++ b/res/css/structures/_RightPanel.scss @@ -68,16 +68,14 @@ limitations under the License. mask-repeat: no-repeat; mask-size: contain; } -} -.mx_RightPanel_membersButton::before { - mask-image: url('$(res)/img/element-icons/room/members.svg'); - mask-position: center; -} + &:hover { + background: rgba($accent-color, 0.1); -.mx_RightPanel_filesButton::before { - mask-image: url('$(res)/img/element-icons/room/files.svg'); - mask-position: center; + &::before { + background-color: $accent-color; + } + } } .mx_RightPanel_notifsButton::before { @@ -85,6 +83,11 @@ limitations under the License. mask-position: center; } +.mx_RightPanel_roomSummaryButton::before { + mask-image: url('$(res)/img/element-icons/room/room-summary.svg'); + mask-position: center; +} + .mx_RightPanel_groupMembersButton::before { mask-image: url('$(res)/img/element-icons/community-members.svg'); mask-position: center; @@ -96,23 +99,11 @@ limitations under the License. } .mx_RightPanel_headerButton_highlight { - background: rgba($accent-color, 0.25); - // make the icon the accent color too &::before { background-color: $accent-color !important; } } -.mx_RightPanel_headerButton:not(.mx_RightPanel_headerButton_highlight) { - &:hover { - background: rgba($accent-color, 0.1); - - &::before { - background-color: $accent-color; - } - } -} - .mx_RightPanel_headerButton_badge { font-size: $font-8px; border-radius: 8px; @@ -146,7 +137,7 @@ limitations under the License. } .mx_RightPanel_empty { - margin-right: -42px; + margin-right: -28px; h2 { font-weight: 700; diff --git a/res/css/structures/_RoomDirectory.scss b/res/css/structures/_RoomDirectory.scss index e0814182f5..29e6fecd34 100644 --- a/res/css/structures/_RoomDirectory.scss +++ b/res/css/structures/_RoomDirectory.scss @@ -133,6 +133,10 @@ limitations under the License. .mx_RoomDirectory_topic { cursor: initial; color: $light-fg-color; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; + overflow: hidden; } .mx_RoomDirectory_alias { diff --git a/res/css/structures/_RoomView.scss b/res/css/structures/_RoomView.scss index 3b60c4e62b..572c7166d2 100644 --- a/res/css/structures/_RoomView.scss +++ b/res/css/structures/_RoomView.scss @@ -185,13 +185,11 @@ limitations under the License. } .mx_RoomView_empty { - flex: 1 1 auto; font-size: $font-13px; - padding-left: 3em; - padding-right: 3em; - margin-right: 20px; - margin-top: 33%; + padding: 0 24px; + margin-right: 30px; text-align: center; + margin-bottom: 80px; // visually center the content (intentional offset) } .mx_RoomView_MessageList { diff --git a/res/css/structures/_TabbedView.scss b/res/css/structures/_TabbedView.scss index 4a4bb125a3..39a8ebed32 100644 --- a/res/css/structures/_TabbedView.scss +++ b/res/css/structures/_TabbedView.scss @@ -17,7 +17,7 @@ limitations under the License. .mx_TabbedView { margin: 0; - padding: 0 0 0 58px; + padding: 0 0 0 16px; display: flex; flex-direction: column; position: absolute; @@ -25,6 +25,7 @@ limitations under the License. bottom: 0; left: 0; right: 0; + margin-top: 8px; } .mx_TabbedView_tabLabels { @@ -35,13 +36,13 @@ limitations under the License. } .mx_TabbedView_tabLabel { + display: flex; + align-items: center; vertical-align: text-top; cursor: pointer; - display: block; - border-radius: 3px; - font-size: $font-14px; - min-height: 24px; // use min-height instead of height to allow the label to overflow a bit - margin-bottom: 6px; + padding: 8px 0; + border-radius: 8px; + font-size: $font-13px; position: relative; } @@ -51,9 +52,8 @@ limitations under the License. } .mx_TabbedView_maskedIcon { - margin-left: 6px; - margin-right: 9px; - margin-top: 1px; + margin-left: 8px; + margin-right: 16px; width: 16px; height: 16px; display: inline-block; @@ -65,10 +65,9 @@ limitations under the License. mask-repeat: no-repeat; mask-size: 16px; width: 16px; - height: 22px; + height: 16px; mask-position: center; content: ''; - vertical-align: middle; } .mx_TabbedView_tabLabel_active .mx_TabbedView_maskedIcon::before { diff --git a/res/css/structures/_ToastContainer.scss b/res/css/structures/_ToastContainer.scss index 544dcbc180..c381668a6a 100644 --- a/res/css/structures/_ToastContainer.scss +++ b/res/css/structures/_ToastContainer.scss @@ -80,6 +80,11 @@ limitations under the License. } } + &.mx_Toast_icon_secure_backup::after { + mask-image: url('$(res)/img/feather-customised/secure-backup.svg'); + background-color: $primary-fg-color; + } + .mx_Toast_title, .mx_Toast_body { grid-column: 2; } diff --git a/res/css/structures/_UserMenu.scss b/res/css/structures/_UserMenu.scss index 78795c85a2..fecac40e4e 100644 --- a/res/css/structures/_UserMenu.scss +++ b/res/css/structures/_UserMenu.scss @@ -15,10 +15,33 @@ limitations under the License. */ .mx_UserMenu { - - // to make the ... button sort of aligned with the explore button below + // to make the menu button sort of aligned with the explore button below padding-right: 6px; + &.mx_UserMenu_prototype { + // The margin & padding combination between here and the ::after is to + // align the border line with the tag panel. + margin-bottom: 6px; + + padding-right: 0; // make the right edge line up with the explore button + + .mx_UserMenu_headerButtons { + // considering we've eliminated right padding on the menu itself, we need to + // push the chevron in slightly (roughly lining up with the center of the + // plus buttons) + margin-right: 2px; + } + + // we cheat opacity on the theme colour with an after selector here + &::after { + content: ''; + border-bottom: 1px solid $primary-fg-color; // XXX: Variable abuse + opacity: 0.2; + display: block; + padding-top: 8px; + } + } + .mx_UserMenu_headerButtons { width: 16px; height: 16px; @@ -35,8 +58,8 @@ limitations under the License. mask-position: center; mask-size: contain; mask-repeat: no-repeat; - background: $primary-fg-color; - mask-image: url('$(res)/img/element-icons/context-menu.svg'); + background: $tertiary-fg-color; + mask-image: url('$(res)/img/feather-customised/chevron-down.svg'); } } @@ -56,6 +79,28 @@ limitations under the License. } } + .mx_UserMenu_doubleName { + flex: 1; + min-width: 0; // make flexbox aware that it can crush this to a tiny width + + .mx_UserMenu_userName, + .mx_UserMenu_subUserName { + display: block; + } + + .mx_UserMenu_subUserName { + color: $muted-fg-color; + font-size: $font-13px; + line-height: $font-18px; + flex: 1; + + // Ellipsize any text overflow + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + } + } + .mx_UserMenu_userName { font-weight: 600; font-size: $font-15px; @@ -89,6 +134,44 @@ limitations under the License. .mx_UserMenu_contextMenu { width: 247px; + // These override the styles already present on the user menu rather than try to + // define a new menu. They are specifically for the stacked menu when a community + // is being represented as a prototype. + &.mx_UserMenu_contextMenu_prototype { + padding-bottom: 16px; + + .mx_UserMenu_contextMenu_header { + padding-bottom: 0; + padding-top: 16px; + + &:nth-child(n + 2) { + padding-top: 8px; + } + } + + hr { + width: 85%; + opacity: 0.2; + border: none; + border-bottom: 1px solid $primary-fg-color; // XXX: Variable abuse + } + + &.mx_IconizedContextMenu { + > .mx_IconizedContextMenu_optionList { + margin-top: 4px; + + &::before { + border: none; + } + + > .mx_AccessibleButton { + padding-top: 2px; + padding-bottom: 2px; + } + } + } + } + &.mx_IconizedContextMenu .mx_IconizedContextMenu_optionList_red { .mx_AccessibleButton { padding-top: 16px; @@ -193,4 +276,12 @@ limitations under the License. .mx_UserMenu_iconSignOut::before { mask-image: url('$(res)/img/element-icons/leave.svg'); } + + .mx_UserMenu_iconMembers::before { + mask-image: url('$(res)/img/element-icons/room/members.svg'); + } + + .mx_UserMenu_iconInvite::before { + mask-image: url('$(res)/img/element-icons/room/invite.svg'); + } } diff --git a/res/css/views/auth/_Welcome.scss b/res/css/views/auth/_Welcome.scss index 9043289184..f0e2b3de33 100644 --- a/res/css/views/auth/_Welcome.scss +++ b/res/css/views/auth/_Welcome.scss @@ -18,6 +18,12 @@ limitations under the License. display: flex; flex-direction: column; align-items: center; + + &.mx_WelcomePage_registrationDisabled { + .mx_ButtonCreateAccount { + display: none; + } + } } .mx_Welcome .mx_AuthBody_language { diff --git a/res/css/views/context_menus/_IconizedContextMenu.scss b/res/css/views/context_menus/_IconizedContextMenu.scss index 7913058995..d911ac6dfe 100644 --- a/res/css/views/context_menus/_IconizedContextMenu.scss +++ b/res/css/views/context_menus/_IconizedContextMenu.scss @@ -82,7 +82,6 @@ limitations under the License. } span.mx_IconizedContextMenu_label { // labels - padding-left: 14px; width: 100%; flex: 1; @@ -91,6 +90,10 @@ limitations under the License. overflow: hidden; white-space: nowrap; } + + .mx_IconizedContextMenu_icon + .mx_IconizedContextMenu_label { + padding-left: 14px; + } } } diff --git a/res/css/views/dialogs/_CommunityPrototypeInviteDialog.scss b/res/css/views/dialogs/_CommunityPrototypeInviteDialog.scss new file mode 100644 index 0000000000..beae03f00f --- /dev/null +++ b/res/css/views/dialogs/_CommunityPrototypeInviteDialog.scss @@ -0,0 +1,88 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_CommunityPrototypeInviteDialog { + &.mx_Dialog_fixedWidth { + width: 360px; + } + + .mx_Dialog_content { + margin-bottom: 0; + + .mx_CommunityPrototypeInviteDialog_people { + position: relative; + margin-bottom: 4px; + + .mx_AccessibleButton { + display: inline-block; + background-color: $focus-bg-color; // XXX: Abuse of variables + border-radius: 4px; + padding: 3px 5px; + font-size: $font-12px; + float: right; + } + } + + .mx_CommunityPrototypeInviteDialog_morePeople { + margin-top: 8px; + } + + .mx_CommunityPrototypeInviteDialog_person { + position: relative; + margin-top: 4px; + + & > * { + vertical-align: middle; + } + + .mx_Checkbox { + position: absolute; + right: 0; + top: calc(50% - 8px); // checkbox is 16px high + width: 16px; // to force a square + } + + .mx_CommunityPrototypeInviteDialog_personIdentifiers { + display: inline-block; + + & > * { + display: block; + } + + .mx_CommunityPrototypeInviteDialog_personName { + font-weight: 600; + font-size: $font-14px; + color: $primary-fg-color; + margin-left: 7px; + } + + .mx_CommunityPrototypeInviteDialog_personId { + font-size: $font-12px; + color: $muted-fg-color; + margin-left: 7px; + } + } + } + + .mx_CommunityPrototypeInviteDialog_primaryButton { + display: block; + font-size: $font-13px; + line-height: 20px; + height: 20px; + margin-top: 24px; + } + } +} diff --git a/res/css/views/dialogs/_CreateCommunityPrototypeDialog.scss b/res/css/views/dialogs/_CreateCommunityPrototypeDialog.scss new file mode 100644 index 0000000000..81babc4c38 --- /dev/null +++ b/res/css/views/dialogs/_CreateCommunityPrototypeDialog.scss @@ -0,0 +1,102 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_CreateCommunityPrototypeDialog { + .mx_Dialog_content { + display: flex; + flex-direction: row; + margin-bottom: 12px; + + .mx_CreateCommunityPrototypeDialog_colName { + flex-basis: 66.66%; + padding-right: 100px; + + .mx_Field input { + font-size: $font-16px; + line-height: $font-20px; + } + + .mx_CreateCommunityPrototypeDialog_subtext { + display: block; + color: $muted-fg-color; + margin-bottom: 16px; + + &:last-child { + margin-top: 16px; + } + + &.mx_CreateCommunityPrototypeDialog_subtext_error { + color: $warning-color; + } + } + + .mx_CreateCommunityPrototypeDialog_communityId { + position: relative; + + .mx_InfoTooltip { + float: right; + } + } + + .mx_AccessibleButton { + display: block; + height: 32px; + font-size: $font-16px; + line-height: 32px; + } + } + + .mx_CreateCommunityPrototypeDialog_colAvatar { + flex-basis: 33.33%; + + .mx_CreateCommunityPrototypeDialog_avatarContainer { + margin-top: 12px; + margin-bottom: 20px; + + .mx_CreateCommunityPrototypeDialog_avatar, + .mx_CreateCommunityPrototypeDialog_placeholderAvatar { + width: 96px; + height: 96px; + border-radius: 96px; + } + + .mx_CreateCommunityPrototypeDialog_placeholderAvatar { + background-color: #368bd6; // hardcoded for both themes + + &::before { + display: inline-block; + background-color: #fff; // hardcoded because the background is + mask-repeat: no-repeat; + mask-size: 96px; + width: 96px; + height: 96px; + mask-position: center; + content: ''; + vertical-align: middle; + mask-image: url('$(res)/img/element-icons/add-photo.svg'); + } + } + } + + .mx_CreateCommunityPrototypeDialog_tip { + & > b, & > span { + display: block; + color: $muted-fg-color; + } + } + } + } +} diff --git a/res/css/views/dialogs/_EditCommunityPrototypeDialog.scss b/res/css/views/dialogs/_EditCommunityPrototypeDialog.scss new file mode 100644 index 0000000000..75a56bf6b3 --- /dev/null +++ b/res/css/views/dialogs/_EditCommunityPrototypeDialog.scss @@ -0,0 +1,77 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// XXX: many of these styles are shared with the create dialog +.mx_EditCommunityPrototypeDialog { + &.mx_Dialog_fixedWidth { + width: 360px; + } + + .mx_Dialog_content { + margin-bottom: 12px; + + .mx_AccessibleButton.mx_AccessibleButton_kind_primary { + display: block; + height: 32px; + font-size: $font-16px; + line-height: 32px; + } + + .mx_EditCommunityPrototypeDialog_rowAvatar { + display: flex; + flex-direction: row; + align-items: center; + } + + .mx_EditCommunityPrototypeDialog_avatarContainer { + margin-top: 20px; + margin-bottom: 20px; + + .mx_EditCommunityPrototypeDialog_avatar, + .mx_EditCommunityPrototypeDialog_placeholderAvatar { + width: 96px; + height: 96px; + border-radius: 96px; + } + + .mx_EditCommunityPrototypeDialog_placeholderAvatar { + background-color: #368bd6; // hardcoded for both themes + + &::before { + display: inline-block; + background-color: #fff; // hardcoded because the background is + mask-repeat: no-repeat; + mask-size: 96px; + width: 96px; + height: 96px; + mask-position: center; + content: ''; + vertical-align: middle; + mask-image: url('$(res)/img/element-icons/add-photo.svg'); + } + } + } + + .mx_EditCommunityPrototypeDialog_tip { + margin-left: 20px; + + & > b, & > span { + display: block; + color: $muted-fg-color; + } + } + } +} diff --git a/res/css/views/dialogs/_InviteDialog.scss b/res/css/views/dialogs/_InviteDialog.scss index a77d0bfbba..b9063f46b9 100644 --- a/res/css/views/dialogs/_InviteDialog.scss +++ b/res/css/views/dialogs/_InviteDialog.scss @@ -89,6 +89,13 @@ limitations under the License. font-weight: bold; text-transform: uppercase; } + + .mx_InviteDialog_subname { + margin-bottom: 10px; + margin-top: -10px; // HACK: Positioning with margins is bad + font-size: $font-12px; + color: $muted-fg-color; + } } .mx_InviteDialog_roomTile { @@ -226,3 +233,7 @@ limitations under the License. .mx_InviteDialog_addressBar { margin-right: 45px; } + +.mx_InviteDialog_helpText .mx_AccessibleButton_kind_link { + padding: 0; +} diff --git a/res/css/views/dialogs/_RoomSettingsDialog.scss b/res/css/views/dialogs/_RoomSettingsDialog.scss index d4199a1e66..9bcde6e1e0 100644 --- a/res/css/views/dialogs/_RoomSettingsDialog.scss +++ b/res/css/views/dialogs/_RoomSettingsDialog.scss @@ -48,7 +48,6 @@ limitations under the License. white-space: nowrap; overflow: hidden; margin: 0 auto; - padding-left: 40px; padding-right: 80px; } diff --git a/res/css/views/dialogs/_SetMxIdDialog.scss b/res/css/views/dialogs/_SetMxIdDialog.scss deleted file mode 100644 index 1df34f3408..0000000000 --- a/res/css/views/dialogs/_SetMxIdDialog.scss +++ /dev/null @@ -1,50 +0,0 @@ -/* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2017 Vector Creations Ltd - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -.mx_SetMxIdDialog .mx_Dialog_title { - padding-right: 40px; -} - -.mx_SetMxIdDialog_input_group { - display: flex; -} - -.mx_SetMxIdDialog_input { - border-radius: 3px; - border: 1px solid $input-border-color; - padding: 9px; - color: $primary-fg-color; - background-color: $primary-bg-color; - font-size: $font-15px; - width: 100%; - max-width: 280px; -} - -.mx_SetMxIdDialog_input.error, -.mx_SetMxIdDialog_input.error:focus { - border: 1px solid $warning-color; -} - -.mx_SetMxIdDialog_input_group .mx_Spinner { - height: 37px; - padding-left: 10px; - justify-content: flex-start; -} - -.mx_SetMxIdDialog .success { - color: $accent-color; -} diff --git a/res/css/views/dialogs/_SettingsDialog.scss b/res/css/views/dialogs/_SettingsDialog.scss index ec813a1a07..6c4ed35c5a 100644 --- a/res/css/views/dialogs/_SettingsDialog.scss +++ b/res/css/views/dialogs/_SettingsDialog.scss @@ -36,7 +36,6 @@ limitations under the License. } .mx_Dialog_title { - text-align: center; margin-bottom: 24px; } } diff --git a/res/css/views/dialogs/_ShareDialog.scss b/res/css/views/dialogs/_ShareDialog.scss index c343b872fd..ce3fdd021f 100644 --- a/res/css/views/dialogs/_ShareDialog.scss +++ b/res/css/views/dialogs/_ShareDialog.scss @@ -71,9 +71,12 @@ limitations under the License. margin-right: 64px; } +.mx_ShareDialog_qrcode_container + .mx_ShareDialog_social_container { + width: 299px; +} + .mx_ShareDialog_social_container { display: inline-block; - width: 299px; } .mx_ShareDialog_social_icon { diff --git a/res/css/views/dialogs/secretstorage/_AccessSecretStorageDialog.scss b/res/css/views/dialogs/security/_AccessSecretStorageDialog.scss similarity index 100% rename from res/css/views/dialogs/secretstorage/_AccessSecretStorageDialog.scss rename to res/css/views/dialogs/security/_AccessSecretStorageDialog.scss diff --git a/res/css/views/dialogs/security/_CreateCrossSigningDialog.scss b/res/css/views/dialogs/security/_CreateCrossSigningDialog.scss new file mode 100644 index 0000000000..8303e02b9e --- /dev/null +++ b/res/css/views/dialogs/security/_CreateCrossSigningDialog.scss @@ -0,0 +1,33 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_CreateCrossSigningDialog { + // Why you ask? Because CompleteSecurityBody is 600px so this is the width + // we end up when in there, but when in our own dialog we set our own width + // so need to fix it to something sensible as otherwise we'd end up either + // really wide or really narrow depending on the phase. I bet you wish you + // never asked. + width: 560px; + + details .mx_AccessibleButton { + margin: 1em 0; // emulate paragraph spacing because we can't put this button in a paragraph due to HTML rules + } +} + +.mx_CreateCrossSigningDialog .mx_Dialog_title { + /* TODO: Consider setting this for all dialog titles. */ + margin-bottom: 1em; +} diff --git a/res/css/views/dialogs/keybackup/_CreateKeyBackupDialog.scss b/res/css/views/dialogs/security/_CreateKeyBackupDialog.scss similarity index 100% rename from res/css/views/dialogs/keybackup/_CreateKeyBackupDialog.scss rename to res/css/views/dialogs/security/_CreateKeyBackupDialog.scss diff --git a/res/css/views/dialogs/secretstorage/_CreateSecretStorageDialog.scss b/res/css/views/dialogs/security/_CreateSecretStorageDialog.scss similarity index 100% rename from res/css/views/dialogs/secretstorage/_CreateSecretStorageDialog.scss rename to res/css/views/dialogs/security/_CreateSecretStorageDialog.scss diff --git a/res/css/views/dialogs/keybackup/_KeyBackupFailedDialog.scss b/res/css/views/dialogs/security/_KeyBackupFailedDialog.scss similarity index 100% rename from res/css/views/dialogs/keybackup/_KeyBackupFailedDialog.scss rename to res/css/views/dialogs/security/_KeyBackupFailedDialog.scss diff --git a/res/css/views/dialogs/keybackup/_RestoreKeyBackupDialog.scss b/res/css/views/dialogs/security/_RestoreKeyBackupDialog.scss similarity index 100% rename from res/css/views/dialogs/keybackup/_RestoreKeyBackupDialog.scss rename to res/css/views/dialogs/security/_RestoreKeyBackupDialog.scss diff --git a/src/extend.js b/res/css/views/elements/_DesktopBuildsNotice.scss similarity index 72% rename from src/extend.js rename to res/css/views/elements/_DesktopBuildsNotice.scss index 263d802ab6..3672595bf1 100644 --- a/src/extend.js +++ b/res/css/views/elements/_DesktopBuildsNotice.scss @@ -1,5 +1,5 @@ /* -Copyright 2015, 2016 OpenMarket Ltd +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. @@ -14,13 +14,15 @@ See the License for the specific language governing permissions and limitations under the License. */ -'use strict'; +.mx_DesktopBuildsNotice { + text-align: center; + padding: 0 16px; -export default function(dest, src) { - for (const i in src) { - if (src.hasOwnProperty(i)) { - dest[i] = src[i]; - } + > * { + vertical-align: middle; + } + + > img { + margin-right: 8px; } - return dest; } diff --git a/res/css/views/dialogs/_SetPasswordDialog.scss b/res/css/views/elements/_InfoTooltip.scss similarity index 54% rename from res/css/views/dialogs/_SetPasswordDialog.scss rename to res/css/views/elements/_InfoTooltip.scss index 1f99353298..5858a60629 100644 --- a/res/css/views/dialogs/_SetPasswordDialog.scss +++ b/res/css/views/elements/_InfoTooltip.scss @@ -1,5 +1,5 @@ /* -Copyright 2017 Vector Creations Ltd +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. @@ -14,21 +14,21 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_SetPasswordDialog_change_password input { - border-radius: 3px; - border: 1px solid $input-border-color; - padding: 9px; - color: $primary-fg-color; - background-color: $primary-bg-color; - font-size: $font-15px; - max-width: 280px; - margin-bottom: 10px; +.mx_InfoTooltip_icon { + width: 16px; + height: 16px; + display: inline-block; } -.mx_SetPasswordDialog_change_password_button { - margin-top: 68px; -} - -.mx_SetPasswordDialog .mx_Dialog_content { - margin-bottom: 0px; +.mx_InfoTooltip_icon::before { + display: inline-block; + background-color: $muted-fg-color; + mask-repeat: no-repeat; + mask-size: 16px; + width: 16px; + height: 16px; + mask-position: center; + content: ''; + vertical-align: middle; + mask-image: url('$(res)/img/element-icons/info.svg'); } diff --git a/res/css/views/messages/_MJitsiWidgetEvent.scss b/res/css/views/messages/_MJitsiWidgetEvent.scss new file mode 100644 index 0000000000..3e51e89744 --- /dev/null +++ b/res/css/views/messages/_MJitsiWidgetEvent.scss @@ -0,0 +1,55 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_MJitsiWidgetEvent { + display: grid; + grid-template-columns: 24px minmax(0, 1fr) min-content; + + &::before { + grid-column: 1; + grid-row: 1 / 3; + width: 16px; + height: 16px; + content: ""; + top: 0; + bottom: 0; + left: 0; + right: 0; + mask-repeat: no-repeat; + mask-position: center; + mask-size: contain; + background-color: $composer-e2e-icon-color; // XXX: Variable abuse + margin-top: 4px; + mask-image: url('$(res)/img/element-icons/call/video-call.svg'); + } + + .mx_MJitsiWidgetEvent_title { + font-weight: 600; + font-size: $font-15px; + grid-column: 2; + grid-row: 1; + } + + .mx_MJitsiWidgetEvent_subtitle { + grid-column: 2; + grid-row: 2; + } + + .mx_MJitsiWidgetEvent_title, + .mx_MJitsiWidgetEvent_subtitle { + overflow-wrap: break-word; + } +} diff --git a/res/css/views/right_panel/_BaseCard.scss b/res/css/views/right_panel/_BaseCard.scss new file mode 100644 index 0000000000..3ff3b52531 --- /dev/null +++ b/res/css/views/right_panel/_BaseCard.scss @@ -0,0 +1,165 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_BaseCard { + padding: 0 8px; + overflow: hidden; + display: flex; + flex-direction: column; + flex: 1; + + .mx_BaseCard_header { + margin: 8px 0; + + > h2 { + margin: 0 44px; + font-size: $font-18px; + font-weight: $font-semi-bold; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .mx_BaseCard_back, .mx_BaseCard_close { + position: absolute; + background-color: rgba(141, 151, 165, 0.2); + height: 20px; + width: 20px; + margin: 12px; + top: 0; + border-radius: 10px; + + &::before { + content: ""; + position: absolute; + height: 20px; + width: 20px; + top: 0; + left: 0; + mask-repeat: no-repeat; + mask-position: center; + background-color: $rightpanel-button-color; + } + } + + .mx_BaseCard_back { + left: 0; + + &::before { + transform: rotate(90deg); + mask-size: 22px; + mask-image: url('$(res)/img/feather-customised/chevron-down.svg'); + } + } + + .mx_BaseCard_close { + right: 0; + + &::before { + mask-image: url('$(res)/img/icons-close.svg'); + mask-size: 8px; + } + } + } + + .mx_AutoHideScrollbar { + // collapse the margin into a padding to move the scrollbar into the right gutter + margin-right: -8px; + padding-right: 8px; + min-height: 0; + width: 100%; + height: 100%; + } + + .mx_BaseCard_Group { + margin: 20px 0 16px; + + & > * { + margin-left: 12px; + margin-right: 12px; + } + + > h1 { + color: $tertiary-fg-color; + font-size: $font-12px; + font-weight: 500; + } + + .mx_BaseCard_Button { + padding: 10px 38px 10px 12px; + margin: 0; + position: relative; + font-size: $font-13px; + height: 20px; + line-height: 20px; + border-radius: 8px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + + &:hover { + background-color: rgba(141, 151, 165, 0.1); + } + + &::after { + content: ''; + position: absolute; + top: 10px; + right: 6px; + height: 20px; + width: 20px; + mask-repeat: no-repeat; + mask-position: center; + background-color: $icon-button-color; + transform: rotate(270deg); + mask-size: 20px; + mask-image: url('$(res)/img/feather-customised/chevron-down.svg'); + } + } + } + + .mx_BaseCard_footer { + padding-top: 4px; + text-align: center; + display: flex; + justify-content: space-around; + + .mx_AccessibleButton_kind_secondary { + color: $secondary-fg-color; + background-color: rgba(141, 151, 165, 0.2); + font-weight: $font-semi-bold; + font-size: $font-14px; + } + + .mx_AccessibleButton_disabled { + cursor: not-allowed; + } + } +} + +.mx_FilePanel, +.mx_UserInfo, +.mx_NotificationPanel, +.mx_MemberList { + &.mx_BaseCard { + padding: 32px 0 0; + + .mx_AutoHideScrollbar { + margin-right: unset; + padding-right: unset; + } + } +} diff --git a/res/css/views/right_panel/_RoomSummaryCard.scss b/res/css/views/right_panel/_RoomSummaryCard.scss new file mode 100644 index 0000000000..0031d3a64c --- /dev/null +++ b/res/css/views/right_panel/_RoomSummaryCard.scss @@ -0,0 +1,161 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_RoomSummaryCard { + .mx_BaseCard_header { + text-align: center; + margin-top: 20px; + + h2 { + font-weight: $font-semi-bold; + font-size: $font-18px; + margin: 12px 0 4px; + } + + .mx_RoomSummaryCard_alias { + font-size: $font-13px; + color: $secondary-fg-color; + } + + h2, .mx_RoomSummaryCard_alias { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + text-overflow: ellipsis; + } + + .mx_RoomSummaryCard_avatar { + display: inline-flex; + + .mx_RoomSummaryCard_e2ee { + display: inline-block; + position: relative; + width: 54px; + height: 54px; + border-radius: 50%; + background-color: #737d8c; + margin-top: -3px; // alignment + margin-left: -10px; // overlap + border: 3px solid $dark-panel-bg-color; + + &::before { + content: ''; + position: absolute; + top: 13px; + left: 13px; + height: 28px; + width: 28px; + mask-size: cover; + mask-repeat: no-repeat; + mask-position: center; + mask-image: url('$(res)/img/e2e/disabled.svg'); + background-color: #ffffff; + } + } + + .mx_RoomSummaryCard_e2ee_normal { + background-color: #424446; + &::before { + mask-image: url('$(res)/img/e2e/normal.svg'); + } + } + + .mx_RoomSummaryCard_e2ee_verified { + background-color: #0dbd8b; + &::before { + mask-image: url('$(res)/img/e2e/verified.svg'); + } + } + + .mx_RoomSummaryCard_e2ee_warning { + background-color: #ff4b55; + &::before { + mask-image: url('$(res)/img/e2e/warning.svg'); + } + } + } + } + + .mx_RoomSummaryCard_aboutGroup { + .mx_RoomSummaryCard_Button { + padding-left: 44px; + + &::before { + content: ''; + position: absolute; + top: 8px; + left: 10px; + height: 24px; + width: 24px; + mask-repeat: no-repeat; + mask-position: center; + background-color: $icon-button-color; + } + } + } + + .mx_RoomSummaryCard_appsGroup { + .mx_RoomSummaryCard_Button { + padding-left: 12px; + color: $tertiary-fg-color; + + span { + color: $primary-fg-color; + } + + img { + vertical-align: top; + margin-right: 12px; + border-radius: 4px; + } + + &::before { + content: unset; + } + } + + .mx_RoomSummaryCard_icon_app_pinned::after { + mask-image: url('$(res)/img/element-icons/room/pin-upright.svg'); + background-color: $accent-color; + transform: unset; + } + } + + .mx_AccessibleButton_kind_link { + padding: 0; + margin-top: 12px; + margin-bottom: 12px; + font-size: $font-13px; + font-weight: $font-semi-bold; + } +} + +.mx_RoomSummaryCard_icon_people::before { + mask-image: url("$(res)/img/element-icons/room/members.svg"); +} + +.mx_RoomSummaryCard_icon_files::before { + mask-image: url('$(res)/img/element-icons/room/files.svg'); +} + +.mx_RoomSummaryCard_icon_share::before { + mask-image: url('$(res)/img/element-icons/room/share.svg'); +} + +.mx_RoomSummaryCard_icon_settings::before { + mask-image: url('$(res)/img/element-icons/settings.svg'); +} diff --git a/res/css/views/right_panel/_UserInfo.scss b/res/css/views/right_panel/_UserInfo.scss index 6f86d1ad18..f20c9b7868 100644 --- a/res/css/views/right_panel/_UserInfo.scss +++ b/res/css/views/right_panel/_UserInfo.scss @@ -15,7 +15,9 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_UserInfo { +.mx_UserInfo.mx_BaseCard { + // UserInfo has a circular image at the top so it fits between the back & close buttons + padding-top: 0; display: flex; flex-direction: column; flex: 1; @@ -217,9 +219,8 @@ limitations under the License. text-overflow: clip; } - .mx_UserInfo_scrollContainer { + .mx_AutoHideScrollbar { flex: 1 1 0; - padding-bottom: 16px; } .mx_UserInfo_container:not(.mx_UserInfo_separator) { diff --git a/res/css/views/right_panel/_WidgetCard.scss b/res/css/views/right_panel/_WidgetCard.scss new file mode 100644 index 0000000000..315fd5213c --- /dev/null +++ b/res/css/views/right_panel/_WidgetCard.scss @@ -0,0 +1,62 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_WidgetCard { + height: 100%; + display: contents; + + .mx_AppTileFullWidth { + max-width: unset; + height: 100%; + border: 0; + } + + &.mx_WidgetCard_noEdit { + .mx_AccessibleButton_kind_secondary { + margin: 0 12px; + + &:first-child { + // expand the Pin to room primary action + flex-grow: 1; + } + } + } + + .mx_WidgetCard_optionsButton { + position: relative; + height: 18px; + width: 26px; + + &::before { + content: ""; + position: absolute; + width: 20px; + height: 20px; + top: 6px; + left: 20px; + mask-repeat: no-repeat; + mask-position: center; + mask-size: contain; + mask-image: url('$(res)/img/element-icons/room/ellipsis.svg'); + background-color: $secondary-fg-color; + } + } +} + +.mx_WidgetCard_maxPinnedTooltip { + background-color: $notice-primary-color; + color: #ffffff; +} diff --git a/res/css/views/rooms/_AppsDrawer.scss b/res/css/views/rooms/_AppsDrawer.scss index 6be417f631..6e3ffbe5f0 100644 --- a/res/css/views/rooms/_AppsDrawer.scss +++ b/res/css/views/rooms/_AppsDrawer.scss @@ -15,90 +15,111 @@ See the License for the specific language governing permissions and limitations under the License. */ -/* -the tile title bar is 5 (top border) + 12 (title, buttons) + 5 (bottom padding) px = 22px -the body is assumed to be 300px (assumed by at least the sticker pickerm, perhaps elsewhere), -so the body height would be 300px - 22px (room for title bar) = 278px -BUT! the sticker picker also assumes it's a little less high than that because the iframe -for the sticker picker doesn't have any padding or margin on it's bottom. -so subtracking another 5px, which brings us at 273px. -*/ -$AppsDrawerBodyHeight: 273px; +$MiniAppTileHeight: 200px; .mx_AppsDrawer { - margin: 5px; -} + margin: 5px 5px 5px 18px; + position: relative; + display: flex; + flex-direction: column; + overflow: hidden; -.mx_AppsDrawer_hidden { - display: none; + .mx_AppsContainer_resizerHandle { + cursor: ns-resize; + border-radius: 3px; + + // Override styles from library + width: unset !important; + height: 4px !important; + + // This is positioned directly below frame + position: absolute; + bottom: -8px !important; // override from library + + // Together, these make the bar 64px wide + // These are also overridden from the library + left: calc(50% - 32px) !important; + right: calc(50% - 32px) !important; + } + + &:hover { + .mx_AppsContainer_resizerHandle { + opacity: 0.8; + background: $primary-fg-color; + } + } } .mx_AppsContainer { display: flex; flex-direction: row; - align-items: center; + align-items: stretch; justify-content: center; + height: 100%; + margin-bottom: 8px; +} + +.mx_AppsDrawer_minimised .mx_AppsContainer { + // override the re-resizable inline styles + height: inherit !important; + min-height: inherit !important; } .mx_AddWidget_button { order: 2; cursor: pointer; padding: 0; - margin: 5px auto 5px auto; + margin: -3px auto 5px 0; color: $accent-color; font-size: $font-12px; } -.mx_AddWidget_button_full_width { - max-width: 960px; -} - -.mx_SetAppURLDialog_input { - border-radius: 3px; - border: 1px solid $input-border-color; - padding: 9px; - color: $primary-hairline-color; - background-color: $primary-bg-color; - font-size: $font-15px; -} - .mx_AppTile { - max-width: 960px; width: 50%; - margin-right: 5px; border: 5px solid $widget-menu-bar-bg-color; border-radius: 4px; -} + display: flex; + flex-direction: column; -.mx_AppTile:last-child { - margin-right: 1px; + & + .mx_AppTile { + margin-left: 5px; + } } .mx_AppTileFullWidth { - max-width: 960px; width: 100%; - height: 100%; margin: 0; padding: 0; border: 5px solid $widget-menu-bar-bg-color; border-radius: 8px; + display: flex; + flex-direction: column; } .mx_AppTile_mini { - max-width: 960px; width: 100%; - height: 100%; margin: 0; padding: 0; + display: flex; + flex-direction: column; + height: $MiniAppTileHeight; } -.mx_AppTile_persistedWrapper { - height: $AppsDrawerBodyHeight; +.mx_AppTile.mx_AppTile_minimised, +.mx_AppTileFullWidth.mx_AppTile_minimised, +.mx_AppTile_mini.mx_AppTile_minimised { + height: 14px; } +.mx_AppTile .mx_AppTile_persistedWrapper, +.mx_AppTileFullWidth .mx_AppTile_persistedWrapper, .mx_AppTile_mini .mx_AppTile_persistedWrapper { - height: 114px; - min-width: 300px; + flex: 1; +} + +.mx_AppTile_persistedWrapper div { + width: 100%; + height: 100%; } .mx_AppTileMenuBar { @@ -110,6 +131,7 @@ $AppsDrawerBodyHeight: 273px; align-items: center; justify-content: space-between; cursor: pointer; + width: 100%; } .mx_AppTileMenuBar_expanded { @@ -172,15 +194,23 @@ $AppsDrawerBodyHeight: 273px; } .mx_AppTileBody { - height: $AppsDrawerBodyHeight; + height: 100%; width: 100%; overflow: hidden; } .mx_AppTileBody_mini { - height: 112px; + height: $MiniAppTileHeight; width: 100%; overflow: hidden; + border-radius: 8px; +} + +.mx_AppTile .mx_AppTileBody, +.mx_AppTileFullWidth .mx_AppTileBody, +.mx_AppTile_mini .mx_AppTileBody_mini { + height: inherit; + flex: 1; } .mx_AppTileBody_mini iframe { @@ -191,7 +221,7 @@ $AppsDrawerBodyHeight: 273px; .mx_AppTileBody iframe { width: 100%; - height: $AppsDrawerBodyHeight; + height: 100%; overflow: hidden; border: none; padding: 0; @@ -199,72 +229,6 @@ $AppsDrawerBodyHeight: 273px; display: block; } -.mx_AppTileMenuBarWidgetPadding { - margin-right: 5px; -} - -.mx_AppIconTile { - background-color: $lightbox-bg-color; - border: 1px solid rgba(0, 0, 0, 0); - width: 200px; - box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2); - transition: 0.3s; - border-radius: 3px; - margin: 5px; - display: inline-block; -} - -.mx_AppIconTile.mx_AppIconTile_active { - color: $accent-color; - border-color: $accent-color; -} - -.mx_AppIconTile:hover { - border: 1px solid $accent-color; - box-shadow: 0 0 10px 5px rgba(200, 200, 200, 0.5); -} - -.mx_AppIconTile_content { - padding: 2px 16px; - height: 60px; - overflow: hidden; -} - -.mx_AppIconTile_content h4 { - margin-top: 5px; - margin-bottom: 2px; -} - -.mx_AppIconTile_content p { - margin-top: 0; - margin-bottom: 5px; - font-size: smaller; -} - -.mx_AppIconTile_image { - padding: 10px; - max-width: 100px; - max-height: 100px; - width: auto; - height: auto; -} - -.mx_AppIconTile_imageContainer { - text-align: center; - width: 100%; - background-color: white; - border-radius: 3px 3px 0 0; - height: 155px; - display: flex; - justify-content: center; - align-items: center; -} - -form.mx_Custom_Widget_Form div { - margin-top: 10px; - margin-bottom: 10px; -} - .mx_AppPermissionWarning { text-align: center; background-color: $widget-menu-bar-bg-color; @@ -331,7 +295,7 @@ form.mx_Custom_Widget_Form div { align-items: center; font-weight: bold; position: relative; - height: $AppsDrawerBodyHeight; + height: 100%; } .mx_AppLoading .mx_Spinner { @@ -358,3 +322,16 @@ form.mx_Custom_Widget_Form div { .mx_AppLoading iframe { display: none; } + +.mx_AppsDrawer_minimised .mx_AppsContainer_resizerHandle { + 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/_EventTile.scss b/res/css/views/rooms/_EventTile.scss index eb0e1dd7b0..3b9a491db5 100644 --- a/res/css/views/rooms/_EventTile.scss +++ b/res/css/views/rooms/_EventTile.scss @@ -394,16 +394,6 @@ $left-gutter: 64px; opacity: 1; } -.mx_EventTile_e2eIcon_hidden { - display: none; -} - -/* always override hidden attribute for blocked and warning */ -.mx_EventTile_e2eIcon_hidden[src*="img/e2e-blocked.svg"], -.mx_EventTile_e2eIcon_hidden[src*="img/e2e-warning.svg"] { - display: block; -} - .mx_EventTile_keyRequestInfo { font-size: $font-12px; } diff --git a/res/css/views/rooms/_MemberInfo.scss b/res/css/views/rooms/_MemberInfo.scss index fb082843f1..182c280217 100644 --- a/res/css/views/rooms/_MemberInfo.scss +++ b/res/css/views/rooms/_MemberInfo.scss @@ -70,7 +70,7 @@ limitations under the License. } .mx_MemberInfo_avatar { - background: $tagpanel-bg-color; + background: $groupFilterPanel-bg-color; margin-bottom: 16px; } diff --git a/res/css/views/rooms/_MemberList.scss b/res/css/views/rooms/_MemberList.scss index 90667d41b4..f00907aeef 100644 --- a/res/css/views/rooms/_MemberList.scss +++ b/res/css/views/rooms/_MemberList.scss @@ -70,6 +70,10 @@ limitations under the License. } } +.mx_MemberList_query { + height: 16px; +} + .mx_MemberList_wrapper { padding: 10px; } @@ -92,11 +96,21 @@ limitations under the License. } .mx_MemberList_invite span { - background-image: url('$(res)/img/element-icons/room/invite.svg'); - background-repeat: no-repeat; - background-position: center left; - background-size: 20px; - padding: 8px 0 8px 25px; + padding: 8px 0; + display: inline-flex; + + &::before { + content: ''; + display: inline-block; + background-color: $button-fg-color; + mask-image: url('$(res)/img/element-icons/room/invite.svg'); + mask-position: center; + mask-repeat: no-repeat; + mask-size: 20px; + width: 20px; + height: 20px; + margin-right: 5px; + } } .mx_MemberList_inviteCommunity span { diff --git a/res/css/views/rooms/_MessageComposer.scss b/res/css/views/rooms/_MessageComposer.scss index a403a8dc4c..71c0db947e 100644 --- a/res/css/views/rooms/_MessageComposer.scss +++ b/res/css/views/rooms/_MessageComposer.scss @@ -217,7 +217,7 @@ limitations under the License. } } - &.mx_MessageComposer_hangup::before { + &.mx_MessageComposer_hangup:not(.mx_AccessibleButton_disabled)::before { background-color: $warning-color; } } diff --git a/res/css/views/rooms/_RoomHeader.scss b/res/css/views/rooms/_RoomHeader.scss index a880a7bee2..d240877507 100644 --- a/res/css/views/rooms/_RoomHeader.scss +++ b/res/css/views/rooms/_RoomHeader.scss @@ -236,10 +236,6 @@ limitations under the License. } } -.mx_RoomHeader_settingsButton::before { - mask-image: url('$(res)/img/element-icons/settings.svg'); -} - .mx_RoomHeader_forgetButton::before { mask-image: url('$(res)/img/element-icons/leave.svg'); width: 26px; @@ -249,14 +245,6 @@ limitations under the License. mask-image: url('$(res)/img/element-icons/room/search-inset.svg'); } -.mx_RoomHeader_shareButton::before { - mask-image: url('$(res)/img/element-icons/room/share.svg'); -} - -.mx_RoomHeader_manageIntegsButton::before { - mask-image: url('$(res)/img/element-icons/room/integrations.svg'); -} - .mx_RoomHeader_showPanel { height: 16px; } diff --git a/res/css/views/rooms/_RoomRecoveryReminder.scss b/res/css/views/rooms/_RoomRecoveryReminder.scss deleted file mode 100644 index 09b28ae235..0000000000 --- a/res/css/views/rooms/_RoomRecoveryReminder.scss +++ /dev/null @@ -1,39 +0,0 @@ -/* -Copyright 2018 New Vector Ltd - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -.mx_RoomRecoveryReminder { - display: flex; - flex-direction: column; - text-align: center; - background-color: $room-warning-bg-color; - padding: 20px; - border: 1px solid $primary-hairline-color; - border-bottom: unset; -} - -.mx_RoomRecoveryReminder_header { - font-weight: bold; - margin-bottom: 1em; -} - -.mx_RoomRecoveryReminder_body { - margin-bottom: 1em; -} - -.mx_RoomRecoveryReminder_secondary { - font-size: 90%; - margin-top: 1em; -} diff --git a/res/css/views/rooms/_SearchBar.scss b/res/css/views/rooms/_SearchBar.scss index fecc8d78d8..d9f730a8b6 100644 --- a/res/css/views/rooms/_SearchBar.scss +++ b/res/css/views/rooms/_SearchBar.scss @@ -68,3 +68,4 @@ limitations under the License. cursor: pointer; } } + diff --git a/res/css/views/rooms/_Stickers.scss b/res/css/views/rooms/_Stickers.scss index 4bd45631cc..d99276b70a 100644 --- a/res/css/views/rooms/_Stickers.scss +++ b/res/css/views/rooms/_Stickers.scss @@ -7,12 +7,19 @@ height: 300px; } -#mx_persistedElement_stickerPicker .mx_AppTileFullWidth { - height: unset; - box-sizing: border-box; - border-left: none; - border-right: none; - border-bottom: none; +#mx_persistedElement_stickerPicker { + .mx_AppTileFullWidth { + height: unset; + box-sizing: border-box; + border-left: none; + border-right: none; + border-bottom: none; + } + + iframe { + // Sticker picker depends on the fixed height previously used for all tiles + height: 273px; + } } .mx_Stickers_contentPlaceholder { diff --git a/res/css/views/settings/_AvatarSetting.scss b/res/css/views/settings/_AvatarSetting.scss index eddcf9f55a..a350605ab1 100644 --- a/res/css/views/settings/_AvatarSetting.scss +++ b/res/css/views/settings/_AvatarSetting.scss @@ -1,5 +1,5 @@ /* -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2019, 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. @@ -15,13 +15,56 @@ limitations under the License. */ .mx_AvatarSetting_avatar { - width: $font-88px; - height: $font-88px; - margin-left: 13px; + width: 90px; + min-width: 90px; // so it doesn't get crushed by the flexbox in languages with longer words + height: 90px; + margin-top: 8px; position: relative; + .mx_AvatarSetting_hover { + transition: opacity $hover-transition; + + // position to place the hover bg over the entire thing + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + + pointer-events: none; // let the pointer fall through the underlying thing + + line-height: 90px; + text-align: center; + + > span { + color: #fff; // hardcoded to contrast with background + position: relative; // tricks the layout engine into putting this on top of the bg + font-weight: 500; + } + + .mx_AvatarSetting_hoverBg { + // absolute position to lazily fill the entire container + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + + opacity: 0.5; + background-color: $settings-profile-overlay-placeholder-fg-color; + border-radius: 90px; + } + } + + &.mx_AvatarSetting_avatar_hovering .mx_AvatarSetting_hover { + opacity: 1; + } + + &:not(.mx_AvatarSetting_avatar_hovering) .mx_AvatarSetting_hover { + opacity: 0; + } + & > * { - width: $font-88px; box-sizing: border-box; } @@ -30,7 +73,7 @@ limitations under the License. } .mx_AccessibleButton.mx_AccessibleButton_kind_link_sm { - color: $button-danger-bg-color; + width: 100%; } & > img { @@ -41,8 +84,10 @@ limitations under the License. & > img, .mx_AvatarSetting_avatarPlaceholder { display: block; - height: $font-88px; - border-radius: 4px; + height: 90px; + width: inherit; + border-radius: 90px; + cursor: pointer; } .mx_AvatarSetting_avatarPlaceholder::before { @@ -58,6 +103,29 @@ limitations under the License. left: 0; right: 0; } + + .mx_AvatarSetting_uploadButton { + width: 32px; + height: 32px; + border-radius: 32px; + background-color: $settings-profile-button-bg-color; + + position: absolute; + bottom: 0; + right: 0; + } + + .mx_AvatarSetting_uploadButton::before { + content: ""; + display: block; + width: 100%; + height: 100%; + mask-repeat: no-repeat; + mask-position: center; + mask-size: 55%; + background-color: $settings-profile-button-fg-color; + mask-image: url('$(res)/img/feather-customised/edit.svg'); + } } .mx_AvatarSetting_avatar .mx_AvatarSetting_avatarPlaceholder { diff --git a/res/css/views/settings/_CrossSigningPanel.scss b/res/css/views/settings/_CrossSigningPanel.scss index fa9f76a963..12a0e36835 100644 --- a/res/css/views/settings/_CrossSigningPanel.scss +++ b/res/css/views/settings/_CrossSigningPanel.scss @@ -28,4 +28,8 @@ limitations under the License. .mx_CrossSigningPanel_buttonRow { margin: 1em 0; + + :nth-child(n + 1) { + margin-inline-end: 10px; + } } diff --git a/res/css/views/settings/_ProfileSettings.scss b/res/css/views/settings/_ProfileSettings.scss index 58624d1597..732cbedf02 100644 --- a/res/css/views/settings/_ProfileSettings.scss +++ b/res/css/views/settings/_ProfileSettings.scss @@ -1,5 +1,5 @@ /* -Copyright 2019 New Vector Ltd +Copyright 2019, 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. @@ -20,6 +20,13 @@ limitations under the License. .mx_ProfileSettings_controls { flex-grow: 1; + margin-right: 54px; + + // We put the header under the controls with some minor styling to cheat + // alignment of the field with the avatar + .mx_SettingsTab_subheading { + margin-top: 0; + } } .mx_ProfileSettings_controls .mx_Field #profileTopic { @@ -41,3 +48,17 @@ limitations under the License. .mx_ProfileSettings_avatarUpload { display: none; } + +.mx_ProfileSettings_profileForm { + @mixin mx_Settings_fullWidthField; + border-bottom: 1px solid $menu-border-color; +} + +.mx_ProfileSettings_buttons { + margin-top: 10px; // 18px is already accounted for by the

above the buttons + margin-bottom: 28px; + + > .mx_AccessibleButton_kind_link { + padding-left: 0; // to align with left side + } +} diff --git a/res/css/views/settings/_KeyBackupPanel.scss b/res/css/views/settings/_SecureBackupPanel.scss similarity index 52% rename from res/css/views/settings/_KeyBackupPanel.scss rename to res/css/views/settings/_SecureBackupPanel.scss index 872162caad..a9dab06b57 100644 --- a/res/css/views/settings/_KeyBackupPanel.scss +++ b/res/css/views/settings/_SecureBackupPanel.scss @@ -1,6 +1,6 @@ /* Copyright 2018 New Vector Ltd -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2019, 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. @@ -15,23 +15,39 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_KeyBackupPanel_sigValid, .mx_KeyBackupPanel_sigInvalid, -.mx_KeyBackupPanel_deviceVerified, .mx_KeyBackupPanel_deviceNotVerified { +.mx_SecureBackupPanel_sigValid, .mx_SecureBackupPanel_sigInvalid, +.mx_SecureBackupPanel_deviceVerified, .mx_SecureBackupPanel_deviceNotVerified { font-weight: bold; } -.mx_KeyBackupPanel_sigValid, .mx_KeyBackupPanel_deviceVerified { +.mx_SecureBackupPanel_sigValid, .mx_SecureBackupPanel_deviceVerified { color: $e2e-verified-color; } -.mx_KeyBackupPanel_sigInvalid, .mx_KeyBackupPanel_deviceNotVerified { +.mx_SecureBackupPanel_sigInvalid, .mx_SecureBackupPanel_deviceNotVerified { color: $e2e-warning-color; } -.mx_KeyBackupPanel_deviceName { +.mx_SecureBackupPanel_deviceName { font-style: italic; } -.mx_KeyBackupPanel_buttonRow { +.mx_SecureBackupPanel_buttonRow { margin: 1em 0; + + :nth-child(n + 1) { + margin-inline-end: 10px; + } +} + +.mx_SecureBackupPanel_statusList { + border-spacing: 0; + + td { + padding: 0; + + &:first-of-type { + padding-inline-end: 1em; + } + } } diff --git a/res/css/views/settings/tabs/_SettingsTab.scss b/res/css/views/settings/tabs/_SettingsTab.scss index e3a61e6825..892f5fe744 100644 --- a/res/css/views/settings/tabs/_SettingsTab.scss +++ b/res/css/views/settings/tabs/_SettingsTab.scss @@ -1,5 +1,5 @@ /* -Copyright 2019 New Vector Ltd +Copyright 2019, 2020 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. @@ -14,6 +14,10 @@ See the License for the specific language governing permissions and limitations under the License. */ +.mx_SettingsTab { + color: $muted-fg-color; +} + .mx_SettingsTab_warningText { color: $warning-color; } @@ -22,6 +26,7 @@ limitations under the License. font-size: $font-20px; font-weight: 600; color: $primary-fg-color; + margin-bottom: 10px; } .mx_SettingsTab_heading:nth-child(n + 2) { diff --git a/res/css/views/settings/tabs/user/_GeneralUserSettingsTab.scss b/res/css/views/settings/tabs/user/_GeneralUserSettingsTab.scss index 6c9b89cf5a..8b73e69031 100644 --- a/res/css/views/settings/tabs/user/_GeneralUserSettingsTab.scss +++ b/res/css/views/settings/tabs/user/_GeneralUserSettingsTab.scss @@ -22,6 +22,13 @@ limitations under the License. margin-top: 0; } +// TODO: Make this selector less painful +.mx_GeneralUserSettingsTab_accountSection .mx_SettingsTab_subheading:nth-child(n + 1), +.mx_GeneralUserSettingsTab_discovery .mx_SettingsTab_subheading:nth-child(n + 2), +.mx_SetIdServer .mx_SettingsTab_subheading { + margin-top: 24px; +} + .mx_GeneralUserSettingsTab_accountSection .mx_Spinner, .mx_GeneralUserSettingsTab_discovery .mx_Spinner { // Move the spinner to the left side of the container (default center) diff --git a/res/css/views/voip/_CallContainer.scss b/res/css/views/voip/_CallContainer.scss index 8d1b68dd99..759797ae7b 100644 --- a/res/css/views/voip/_CallContainer.scss +++ b/res/css/views/voip/_CallContainer.scss @@ -23,9 +23,16 @@ limitations under the License. z-index: 100; box-shadow: 0px 14px 24px rgba(0, 0, 0, 0.08); - cursor: pointer; + // Disable pointer events for Jitsi widgets to function. Direct + // calls have their own cursor and behaviour, but we need to make + // sure the cursor hits the iframe for Jitsi which will be at a + // different level. + pointer-events: none; .mx_CallPreview { + pointer-events: initial; // restore pointer events so the user can leave/interact + cursor: pointer; + .mx_VideoView { width: 350px; } @@ -36,16 +43,23 @@ limitations under the License. } } + .mx_AppTile_persistedWrapper div { + min-width: 350px; + } + .mx_IncomingCallBox { min-width: 250px; background-color: $primary-bg-color; padding: 8px; + pointer-events: initial; // restore pointer events so the user can accept/decline + cursor: pointer; + .mx_IncomingCallBox_CallerInfo { display: flex; direction: row; - img { + img, .mx_BaseAvatar_initial { margin: 8px; } diff --git a/res/img/e2e/disabled.svg b/res/img/e2e/disabled.svg new file mode 100644 index 0000000000..2f6110a36a --- /dev/null +++ b/res/img/e2e/disabled.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/res/img/e2e/normal.svg b/res/img/e2e/normal.svg index 23ca78e44d..83b544a326 100644 --- a/res/img/e2e/normal.svg +++ b/res/img/e2e/normal.svg @@ -1,3 +1,3 @@ - - + + diff --git a/res/img/e2e/verified.svg b/res/img/e2e/verified.svg index ac4827baed..f90d9db554 100644 --- a/res/img/e2e/verified.svg +++ b/res/img/e2e/verified.svg @@ -1,3 +1,3 @@ - - + + diff --git a/res/img/e2e/warning.svg b/res/img/e2e/warning.svg index d42922892a..58f5c3b7d1 100644 --- a/res/img/e2e/warning.svg +++ b/res/img/e2e/warning.svg @@ -1,3 +1,3 @@ - - + + diff --git a/res/img/element-desktop-logo.svg b/res/img/element-desktop-logo.svg new file mode 100644 index 0000000000..2031733ce3 --- /dev/null +++ b/res/img/element-desktop-logo.svg @@ -0,0 +1,157 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/res/img/element-icons/add-photo.svg b/res/img/element-icons/add-photo.svg new file mode 100644 index 0000000000..bde5253bea --- /dev/null +++ b/res/img/element-icons/add-photo.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/res/img/element-icons/home.svg b/res/img/element-icons/home.svg new file mode 100644 index 0000000000..a6c15456ff --- /dev/null +++ b/res/img/element-icons/home.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/img/element-icons/info.svg b/res/img/element-icons/info.svg new file mode 100644 index 0000000000..b5769074ab --- /dev/null +++ b/res/img/element-icons/info.svg @@ -0,0 +1,4 @@ + + + + diff --git a/res/img/element-icons/room/default_app.svg b/res/img/element-icons/room/default_app.svg new file mode 100644 index 0000000000..08734170df --- /dev/null +++ b/res/img/element-icons/room/default_app.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/res/img/element-icons/room/default_cal.svg b/res/img/element-icons/room/default_cal.svg new file mode 100644 index 0000000000..5bced115cf --- /dev/null +++ b/res/img/element-icons/room/default_cal.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/res/img/element-icons/room/default_clock.svg b/res/img/element-icons/room/default_clock.svg new file mode 100644 index 0000000000..cc21716d15 --- /dev/null +++ b/res/img/element-icons/room/default_clock.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/res/img/element-icons/room/default_doc.svg b/res/img/element-icons/room/default_doc.svg new file mode 100644 index 0000000000..93e7507be3 --- /dev/null +++ b/res/img/element-icons/room/default_doc.svg @@ -0,0 +1,4 @@ + + + + diff --git a/res/img/element-icons/room/ellipsis.svg b/res/img/element-icons/room/ellipsis.svg new file mode 100644 index 0000000000..db1db6ec8b --- /dev/null +++ b/res/img/element-icons/room/ellipsis.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/res/img/element-icons/room/invite.svg b/res/img/element-icons/room/invite.svg index f713e57d73..655f9f118a 100644 --- a/res/img/element-icons/room/invite.svg +++ b/res/img/element-icons/room/invite.svg @@ -1,3 +1,3 @@ - - + + diff --git a/res/img/element-icons/room/pin-upright.svg b/res/img/element-icons/room/pin-upright.svg new file mode 100644 index 0000000000..9297f62a02 --- /dev/null +++ b/res/img/element-icons/room/pin-upright.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/res/img/element-icons/room/room-summary.svg b/res/img/element-icons/room/room-summary.svg new file mode 100644 index 0000000000..b6ac258b18 --- /dev/null +++ b/res/img/element-icons/room/room-summary.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/themes/dark/css/_dark.scss b/res/themes/dark/css/_dark.scss index d48abf6a4c..6e0c9acdfe 100644 --- a/res/themes/dark/css/_dark.scss +++ b/res/themes/dark/css/_dark.scss @@ -39,7 +39,7 @@ $info-plinth-fg-color: #888; $preview-bar-bg-color: $header-panel-bg-color; -$tagpanel-bg-color: rgba(38, 39, 43, 0.82); +$groupFilterPanel-bg-color: rgba(38, 39, 43, 0.82); $inverted-bg-color: $base-color; // used by AddressSelector @@ -87,11 +87,10 @@ $dialog-background-bg-color: $header-panel-bg-color; $lightbox-background-bg-color: #000; $settings-grey-fg-color: #a2a2a2; -$settings-profile-placeholder-bg-color: #e7e7e7; -$settings-profile-overlay-bg-color: #000; -$settings-profile-overlay-placeholder-bg-color: transparent; -$settings-profile-overlay-fg-color: #fff; +$settings-profile-placeholder-bg-color: #21262c; $settings-profile-overlay-placeholder-fg-color: #454545; +$settings-profile-button-bg-color: #e7e7e7; +$settings-profile-button-fg-color: $settings-profile-overlay-placeholder-fg-color; $settings-subsection-fg-color: $text-secondary-color; $topleftmenu-color: $text-primary-color; @@ -99,7 +98,7 @@ $roomheader-color: $text-primary-color; $roomheader-bg-color: $bg-color; $roomheader-addroom-bg-color: rgba(92, 100, 112, 0.3); $roomheader-addroom-fg-color: $text-primary-color; -$tagpanel-button-color: $header-panel-text-primary-color; +$groupFilterPanel-button-color: $header-panel-text-primary-color; $groupheader-button-color: $header-panel-text-primary-color; $rightpanel-button-color: $header-panel-text-primary-color; $icon-button-color: #8E99A4; @@ -119,6 +118,8 @@ $roomlist-bg-color: rgba(33, 38, 44, 0.90); $roomlist-header-color: $tertiary-fg-color; $roomsublist-divider-color: $primary-fg-color; +$groupFilterPanel-divider-color: $roomlist-header-color; + $roomtile-preview-color: $secondary-fg-color; $roomtile-default-badge-bg-color: #61708b; $roomtile-selected-bg-color: rgba(141, 151, 165, 0.2); @@ -186,7 +187,7 @@ $reaction-row-button-selected-border-color: $accent-color; $kbd-border-color: #000000; -$tooltip-timeline-bg-color: $tagpanel-bg-color; +$tooltip-timeline-bg-color: $groupFilterPanel-bg-color; $tooltip-timeline-fg-color: #ffffff; $interactive-tooltip-bg-color: $base-color; @@ -201,7 +202,7 @@ $appearance-tab-border-color: $room-highlight-color; // blur amounts for left left panel (only for element theme, used in _mods.scss) $roomlist-background-blur-amount: 60px; -$tagpanel-background-blur-amount: 30px; +$groupFilterPanel-background-blur-amount: 30px; $composer-shadow-color: rgba(0, 0, 0, 0.28); diff --git a/res/themes/dark/css/dark.scss b/res/themes/dark/css/dark.scss index 6d9dc7352c..f9695018e4 100644 --- a/res/themes/dark/css/dark.scss +++ b/res/themes/dark/css/dark.scss @@ -3,7 +3,7 @@ @import "../../light/css/_fonts.scss"; @import "../../light/css/_light.scss"; // important this goes before _mods, -// as $tagpanel-background-blur-amount and +// as $groupFilterPanel-background-blur-amount and // $roomlist-background-blur-amount // are overridden in _dark.scss @import "_dark.scss"; diff --git a/res/themes/legacy-dark/css/_legacy-dark.scss b/res/themes/legacy-dark/css/_legacy-dark.scss index 4ab5f99942..efde7b3747 100644 --- a/res/themes/legacy-dark/css/_legacy-dark.scss +++ b/res/themes/legacy-dark/css/_legacy-dark.scss @@ -37,8 +37,8 @@ $info-plinth-fg-color: #888; $preview-bar-bg-color: $header-panel-bg-color; -$tagpanel-bg-color: $base-color; -$inverted-bg-color: $tagpanel-bg-color; +$groupFilterPanel-bg-color: $base-color; +$inverted-bg-color: $groupFilterPanel-bg-color; // used by AddressSelector $selected-color: $room-highlight-color; @@ -86,17 +86,16 @@ $lightbox-background-bg-color: #000; $settings-grey-fg-color: #a2a2a2; $settings-profile-placeholder-bg-color: #e7e7e7; -$settings-profile-overlay-bg-color: #000; -$settings-profile-overlay-placeholder-bg-color: transparent; -$settings-profile-overlay-fg-color: #fff; $settings-profile-overlay-placeholder-fg-color: #454545; +$settings-profile-button-bg-color: #e7e7e7; +$settings-profile-button-fg-color: $settings-profile-overlay-placeholder-fg-color; $settings-subsection-fg-color: $text-secondary-color; $topleftmenu-color: $text-primary-color; $roomheader-color: $text-primary-color; $roomheader-addroom-bg-color: #3c4556; // $search-placeholder-color at 0.5 opacity $roomheader-addroom-fg-color: $text-primary-color; -$tagpanel-button-color: $header-panel-text-primary-color; +$groupFilterPanel-button-color: $header-panel-text-primary-color; $groupheader-button-color: $header-panel-text-primary-color; $rightpanel-button-color: $header-panel-text-primary-color; $icon-button-color: $header-panel-text-primary-color; @@ -116,6 +115,8 @@ $roomlist-bg-color: $header-panel-bg-color; $roomsublist-divider-color: $primary-fg-color; +$groupFilterPanel-divider-color: $roomlist-header-color; + $roomtile-preview-color: #9e9e9e; $roomtile-default-badge-bg-color: #61708b; $roomtile-selected-bg-color: #1A1D23; @@ -181,7 +182,7 @@ $reaction-row-button-selected-border-color: $accent-color; $kbd-border-color: #000000; -$tooltip-timeline-bg-color: $tagpanel-bg-color; +$tooltip-timeline-bg-color: $groupFilterPanel-bg-color; $tooltip-timeline-fg-color: #ffffff; $interactive-tooltip-bg-color: $base-color; diff --git a/res/themes/legacy-light/css/_legacy-light.scss b/res/themes/legacy-light/css/_legacy-light.scss index 6e66964fdf..f77226cbca 100644 --- a/res/themes/legacy-light/css/_legacy-light.scss +++ b/res/themes/legacy-light/css/_legacy-light.scss @@ -67,8 +67,8 @@ $preview-bar-bg-color: #f7f7f7; $secondary-accent-color: #f2f5f8; $tertiary-accent-color: #d3efe1; -$tagpanel-bg-color: #27303a; -$inverted-bg-color: $tagpanel-bg-color; +$groupFilterPanel-bg-color: #27303a; +$inverted-bg-color: $groupFilterPanel-bg-color; // used by RoomDirectory permissions $plinth-bg-color: $secondary-accent-color; @@ -144,10 +144,9 @@ $blockquote-fg-color: #777; $settings-grey-fg-color: #a2a2a2; $settings-profile-placeholder-bg-color: #e7e7e7; -$settings-profile-overlay-bg-color: #000; -$settings-profile-overlay-placeholder-bg-color: transparent; -$settings-profile-overlay-fg-color: #fff; $settings-profile-overlay-placeholder-fg-color: #2e2f32; +$settings-profile-button-bg-color: #e7e7e7; +$settings-profile-button-fg-color: $settings-profile-overlay-placeholder-fg-color; $settings-subsection-fg-color: #61708b; $voip-decline-color: #f48080; @@ -163,7 +162,7 @@ $roomheader-color: #45474a; $roomheader-bg-color: $primary-bg-color; $roomheader-addroom-bg-color: #91a1c0; $roomheader-addroom-fg-color: $accent-fg-color; -$tagpanel-button-color: #91a1c0; +$groupFilterPanel-button-color: #91a1c0; $groupheader-button-color: #91a1c0; $rightpanel-button-color: #91a1c0; $icon-button-color: #91a1c0; @@ -183,6 +182,8 @@ $roomlist-bg-color: $header-panel-bg-color; $roomlist-header-color: $primary-fg-color; $roomsublist-divider-color: $primary-fg-color; +$groupFilterPanel-divider-color: $roomlist-header-color; + $roomtile-preview-color: #9e9e9e; $roomtile-default-badge-bg-color: #61708b; $roomtile-selected-bg-color: #fff; @@ -304,7 +305,7 @@ $reaction-row-button-selected-border-color: $accent-color; $kbd-border-color: $reaction-row-button-border-color; -$tooltip-timeline-bg-color: $tagpanel-bg-color; +$tooltip-timeline-bg-color: $groupFilterPanel-bg-color; $tooltip-timeline-fg-color: #ffffff; $interactive-tooltip-bg-color: #27303a; diff --git a/res/themes/light-custom/css/_custom.scss b/res/themes/light-custom/css/_custom.scss index b830e86e02..1b9254d100 100644 --- a/res/themes/light-custom/css/_custom.scss +++ b/res/themes/light-custom/css/_custom.scss @@ -49,7 +49,7 @@ $roomtile-selected-bg-color: var(--roomlist-highlights-color); // // --sidebar-color $interactive-tooltip-bg-color: var(--sidebar-color); -$tagpanel-bg-color: var(--sidebar-color); +$groupFilterPanel-bg-color: var(--sidebar-color); $tooltip-timeline-bg-color: var(--sidebar-color); $dialog-backdrop-color: var(--sidebar-color-50pct); $roomlist-button-bg-color: var(--sidebar-color-15pct); @@ -124,15 +124,15 @@ $pinned-unread-color: var(--warning-color); $warning-color: var(--warning-color); $button-danger-disabled-bg-color: var(--warning-color-50pct); // still needs alpha at 0.5 // -// --username colors -$username-variant1-color: var(--username-colors_1, $username-variant1-color); -$username-variant2-color: var(--username-colors_2, $username-variant2-color); -$username-variant3-color: var(--username-colors_3, $username-variant3-color); -$username-variant4-color: var(--username-colors_4, $username-variant4-color); -$username-variant5-color: var(--username-colors_5, $username-variant5-color); -$username-variant6-color: var(--username-colors_6, $username-variant6-color); -$username-variant7-color: var(--username-colors_7, $username-variant7-color); -$username-variant8-color: var(--username-colors_8, $username-variant8-color); +// --username colors (which use a 0-based index) +$username-variant1-color: var(--username-colors_0, $username-variant1-color); +$username-variant2-color: var(--username-colors_1, $username-variant2-color); +$username-variant3-color: var(--username-colors_2, $username-variant3-color); +$username-variant4-color: var(--username-colors_3, $username-variant4-color); +$username-variant5-color: var(--username-colors_4, $username-variant5-color); +$username-variant6-color: var(--username-colors_5, $username-variant6-color); +$username-variant7-color: var(--username-colors_6, $username-variant7-color); +$username-variant8-color: var(--username-colors_7, $username-variant8-color); // // --timeline-highlights-color $event-selected-color: var(--timeline-highlights-color); diff --git a/res/themes/light/css/_light.scss b/res/themes/light/css/_light.scss index ceb8d5677c..d137373bd5 100644 --- a/res/themes/light/css/_light.scss +++ b/res/themes/light/css/_light.scss @@ -62,7 +62,7 @@ $preview-bar-bg-color: #f7f7f7; $secondary-accent-color: #f2f5f8; $tertiary-accent-color: #d3efe1; -$tagpanel-bg-color: rgba(232, 232, 232, 0.77); +$groupFilterPanel-bg-color: rgba(232, 232, 232, 0.77); // used by RoomDirectory permissions $plinth-bg-color: $secondary-accent-color; @@ -137,11 +137,10 @@ $blockquote-bar-color: #ddd; $blockquote-fg-color: #777; $settings-grey-fg-color: #a2a2a2; -$settings-profile-placeholder-bg-color: #e7e7e7; -$settings-profile-overlay-bg-color: #000; -$settings-profile-overlay-placeholder-bg-color: transparent; -$settings-profile-overlay-fg-color: #fff; +$settings-profile-placeholder-bg-color: #f4f6fa; $settings-profile-overlay-placeholder-fg-color: #2e2f32; +$settings-profile-button-bg-color: #e7e7e7; +$settings-profile-button-fg-color: $settings-profile-overlay-placeholder-fg-color; $settings-subsection-fg-color: #61708b; $voip-decline-color: #f48080; @@ -157,7 +156,7 @@ $roomheader-color: #45474a; $roomheader-bg-color: $primary-bg-color; $roomheader-addroom-bg-color: rgba(92, 100, 112, 0.2); $roomheader-addroom-fg-color: #5c6470; -$tagpanel-button-color: #91A1C0; +$groupFilterPanel-button-color: #91A1C0; $groupheader-button-color: #91A1C0; $rightpanel-button-color: #91A1C0; $icon-button-color: #C1C6CD; @@ -177,6 +176,8 @@ $roomlist-bg-color: rgba(245, 245, 245, 0.90); $roomlist-header-color: $tertiary-fg-color; $roomsublist-divider-color: $primary-fg-color; +$groupFilterPanel-divider-color: $roomlist-header-color; + $roomtile-preview-color: $secondary-fg-color; $roomtile-default-badge-bg-color: #61708b; $roomtile-selected-bg-color: #FFF; @@ -319,7 +320,7 @@ $appearance-tab-border-color: $input-darker-bg-color; // blur amounts for left left panel (only for element theme, used in _mods.scss) $roomlist-background-blur-amount: 40px; -$tagpanel-background-blur-amount: 20px; +$groupFilterPanel-background-blur-amount: 20px; $composer-shadow-color: rgba(0, 0, 0, 0.04); diff --git a/res/themes/light/css/_mods.scss b/res/themes/light/css/_mods.scss index 9a59acba8e..30aaeedf8f 100644 --- a/res/themes/light/css/_mods.scss +++ b/res/themes/light/css/_mods.scss @@ -6,14 +6,14 @@ @supports (backdrop-filter: none) { .mx_LeftPanel { - background-image: var(--avatar-url); + background-image: var(--avatar-url, unset); background-repeat: no-repeat; background-size: cover; background-position: left top; } - .mx_TagPanel { - backdrop-filter: blur($tagpanel-background-blur-amount); + .mx_GroupFilterPanel { + backdrop-filter: blur($groupFilterPanel-background-blur-amount); } .mx_LeftPanel .mx_LeftPanel_roomListContainer { diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts index 86ee995a13..ed28a5c479 100644 --- a/src/@types/global.d.ts +++ b/src/@types/global.d.ts @@ -14,6 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ +import "matrix-js-sdk/src/@types/global"; // load matrix-js-sdk's type extensions first import * as ModernizrStatic from "modernizr"; import ContentMessages from "../ContentMessages"; import { IMatrixClientPeg } from "../MatrixClientPeg"; @@ -27,10 +28,17 @@ import {ModalManager} from "../Modal"; import SettingsStore from "../settings/SettingsStore"; import {ActiveRoomObserver} from "../ActiveRoomObserver"; import {Notifier} from "../Notifier"; +import type {Renderer} from "react-dom"; +import RightPanelStore from "../stores/RightPanelStore"; +import WidgetStore from "../stores/WidgetStore"; +import CallHandler from "../CallHandler"; +import {Analytics} from "../Analytics"; +import UserActivity from "../UserActivity"; declare global { interface Window { Modernizr: ModernizrStatic; + matrixChat: ReturnType; mxMatrixClientPeg: IMatrixClientPeg; Olm: { init: () => Promise; @@ -47,6 +55,11 @@ declare global { singletonModalManager: ModalManager; mxSettingsStore: SettingsStore; mxNotifier: typeof Notifier; + mxRightPanelStore: RightPanelStore; + mxWidgetStore: WidgetStore; + mxCallHandler: CallHandler; + mxAnalytics: Analytics; + mxUserActivity: UserActivity; } interface Document { @@ -56,6 +69,9 @@ declare global { interface Navigator { userLanguage?: string; + // https://github.com/Microsoft/TypeScript/issues/19473 + // https://developer.mozilla.org/en-US/docs/Web/API/MediaSession + mediaSession: any; } interface StorageEstimate { diff --git a/src/@types/sanitize-html.ts b/src/@types/sanitize-html.ts new file mode 100644 index 0000000000..4cada29845 --- /dev/null +++ b/src/@types/sanitize-html.ts @@ -0,0 +1,23 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import sanitizeHtml from 'sanitize-html'; + +export interface IExtendedSanitizeOptions extends sanitizeHtml.IOptions { + // This option only exists in 2.x RCs so far, so not yet present in the + // separate type definition module. + nestingLimit?: number; +} diff --git a/src/Analytics.js b/src/Analytics.tsx similarity index 73% rename from src/Analytics.js rename to src/Analytics.tsx index 9966d0845e..212bfd3757 100644 --- a/src/Analytics.js +++ b/src/Analytics.tsx @@ -17,7 +17,7 @@ limitations under the License. import React from 'react'; -import { getCurrentLanguage, _t, _td } from './languageHandler'; +import {getCurrentLanguage, _t, _td, IVariables} from './languageHandler'; import PlatformPeg from './PlatformPeg'; import SdkConfig from './SdkConfig'; import Modal from './Modal'; @@ -27,7 +27,7 @@ const hashRegex = /#\/(groups?|room|user|settings|register|login|forgot_password const hashVarRegex = /#\/(group|room|user)\/.*$/; // Remove all but the first item in the hash path. Redact unexpected hashes. -function getRedactedHash(hash) { +function getRedactedHash(hash: string): string { // Don't leak URLs we aren't expecting - they could contain tokens/PII const match = hashRegex.exec(hash); if (!match) { @@ -44,7 +44,7 @@ function getRedactedHash(hash) { // Return the current origin, path and hash separated with a `/`. This does // not include query parameters. -function getRedactedUrl() { +function getRedactedUrl(): string { const { origin, hash } = window.location; let { pathname } = window.location; @@ -56,7 +56,25 @@ function getRedactedUrl() { return origin + pathname + getRedactedHash(hash); } -const customVariables = { +interface IData { + /* eslint-disable camelcase */ + gt_ms?: string; + e_c?: string; + e_a?: string; + e_n?: string; + e_v?: string; + ping?: string; + /* eslint-enable camelcase */ +} + +interface IVariable { + id: number; + expl: string; // explanation + example: string; // example value + getTextVariables?(): IVariables; // object to pass as 2nd argument to `_t` +} + +const customVariables: Record = { // The Matomo installation at https://matomo.riot.im is currently configured // with a limit of 10 custom variables. 'App Platform': { @@ -120,7 +138,7 @@ const customVariables = { }, }; -function whitelistRedact(whitelist, str) { +function whitelistRedact(whitelist: string[], str: string): string { if (whitelist.includes(str)) return str; return ''; } @@ -130,7 +148,7 @@ const CREATION_TS_KEY = "mx_Riot_Analytics_cts"; const VISIT_COUNT_KEY = "mx_Riot_Analytics_vc"; const LAST_VISIT_TS_KEY = "mx_Riot_Analytics_lvts"; -function getUid() { +function getUid(): string { try { let data = localStorage && localStorage.getItem(UID_KEY); if (!data && localStorage) { @@ -145,97 +163,105 @@ function getUid() { const HEARTBEAT_INTERVAL = 30 * 1000; // seconds -class Analytics { +export class Analytics { + private baseUrl: URL = null; + private siteId: string = null; + private visitVariables: Record = {}; // {[id: number]: [name: string, value: string]} + private firstPage = true; + private heartbeatIntervalID: number = null; + + private readonly creationTs: string; + private readonly lastVisitTs: string; + private readonly visitCount: string; + constructor() { - this.baseUrl = null; - this.siteId = null; - this.visitVariables = {}; - - this.firstPage = true; - this._heartbeatIntervalID = null; - this.creationTs = localStorage && localStorage.getItem(CREATION_TS_KEY); if (!this.creationTs && localStorage) { - localStorage.setItem(CREATION_TS_KEY, this.creationTs = new Date().getTime()); + localStorage.setItem(CREATION_TS_KEY, this.creationTs = String(new Date().getTime())); } this.lastVisitTs = localStorage && localStorage.getItem(LAST_VISIT_TS_KEY); - this.visitCount = localStorage && localStorage.getItem(VISIT_COUNT_KEY) || 0; + this.visitCount = localStorage && localStorage.getItem(VISIT_COUNT_KEY) || "0"; + this.visitCount = String(parseInt(this.visitCount, 10) + 1); // increment if (localStorage) { - localStorage.setItem(VISIT_COUNT_KEY, parseInt(this.visitCount, 10) + 1); + localStorage.setItem(VISIT_COUNT_KEY, this.visitCount); } } - get disabled() { + public get disabled() { return !this.baseUrl; } + public canEnable() { + const config = SdkConfig.get(); + return navigator.doNotTrack !== "1" && config && config.piwik && config.piwik.url && config.piwik.siteId; + } + /** * Enable Analytics if initialized but disabled * otherwise try and initalize, no-op if piwik config missing */ - async enable() { + public async enable() { if (!this.disabled) return; - + if (!this.canEnable()) return; const config = SdkConfig.get(); - if (!config || !config.piwik || !config.piwik.url || !config.piwik.siteId) return; this.baseUrl = new URL("piwik.php", config.piwik.url); // set constants - this.baseUrl.searchParams.set("rec", 1); // rec is required for tracking + this.baseUrl.searchParams.set("rec", "1"); // rec is required for tracking this.baseUrl.searchParams.set("idsite", config.piwik.siteId); // rec is required for tracking - this.baseUrl.searchParams.set("apiv", 1); // API version to use - this.baseUrl.searchParams.set("send_image", 0); // we want a 204, not a tiny GIF + this.baseUrl.searchParams.set("apiv", "1"); // API version to use + this.baseUrl.searchParams.set("send_image", "0"); // we want a 204, not a tiny GIF // set user parameters this.baseUrl.searchParams.set("_id", getUid()); // uuid this.baseUrl.searchParams.set("_idts", this.creationTs); // first ts - this.baseUrl.searchParams.set("_idvc", parseInt(this.visitCount, 10)+ 1); // visit count + this.baseUrl.searchParams.set("_idvc", this.visitCount); // visit count if (this.lastVisitTs) { this.baseUrl.searchParams.set("_viewts", this.lastVisitTs); // last visit ts } const platform = PlatformPeg.get(); - this._setVisitVariable('App Platform', platform.getHumanReadableName()); + this.setVisitVariable('App Platform', platform.getHumanReadableName()); try { - this._setVisitVariable('App Version', await platform.getAppVersion()); + this.setVisitVariable('App Version', await platform.getAppVersion()); } catch (e) { - this._setVisitVariable('App Version', 'unknown'); + this.setVisitVariable('App Version', 'unknown'); } - this._setVisitVariable('Chosen Language', getCurrentLanguage()); + this.setVisitVariable('Chosen Language', getCurrentLanguage()); const hostname = window.location.hostname; if (hostname === 'riot.im') { - this._setVisitVariable('Instance', window.location.pathname); + this.setVisitVariable('Instance', window.location.pathname); } else if (hostname.endsWith('.element.io')) { - this._setVisitVariable('Instance', hostname.replace('.element.io', '')); + this.setVisitVariable('Instance', hostname.replace('.element.io', '')); } let installedPWA = "unknown"; try { // Known to work at least for desktop Chrome - installedPWA = window.matchMedia('(display-mode: standalone)').matches; + installedPWA = String(window.matchMedia('(display-mode: standalone)').matches); } catch (e) { } - this._setVisitVariable('Installed PWA', installedPWA); + this.setVisitVariable('Installed PWA', installedPWA); let touchInput = "unknown"; try { // MDN claims broad support across browsers - touchInput = window.matchMedia('(pointer: coarse)').matches; + touchInput = String(window.matchMedia('(pointer: coarse)').matches); } catch (e) { } - this._setVisitVariable('Touch Input', touchInput); + this.setVisitVariable('Touch Input', touchInput); // start heartbeat - this._heartbeatIntervalID = window.setInterval(this.ping.bind(this), HEARTBEAT_INTERVAL); + this.heartbeatIntervalID = window.setInterval(this.ping.bind(this), HEARTBEAT_INTERVAL); } /** * Disable Analytics, stop the heartbeat and clear identifiers from localStorage */ - disable() { + public disable() { if (this.disabled) return; this.trackEvent('Analytics', 'opt-out'); - window.clearInterval(this._heartbeatIntervalID); + window.clearInterval(this.heartbeatIntervalID); this.baseUrl = null; this.visitVariables = {}; localStorage.removeItem(UID_KEY); @@ -244,7 +270,7 @@ class Analytics { localStorage.removeItem(LAST_VISIT_TS_KEY); } - async _track(data) { + private async _track(data: IData) { if (this.disabled) return; const now = new Date(); @@ -260,13 +286,13 @@ class Analytics { s: now.getSeconds(), }; - const url = new URL(this.baseUrl); + const url = new URL(this.baseUrl.toString()); // copy for (const key in params) { url.searchParams.set(key, params[key]); } try { - await window.fetch(url, { + await window.fetch(url.toString(), { method: "GET", mode: "no-cors", cache: "no-cache", @@ -277,14 +303,14 @@ class Analytics { } } - ping() { + public ping() { this._track({ - ping: 1, + ping: "1", }); - localStorage.setItem(LAST_VISIT_TS_KEY, new Date().getTime()); // update last visit ts + localStorage.setItem(LAST_VISIT_TS_KEY, String(new Date().getTime())); // update last visit ts } - trackPageChange(generationTimeMs) { + public trackPageChange(generationTimeMs?: number) { if (this.disabled) return; if (this.firstPage) { // De-duplicate first page @@ -299,11 +325,11 @@ class Analytics { } this._track({ - gt_ms: generationTimeMs, + gt_ms: String(generationTimeMs), }); } - trackEvent(category, action, name, value) { + public trackEvent(category: string, action: string, name?: string, value?: string) { if (this.disabled) return; this._track({ e_c: category, @@ -313,12 +339,12 @@ class Analytics { }); } - _setVisitVariable(key, value) { + private setVisitVariable(key: keyof typeof customVariables, value: string) { if (this.disabled) return; this.visitVariables[customVariables[key].id] = [key, value]; } - setLoggedIn(isGuest, homeserverUrl, identityServerUrl) { + public setLoggedIn(isGuest: boolean, homeserverUrl: string) { if (this.disabled) return; const config = SdkConfig.get(); @@ -326,16 +352,16 @@ class Analytics { const whitelistedHSUrls = config.piwik.whitelistedHSUrls || []; - this._setVisitVariable('User Type', isGuest ? 'Guest' : 'Logged In'); - this._setVisitVariable('Homeserver URL', whitelistRedact(whitelistedHSUrls, homeserverUrl)); + this.setVisitVariable('User Type', isGuest ? 'Guest' : 'Logged In'); + this.setVisitVariable('Homeserver URL', whitelistRedact(whitelistedHSUrls, homeserverUrl)); } - setBreadcrumbs(state) { + public setBreadcrumbs(state: boolean) { if (this.disabled) return; - this._setVisitVariable('Breadcrumbs', state ? 'enabled' : 'disabled'); + this.setVisitVariable('Breadcrumbs', state ? 'enabled' : 'disabled'); } - showDetailsModal = () => { + public showDetailsModal = () => { let rows = []; if (!this.disabled) { rows = Object.values(this.visitVariables); @@ -356,7 +382,7 @@ class Analytics { 'e.g. ', {}, { - CurrentPageURL: getRedactedUrl(), + CurrentPageURL: getRedactedUrl, }, ), }, @@ -397,7 +423,7 @@ class Analytics { }; } -if (!global.mxAnalytics) { - global.mxAnalytics = new Analytics(); +if (!window.mxAnalytics) { + window.mxAnalytics = new Analytics(); } -export default global.mxAnalytics; +export default window.mxAnalytics; diff --git a/src/AsyncWrapper.js b/src/AsyncWrapper.js index 94de5df214..359828b312 100644 --- a/src/AsyncWrapper.js +++ b/src/AsyncWrapper.js @@ -15,7 +15,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import createReactClass from 'create-react-class'; +import React from "react"; import * as sdk from './index'; import PropTypes from 'prop-types'; import { _t } from './languageHandler'; @@ -24,21 +24,19 @@ import { _t } from './languageHandler'; * Wrap an asynchronous loader function with a react component which shows a * spinner until the real component loads. */ -export default createReactClass({ - propTypes: { +export default class AsyncWrapper extends React.Component { + static propTypes = { /** A promise which resolves with the real component */ prom: PropTypes.object.isRequired, - }, + }; - getInitialState: function() { - return { - component: null, - error: null, - }; - }, + state = { + component: null, + error: null, + }; - componentDidMount: function() { + componentDidMount() { this._unmounted = false; // XXX: temporary logging to try to diagnose // https://github.com/vector-im/element-web/issues/3148 @@ -56,17 +54,17 @@ export default createReactClass({ console.warn('AsyncWrapper promise failed', e); this.setState({error: e}); }); - }, + } - componentWillUnmount: function() { + componentWillUnmount() { this._unmounted = true; - }, + } - _onWrapperCancelClick: function() { + _onWrapperCancelClick = () => { this.props.onFinished(false); - }, + }; - render: function() { + render() { if (this.state.component) { const Component = this.state.component; return ; @@ -87,6 +85,6 @@ export default createReactClass({ const Spinner = sdk.getComponent("elements.Spinner"); return ; } - }, -}); + } +} diff --git a/src/Avatar.js b/src/Avatar.ts similarity index 84% rename from src/Avatar.js rename to src/Avatar.ts index d76ea6f2c4..60bdfdcf75 100644 --- a/src/Avatar.js +++ b/src/Avatar.ts @@ -14,14 +14,19 @@ See the License for the specific language governing permissions and limitations under the License. */ -'use strict'; +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 {getHttpUriForMxc} from "matrix-js-sdk/src/content-repo"; + +export type ResizeMethod = "crop" | "scale"; // Not to be used for BaseAvatar urls as that has similar default avatar fallback already -export function avatarUrlForMember(member, width, height, resizeMethod) { - let url; +export function avatarUrlForMember(member: RoomMember, width: number, height: number, resizeMethod: ResizeMethod) { + let url: string; if (member && member.getAvatarUrl) { url = member.getAvatarUrl( MatrixClientPeg.get().getHomeserverUrl(), @@ -41,7 +46,7 @@ export function avatarUrlForMember(member, width, height, resizeMethod) { return url; } -export function avatarUrlForUser(user, width, height, resizeMethod) { +export function avatarUrlForUser(user: User, width: number, height: number, resizeMethod?: ResizeMethod) { const url = getHttpUriForMxc( MatrixClientPeg.get().getHomeserverUrl(), user.avatarUrl, Math.floor(width * window.devicePixelRatio), @@ -54,14 +59,14 @@ export function avatarUrlForUser(user, width, height, resizeMethod) { return url; } -function isValidHexColor(color) { +function isValidHexColor(color: string): boolean { return typeof color === "string" && - (color.length === 7 || color.lengh === 9) && + (color.length === 7 || color.length === 9) && color.charAt(0) === "#" && !color.substr(1).split("").some(c => isNaN(parseInt(c, 16))); } -function urlForColor(color) { +function urlForColor(color: string): string { const size = 40; const canvas = document.createElement("canvas"); canvas.width = size; @@ -79,9 +84,10 @@ function urlForColor(color) { // XXX: Ideally we'd clear this cache when the theme changes // but since this function is at global scope, it's a bit // hard to install a listener here, even if there were a clear event to listen to -const colorToDataURLCache = new Map(); +const colorToDataURLCache = new Map(); -export function defaultAvatarUrlForString(s) { +export function defaultAvatarUrlForString(s: string): string { + if (!s) return ""; // XXX: should never happen but empirically does by evidence of a rageshake const defaultColors = ['#0DBD8B', '#368bd6', '#ac3ba8']; let total = 0; for (let i = 0; i < s.length; ++i) { @@ -112,7 +118,7 @@ export function defaultAvatarUrlForString(s) { * @param {string} name * @return {string} the first letter */ -export function getInitialLetter(name) { +export function getInitialLetter(name: string): string { if (!name) { // XXX: We should find out what causes the name to sometimes be falsy. console.trace("`name` argument to `getInitialLetter` not supplied"); @@ -145,7 +151,7 @@ export function getInitialLetter(name) { return firstChar.toUpperCase(); } -export function avatarUrlForRoom(room, width, height, resizeMethod) { +export function avatarUrlForRoom(room: Room, width: number, height: number, resizeMethod?: ResizeMethod) { if (!room) return null; // null-guard const explicitRoomAvatar = room.getAvatarUrl( diff --git a/src/CallHandler.js b/src/CallHandler.js deleted file mode 100644 index 18f6aeb98a..0000000000 --- a/src/CallHandler.js +++ /dev/null @@ -1,509 +0,0 @@ -/* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2017, 2018 New Vector Ltd -Copyright 2019 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -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. -*/ - -/* - * Manages a list of all the currently active calls. - * - * This handler dispatches when voip calls are added/updated/removed from this list: - * { - * action: 'call_state' - * room_id: - * } - * - * To know the state of the call, this handler exposes a getter to - * obtain the call for a room: - * var call = CallHandler.getCall(roomId) - * var state = call.call_state; // ringing|ringback|connected|ended|busy|stop_ringback|stop_ringing - * - * This handler listens for and handles the following actions: - * { - * action: 'place_call', - * type: 'voice|video', - * room_id: - * } - * - * { - * action: 'incoming_call' - * call: MatrixCall - * } - * - * { - * action: 'hangup' - * room_id: - * } - * - * { - * action: 'answer' - * room_id: - * } - */ - -import {MatrixClientPeg} from './MatrixClientPeg'; -import PlatformPeg from './PlatformPeg'; -import Modal from './Modal'; -import * as sdk from './index'; -import { _t } from './languageHandler'; -import Matrix from 'matrix-js-sdk'; -import dis from './dispatcher/dispatcher'; -import WidgetUtils from './utils/WidgetUtils'; -import WidgetEchoStore from './stores/WidgetEchoStore'; -import SettingsStore from './settings/SettingsStore'; -import {generateHumanReadableId} from "./utils/NamingUtils"; -import {Jitsi} from "./widgets/Jitsi"; -import {WidgetType} from "./widgets/WidgetType"; -import {SettingLevel} from "./settings/SettingLevel"; - -global.mxCalls = { - //room_id: MatrixCall -}; -const calls = global.mxCalls; -let ConferenceHandler = null; - -const audioPromises = {}; - -function play(audioId) { - // TODO: Attach an invisible element for this instead - // which listens? - const audio = document.getElementById(audioId); - if (audio) { - const playAudio = async () => { - try { - // This still causes the chrome debugger to break on promise rejection if - // the promise is rejected, even though we're catching the exception. - await audio.play(); - } catch (e) { - // This is usually because the user hasn't interacted with the document, - // or chrome doesn't think so and is denying the request. Not sure what - // we can really do here... - // https://github.com/vector-im/element-web/issues/7657 - console.log("Unable to play audio clip", e); - } - }; - if (audioPromises[audioId]) { - audioPromises[audioId] = audioPromises[audioId].then(()=>{ - audio.load(); - return playAudio(); - }); - } else { - audioPromises[audioId] = playAudio(); - } - } -} - -function pause(audioId) { - // TODO: Attach an invisible element for this instead - // which listens? - const audio = document.getElementById(audioId); - if (audio) { - if (audioPromises[audioId]) { - audioPromises[audioId] = audioPromises[audioId].then(()=>audio.pause()); - } else { - // pause doesn't actually return a promise, but might as well do this for symmetry with play(); - audioPromises[audioId] = audio.pause(); - } - } -} - -function _setCallListeners(call) { - call.on("error", function(err) { - console.error("Call error:", err); - if ( - MatrixClientPeg.get().getTurnServers().length === 0 && - SettingsStore.getValue("fallbackICEServerAllowed") === null - ) { - _showICEFallbackPrompt(); - return; - } - - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - Modal.createTrackedDialog('Call Failed', '', ErrorDialog, { - title: _t('Call Failed'), - description: err.message, - }); - }); - call.on("hangup", function() { - _setCallState(undefined, call.roomId, "ended"); - }); - // map web rtc states to dummy UI state - // ringing|ringback|connected|ended|busy|stop_ringback|stop_ringing - call.on("state", function(newState, oldState) { - if (newState === "ringing") { - _setCallState(call, call.roomId, "ringing"); - pause("ringbackAudio"); - } else if (newState === "invite_sent") { - _setCallState(call, call.roomId, "ringback"); - play("ringbackAudio"); - } else if (newState === "ended" && oldState === "connected") { - _setCallState(undefined, call.roomId, "ended"); - pause("ringbackAudio"); - play("callendAudio"); - } else if (newState === "ended" && oldState === "invite_sent" && - (call.hangupParty === "remote" || - (call.hangupParty === "local" && call.hangupReason === "invite_timeout") - )) { - _setCallState(call, call.roomId, "busy"); - pause("ringbackAudio"); - play("busyAudio"); - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - Modal.createTrackedDialog('Call Handler', 'Call Timeout', ErrorDialog, { - title: _t('Call Timeout'), - description: _t('The remote side failed to pick up') + '.', - }); - } else if (oldState === "invite_sent") { - _setCallState(call, call.roomId, "stop_ringback"); - pause("ringbackAudio"); - } else if (oldState === "ringing") { - _setCallState(call, call.roomId, "stop_ringing"); - pause("ringbackAudio"); - } else if (newState === "connected") { - _setCallState(call, call.roomId, "connected"); - pause("ringbackAudio"); - } - }); -} - -function _setCallState(call, roomId, status) { - console.log( - `Call state in ${roomId} changed to ${status} (${call ? call.call_state : "-"})`, - ); - calls[roomId] = call; - - if (status === "ringing") { - play("ringAudio"); - } else if (call && call.call_state === "ringing") { - pause("ringAudio"); - } - - if (call) { - call.call_state = status; - } - dis.dispatch({ - action: 'call_state', - room_id: roomId, - state: status, - }); -} - -function _showICEFallbackPrompt() { - const cli = MatrixClientPeg.get(); - const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); - const code = sub => {sub}; - Modal.createTrackedDialog('No TURN servers', '', QuestionDialog, { - title: _t("Call failed due to misconfigured server"), - description:

-

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

-

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

-
, - button: _t('Try using turn.matrix.org'), - cancelButton: _t('OK'), - onFinished: (allow) => { - SettingsStore.setValue("fallbackICEServerAllowed", null, SettingLevel.DEVICE, allow); - cli.setFallbackICEServerAllowed(allow); - }, - }, null, true); -} - -function _onAction(payload) { - function placeCall(newCall) { - _setCallListeners(newCall); - if (payload.type === 'voice') { - newCall.placeVoiceCall(); - } else if (payload.type === 'video') { - newCall.placeVideoCall( - payload.remote_element, - payload.local_element, - ); - } else if (payload.type === 'screensharing') { - const screenCapErrorString = PlatformPeg.get().screenCaptureErrorString(); - if (screenCapErrorString) { - _setCallState(undefined, newCall.roomId, "ended"); - console.log("Can't capture screen: " + screenCapErrorString); - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - Modal.createTrackedDialog('Call Handler', 'Unable to capture screen', ErrorDialog, { - title: _t('Unable to capture screen'), - description: screenCapErrorString, - }); - return; - } - newCall.placeScreenSharingCall( - payload.remote_element, - payload.local_element, - ); - } else { - console.error("Unknown conf call type: %s", payload.type); - } - } - - switch (payload.action) { - case 'place_call': - { - if (callHandler.getAnyActiveCall()) { - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - Modal.createTrackedDialog('Call Handler', 'Existing Call', ErrorDialog, { - title: _t('Existing Call'), - description: _t('You are already in a call.'), - }); - return; // don't allow >1 call to be placed. - } - - // if the runtime env doesn't do VoIP, whine. - if (!MatrixClientPeg.get().supportsVoip()) { - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - Modal.createTrackedDialog('Call Handler', 'VoIP is unsupported', ErrorDialog, { - title: _t('VoIP is unsupported'), - description: _t('You cannot place VoIP calls in this browser.'), - }); - return; - } - - const room = MatrixClientPeg.get().getRoom(payload.room_id); - if (!room) { - console.error("Room %s does not exist.", payload.room_id); - return; - } - - const members = room.getJoinedMembers(); - if (members.length <= 1) { - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - Modal.createTrackedDialog('Call Handler', 'Cannot place call with self', ErrorDialog, { - description: _t('You cannot place a call with yourself.'), - }); - return; - } else if (members.length === 2) { - console.info("Place %s call in %s", payload.type, payload.room_id); - const call = Matrix.createNewMatrixCall(MatrixClientPeg.get(), payload.room_id); - placeCall(call); - } else { // > 2 - dis.dispatch({ - action: "place_conference_call", - room_id: payload.room_id, - type: payload.type, - remote_element: payload.remote_element, - local_element: payload.local_element, - }); - } - } - break; - case 'place_conference_call': - console.info("Place conference call in %s", payload.room_id); - _startCallApp(payload.room_id, payload.type); - break; - case 'incoming_call': - { - if (callHandler.getAnyActiveCall()) { - // ignore multiple incoming calls. in future, we may want a line-1/line-2 setup. - // we avoid rejecting with "busy" in case the user wants to answer it on a different device. - // in future we could signal a "local busy" as a warning to the caller. - // see https://github.com/vector-im/vector-web/issues/1964 - return; - } - - // if the runtime env doesn't do VoIP, stop here. - if (!MatrixClientPeg.get().supportsVoip()) { - return; - } - - const call = payload.call; - _setCallListeners(call); - _setCallState(call, call.roomId, "ringing"); - } - break; - case 'hangup': - if (!calls[payload.room_id]) { - return; // no call to hangup - } - calls[payload.room_id].hangup(); - _setCallState(null, payload.room_id, "ended"); - break; - case 'answer': - if (!calls[payload.room_id]) { - return; // no call to answer - } - calls[payload.room_id].answer(); - _setCallState(calls[payload.room_id], payload.room_id, "connected"); - dis.dispatch({ - action: "view_room", - room_id: payload.room_id, - }); - break; - } -} - -async function _startCallApp(roomId, type) { - dis.dispatch({ - action: 'appsDrawer', - show: true, - }); - - const room = MatrixClientPeg.get().getRoom(roomId); - const currentJitsiWidgets = WidgetUtils.getRoomWidgetsOfType(room, WidgetType.JITSI); - - if (WidgetEchoStore.roomHasPendingWidgetsOfType(roomId, currentJitsiWidgets, WidgetType.JITSI)) { - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - - Modal.createTrackedDialog('Call already in progress', '', ErrorDialog, { - title: _t('Call in Progress'), - description: _t('A call is currently being placed!'), - }); - return; - } - - if (currentJitsiWidgets.length > 0) { - console.warn( - "Refusing to start conference call widget in " + roomId + - " a conference call widget is already present", - ); - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - - Modal.createTrackedDialog('Already have Jitsi Widget', '', ErrorDialog, { - title: _t('Call in Progress'), - description: _t('A call is already in progress!'), - }); - return; - } - - const confId = `JitsiConference${generateHumanReadableId()}`; - const jitsiDomain = Jitsi.getInstance().preferredDomain; - - let widgetUrl = WidgetUtils.getLocalJitsiWrapperUrl(); - - // TODO: Remove URL hacks when the mobile clients eventually support v2 widgets - const parsedUrl = new URL(widgetUrl); - parsedUrl.search = ''; // set to empty string to make the URL class use searchParams instead - parsedUrl.searchParams.set('confId', confId); - widgetUrl = parsedUrl.toString(); - - const widgetData = { - conferenceId: confId, - isAudioOnly: type === 'voice', - domain: jitsiDomain, - }; - - const widgetId = ( - 'jitsi_' + - MatrixClientPeg.get().credentials.userId + - '_' + - Date.now() - ); - - WidgetUtils.setRoomWidget(roomId, widgetId, WidgetType.JITSI, widgetUrl, 'Jitsi', widgetData).then(() => { - console.log('Jitsi widget added'); - }).catch((e) => { - if (e.errcode === 'M_FORBIDDEN') { - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - - Modal.createTrackedDialog('Call Failed', '', ErrorDialog, { - title: _t('Permission Required'), - description: _t("You do not have permission to start a conference call in this room"), - }); - } - console.error(e); - }); -} - -// FIXME: Nasty way of making sure we only register -// with the dispatcher once -if (!global.mxCallHandler) { - dis.register(_onAction); - // add empty handlers for media actions, otherwise the media keys - // end up causing the audio elements with our ring/ringback etc - // audio clips in to play. - if (navigator.mediaSession) { - navigator.mediaSession.setActionHandler('play', function() {}); - navigator.mediaSession.setActionHandler('pause', function() {}); - navigator.mediaSession.setActionHandler('seekbackward', function() {}); - navigator.mediaSession.setActionHandler('seekforward', function() {}); - navigator.mediaSession.setActionHandler('previoustrack', function() {}); - navigator.mediaSession.setActionHandler('nexttrack', function() {}); - } -} - -const callHandler = { - getCallForRoom: function(roomId) { - let call = callHandler.getCall(roomId); - if (call) return call; - - if (ConferenceHandler) { - call = ConferenceHandler.getConferenceCallForRoom(roomId); - } - if (call) return call; - - return null; - }, - - getCall: function(roomId) { - return calls[roomId] || null; - }, - - getAnyActiveCall: function() { - const roomsWithCalls = Object.keys(calls); - for (let i = 0; i < roomsWithCalls.length; i++) { - if (calls[roomsWithCalls[i]] && - calls[roomsWithCalls[i]].call_state !== "ended") { - return calls[roomsWithCalls[i]]; - } - } - return null; - }, - - /** - * The conference handler is a module that deals with implementation-specific - * multi-party calling implementations. Element passes in its own which creates - * a one-to-one call with a freeswitch conference bridge. As of July 2018, - * the de-facto way of conference calling is a Jitsi widget, so this is - * deprecated. It reamins here for two reasons: - * 1. So Element still supports joining existing freeswitch conference calls - * (but doesn't support creating them). After a transition period, we can - * remove support for joining them too. - * 2. To hide the one-to-one rooms that old-style conferencing creates. This - * is much harder to remove: probably either we make Element leave & forget these - * rooms after we remove support for joining freeswitch conferences, or we - * accept that random rooms with cryptic users will suddently appear for - * anyone who's ever used conference calling, or we are stuck with this - * code forever. - * - * @param {object} confHandler The conference handler object - */ - setConferenceHandler: function(confHandler) { - ConferenceHandler = confHandler; - }, - - getConferenceHandler: function() { - return ConferenceHandler; - }, -}; -// Only things in here which actually need to be global are the -// calls list (done separately) and making sure we only register -// with the dispatcher once (which uses this mechanism but checks -// separately). This could be tidied up. -if (global.mxCallHandler === undefined) { - global.mxCallHandler = callHandler; -} - -export default global.mxCallHandler; diff --git a/src/CallHandler.tsx b/src/CallHandler.tsx new file mode 100644 index 0000000000..d1ebb15c26 --- /dev/null +++ b/src/CallHandler.tsx @@ -0,0 +1,565 @@ +/* +Copyright 2015, 2016 OpenMarket Ltd +Copyright 2017, 2018 New Vector Ltd +Copyright 2019, 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/* + * Manages a list of all the currently active calls. + * + * This handler dispatches when voip calls are added/updated/removed from this list: + * { + * action: 'call_state' + * room_id: + * } + * + * To know the state of the call, this handler exposes a getter to + * obtain the call for a room: + * var call = CallHandler.getCall(roomId) + * var state = call.call_state; // ringing|ringback|connected|ended|busy|stop_ringback|stop_ringing + * + * This handler listens for and handles the following actions: + * { + * action: 'place_call', + * type: 'voice|video', + * room_id: + * } + * + * { + * action: 'incoming_call' + * call: MatrixCall + * } + * + * { + * action: 'hangup' + * room_id: + * } + * + * { + * action: 'answer' + * room_id: + * } + */ + +import React from 'react'; + +import {MatrixClientPeg} from './MatrixClientPeg'; +import PlatformPeg from './PlatformPeg'; +import Modal from './Modal'; +import { _t } from './languageHandler'; +// @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 dis from './dispatcher/dispatcher'; +import WidgetUtils from './utils/WidgetUtils'; +import WidgetEchoStore from './stores/WidgetEchoStore'; +import SettingsStore from './settings/SettingsStore'; +import {generateHumanReadableId} from "./utils/NamingUtils"; +import {Jitsi} from "./widgets/Jitsi"; +import {WidgetType} from "./widgets/WidgetType"; +import {SettingLevel} from "./settings/SettingLevel"; +import { ActionPayload } from "./dispatcher/payloads"; +import {base32} from "rfc4648"; + +import QuestionDialog from "./components/views/dialogs/QuestionDialog"; +import ErrorDialog from "./components/views/dialogs/ErrorDialog"; +import WidgetStore from "./stores/WidgetStore"; +import { WidgetMessagingStore } from "./stores/widgets/WidgetMessagingStore"; +import { ElementWidgetActions } from "./stores/widgets/ElementWidgetActions"; +import { MatrixCall, CallErrorCode, CallState, CallEvent, CallParty } from "matrix-js-sdk/lib/webrtc/call"; + +enum AudioID { + Ring = 'ringAudio', + Ringback = 'ringbackAudio', + CallEnd = 'callendAudio', + Busy = 'busyAudio', +} + +// Unlike 'CallType' in js-sdk, this one includes screen sharing +// (because a screen sharing call is only a screen sharing call to the caller, +// to the callee it's just a video call, at least as far as the current impl +// is concerned). +export enum PlaceCallType { + Voice = 'voice', + Video = 'video', + ScreenSharing = 'screensharing', +} + +export default class CallHandler { + private calls = new Map(); + private audioPromises = new Map>(); + + static sharedInstance() { + if (!window.mxCallHandler) { + window.mxCallHandler = new CallHandler() + } + + return window.mxCallHandler; + } + + constructor() { + dis.register(this.onAction); + // add empty handlers for media actions, otherwise the media keys + // end up causing the audio elements with our ring/ringback etc + // audio clips in to play. + if (navigator.mediaSession) { + navigator.mediaSession.setActionHandler('play', function() {}); + navigator.mediaSession.setActionHandler('pause', function() {}); + navigator.mediaSession.setActionHandler('seekbackward', function() {}); + navigator.mediaSession.setActionHandler('seekforward', function() {}); + navigator.mediaSession.setActionHandler('previoustrack', function() {}); + navigator.mediaSession.setActionHandler('nexttrack', function() {}); + } + } + + getCallForRoom(roomId: string): MatrixCall { + return this.calls.get(roomId) || null; + } + + getAnyActiveCall() { + for (const call of this.calls.values()) { + if (call.state !== CallState.Ended) { + return call; + } + } + return null; + } + + play(audioId: AudioID) { + // TODO: Attach an invisible element for this instead + // which listens? + const audio = document.getElementById(audioId) as HTMLMediaElement; + if (audio) { + const playAudio = async () => { + try { + // This still causes the chrome debugger to break on promise rejection if + // the promise is rejected, even though we're catching the exception. + await audio.play(); + } catch (e) { + // This is usually because the user hasn't interacted with the document, + // or chrome doesn't think so and is denying the request. Not sure what + // we can really do here... + // https://github.com/vector-im/element-web/issues/7657 + console.log("Unable to play audio clip", e); + } + }; + if (this.audioPromises.has(audioId)) { + this.audioPromises.set(audioId, this.audioPromises.get(audioId).then(() => { + audio.load(); + return playAudio(); + })); + } else { + this.audioPromises.set(audioId, playAudio()); + } + } + } + + pause(audioId: AudioID) { + // TODO: Attach an invisible element for this instead + // which listens? + const audio = document.getElementById(audioId) as HTMLMediaElement; + if (audio) { + if (this.audioPromises.has(audioId)) { + this.audioPromises.set(audioId, this.audioPromises.get(audioId).then(() => audio.pause())); + } else { + // pause doesn't return a promise, so just do it + audio.pause(); + } + } + } + + private matchesCallForThisRoom(call: MatrixCall) { + // 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 callForThisRoom = this.getCallForRoom(call.roomId); + return callForThisRoom && call.callId === callForThisRoom.callId; + } + + private setCallListeners(call: MatrixCall) { + call.on(CallEvent.Error, (err) => { + if (!this.matchesCallForThisRoom(call)) return; + + console.error("Call error:", err); + if ( + MatrixClientPeg.get().getTurnServers().length === 0 && + SettingsStore.getValue("fallbackICEServerAllowed") === null + ) { + this.showICEFallbackPrompt(); + return; + } + + Modal.createTrackedDialog('Call Failed', '', ErrorDialog, { + title: _t('Call Failed'), + description: err.message, + }); + }); + call.on(CallEvent.Hangup, () => { + if (!this.matchesCallForThisRoom(call)) return; + + this.removeCallForRoom(call.roomId); + }); + call.on(CallEvent.State, (newState: CallState, oldState: CallState) => { + if (!this.matchesCallForThisRoom(call)) return; + + this.setCallState(call, newState); + + switch (oldState) { + case CallState.Ringing: + this.pause(AudioID.Ring); + break; + case CallState.InviteSent: + this.pause(AudioID.Ringback); + break; + } + + switch (newState) { + case CallState.Ringing: + this.play(AudioID.Ring); + break; + case CallState.InviteSent: + this.play(AudioID.Ringback); + break; + case CallState.Ended: + this.removeCallForRoom(call.roomId); + if (oldState === CallState.InviteSent && ( + call.hangupParty === CallParty.Remote || + (call.hangupParty === CallParty.Local && call.hangupReason === CallErrorCode.InviteTimeout) + )) { + this.play(AudioID.Busy); + let title; + let description; + if (call.hangupReason === CallErrorCode.UserHangup) { + title = _t("Call Declined"); + description = _t("The other party declined the call."); + } else if (call.hangupReason === CallErrorCode.InviteTimeout) { + title = _t("Call Failed"); + // XXX: full stop appended as some relic here, but these + // strings need proper input from design anyway, so let's + // not change this string until we have a proper one. + description = _t('The remote side failed to pick up') + '.'; + } else { + title = _t("Call Failed"); + description = _t("The call could not be established"); + } + + Modal.createTrackedDialog('Call Handler', 'Call Failed', ErrorDialog, { + title, description, + }); + } else { + this.play(AudioID.CallEnd); + } + } + }); + call.on(CallEvent.Replaced, (newCall: MatrixCall) => { + if (!this.matchesCallForThisRoom(call)) return; + + console.log(`Call ID ${call.callId} is being replaced by call ID ${newCall.callId}`); + + if (call.state === CallState.Ringing) { + this.pause(AudioID.Ring); + } else if (call.state === CallState.InviteSent) { + this.pause(AudioID.Ringback); + } + + this.calls.set(newCall.roomId, newCall); + this.setCallListeners(newCall); + this.setCallState(newCall, newCall.state); + }); + } + + private setCallState(call: MatrixCall, status: CallState) { + console.log( + `Call state in ${call.roomId} changed to ${status}`, + ); + + dis.dispatch({ + action: 'call_state', + room_id: call.roomId, + state: status, + }); + } + + private removeCallForRoom(roomId: string) { + this.calls.delete(roomId); + } + + private showICEFallbackPrompt() { + const cli = MatrixClientPeg.get(); + const code = sub => {sub}; + Modal.createTrackedDialog('No TURN servers', '', QuestionDialog, { + title: _t("Call failed due to misconfigured server"), + description:
+

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

+

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

+
, + button: _t('Try using turn.matrix.org'), + cancelButton: _t('OK'), + onFinished: (allow) => { + SettingsStore.setValue("fallbackICEServerAllowed", null, SettingLevel.DEVICE, allow); + cli.setFallbackICEServerAllowed(allow); + }, + }, null, true); + } + + + private placeCall( + roomId: string, type: PlaceCallType, + localElement: HTMLVideoElement, remoteElement: HTMLVideoElement, + ) { + const call = Matrix.createNewMatrixCall(MatrixClientPeg.get(), roomId); + this.calls.set(roomId, call); + this.setCallListeners(call); + if (type === PlaceCallType.Voice) { + call.placeVoiceCall(); + } else if (type === 'video') { + call.placeVideoCall( + remoteElement, + localElement, + ); + } else if (type === PlaceCallType.ScreenSharing) { + const screenCapErrorString = PlatformPeg.get().screenCaptureErrorString(); + if (screenCapErrorString) { + this.removeCallForRoom(roomId); + console.log("Can't capture screen: " + screenCapErrorString); + Modal.createTrackedDialog('Call Handler', 'Unable to capture screen', ErrorDialog, { + title: _t('Unable to capture screen'), + description: screenCapErrorString, + }); + return; + } + call.placeScreenSharingCall(remoteElement, localElement); + } else { + console.error("Unknown conf call type: %s", type); + } + } + + private onAction = (payload: ActionPayload) => { + switch (payload.action) { + case 'place_call': + { + if (this.getAnyActiveCall()) { + Modal.createTrackedDialog('Call Handler', 'Existing Call', ErrorDialog, { + title: _t('Existing Call'), + description: _t('You are already in a call.'), + }); + return; // don't allow >1 call to be placed. + } + + // if the runtime env doesn't do VoIP, whine. + if (!MatrixClientPeg.get().supportsVoip()) { + Modal.createTrackedDialog('Call Handler', 'VoIP is unsupported', ErrorDialog, { + title: _t('VoIP is unsupported'), + description: _t('You cannot place VoIP calls in this browser.'), + }); + return; + } + + const room = MatrixClientPeg.get().getRoom(payload.room_id); + if (!room) { + console.error("Room %s does not exist.", payload.room_id); + return; + } + + const members = room.getJoinedMembers(); + if (members.length <= 1) { + Modal.createTrackedDialog('Call Handler', 'Cannot place call with self', ErrorDialog, { + description: _t('You cannot place a call with yourself.'), + }); + return; + } else if (members.length === 2) { + console.info("Place %s call in %s", payload.type, payload.room_id); + + this.placeCall(payload.room_id, payload.type, payload.local_element, payload.remote_element); + } else { // > 2 + dis.dispatch({ + action: "place_conference_call", + room_id: payload.room_id, + type: payload.type, + remote_element: payload.remote_element, + local_element: payload.local_element, + }); + } + } + break; + case 'place_conference_call': + console.info("Place conference call in %s", payload.room_id); + this.startCallApp(payload.room_id, payload.type); + break; + case 'end_conference': + console.info("Terminating conference call in %s", payload.room_id); + this.terminateCallApp(payload.room_id); + break; + case 'hangup_conference': + console.info("Leaving conference call in %s", payload.room_id); + this.hangupCallApp(payload.room_id); + break; + case 'incoming_call': + { + if (this.getAnyActiveCall()) { + // ignore multiple incoming calls. in future, we may want a line-1/line-2 setup. + // we avoid rejecting with "busy" in case the user wants to answer it on a different device. + // in future we could signal a "local busy" as a warning to the caller. + // see https://github.com/vector-im/vector-web/issues/1964 + return; + } + + // if the runtime env doesn't do VoIP, stop here. + if (!MatrixClientPeg.get().supportsVoip()) { + return; + } + + const call = payload.call as MatrixCall; + this.calls.set(call.roomId, call) + this.setCallListeners(call); + } + break; + case 'hangup': + case 'reject': + if (!this.calls.get(payload.room_id)) { + return; // no call to hangup + } + if (payload.action === 'reject') { + this.calls.get(payload.room_id).reject(); + } else { + this.calls.get(payload.room_id).hangup(CallErrorCode.UserHangup, false); + } + this.removeCallForRoom(payload.room_id); + break; + case 'answer': + if (!this.calls.has(payload.room_id)) { + return; // no call to answer + } + this.calls.get(payload.room_id).answer(); + dis.dispatch({ + action: "view_room", + room_id: payload.room_id, + }); + break; + } + } + + private async startCallApp(roomId: string, type: string) { + dis.dispatch({ + action: 'appsDrawer', + show: true, + }); + + // prevent double clicking the call button + const room = MatrixClientPeg.get().getRoom(roomId); + const currentJitsiWidgets = WidgetUtils.getRoomWidgetsOfType(room, WidgetType.JITSI); + const hasJitsi = currentJitsiWidgets.length > 0 + || WidgetEchoStore.roomHasPendingWidgetsOfType(roomId, currentJitsiWidgets, WidgetType.JITSI); + if (hasJitsi) { + Modal.createTrackedDialog('Call already in progress', '', ErrorDialog, { + title: _t('Call in Progress'), + description: _t('A call is currently being placed!'), + }); + return; + } + + const jitsiDomain = Jitsi.getInstance().preferredDomain; + const jitsiAuth = await Jitsi.getInstance().getJitsiAuth(); + let confId; + if (jitsiAuth === 'openidtoken-jwt') { + // Create conference ID from room ID + // For compatibility with Jitsi, use base32 without padding. + // More details here: + // https://github.com/matrix-org/prosody-mod-auth-matrix-user-verification + confId = base32.stringify(Buffer.from(roomId), { pad: false }); + } else { + // Create a random human readable conference ID + confId = `JitsiConference${generateHumanReadableId()}`; + } + + let widgetUrl = WidgetUtils.getLocalJitsiWrapperUrl({auth: jitsiAuth}); + + // TODO: Remove URL hacks when the mobile clients eventually support v2 widgets + const parsedUrl = new URL(widgetUrl); + parsedUrl.search = ''; // set to empty string to make the URL class use searchParams instead + parsedUrl.searchParams.set('confId', confId); + widgetUrl = parsedUrl.toString(); + + const widgetData = { + conferenceId: confId, + isAudioOnly: type === 'voice', + domain: jitsiDomain, + auth: jitsiAuth, + }; + + const widgetId = ( + 'jitsi_' + + MatrixClientPeg.get().credentials.userId + + '_' + + Date.now() + ); + + WidgetUtils.setRoomWidget(roomId, widgetId, WidgetType.JITSI, widgetUrl, 'Jitsi', widgetData).then(() => { + console.log('Jitsi widget added'); + }).catch((e) => { + if (e.errcode === 'M_FORBIDDEN') { + Modal.createTrackedDialog('Call Failed', '', ErrorDialog, { + title: _t('Permission Required'), + description: _t("You do not have permission to start a conference call in this room"), + }); + } + console.error(e); + }); + } + + private terminateCallApp(roomId: string) { + Modal.createTrackedDialog('Confirm Jitsi Terminate', '', QuestionDialog, { + hasCancelButton: true, + title: _t("End conference"), + description: _t("This will end the conference for everyone. Continue?"), + button: _t("End conference"), + onFinished: (proceed) => { + if (!proceed) return; + + // We'll just obliterate them all. There should only ever be one, but might as well + // be safe. + const roomInfo = WidgetStore.instance.getRoom(roomId); + const jitsiWidgets = roomInfo.widgets.filter(w => WidgetType.JITSI.matches(w.type)); + jitsiWidgets.forEach(w => { + // setting invalid content removes it + WidgetUtils.setRoomWidget(roomId, w.id); + }); + }, + }); + } + + private hangupCallApp(roomId: string) { + const roomInfo = WidgetStore.instance.getRoom(roomId); + if (!roomInfo) return; // "should never happen" clauses go here + + const jitsiWidgets = roomInfo.widgets.filter(w => WidgetType.JITSI.matches(w.type)); + jitsiWidgets.forEach(w => { + const messaging = WidgetMessagingStore.instance.getMessagingForId(w.id); + if (!messaging) return; // more "should never happen" words + + messaging.transport.send(ElementWidgetActions.HangupCall, {}); + }); + } +} diff --git a/src/ContentMessages.tsx b/src/ContentMessages.tsx index 6f55a75d0c..cba8671143 100644 --- a/src/ContentMessages.tsx +++ b/src/ContentMessages.tsx @@ -17,7 +17,6 @@ limitations under the License. */ import React from "react"; -import extend from './extend'; import dis from './dispatcher/dispatcher'; import {MatrixClientPeg} from './MatrixClientPeg'; import {MatrixClient} from "matrix-js-sdk/src/client"; @@ -70,6 +69,7 @@ interface IContent { interface IThumbnail { info: { + // eslint-disable-next-line camelcase thumbnail_info: { w: number; h: number; @@ -104,7 +104,12 @@ interface IAbortablePromise extends Promise { * @return {Promise} A promise that resolves with an object with an info key * and a thumbnail key. */ -function createThumbnail(element: ThumbnailableElement, inputWidth: number, inputHeight: number, mimeType: string): Promise { +function createThumbnail( + element: ThumbnailableElement, + inputWidth: number, + inputHeight: number, + mimeType: string, +): Promise { return new Promise((resolve) => { let targetWidth = inputWidth; let targetHeight = inputHeight; @@ -437,11 +442,13 @@ export default class ContentMessages { for (let i = 0; i < okFiles.length; ++i) { const file = okFiles[i]; if (!uploadAll) { - const {finished} = Modal.createTrackedDialog<[boolean, boolean]>('Upload Files confirmation', '', UploadConfirmDialog, { - file, - currentIndex: i, - totalFiles: okFiles.length, - }); + const {finished} = Modal.createTrackedDialog<[boolean, boolean]>('Upload Files confirmation', + '', UploadConfirmDialog, { + file, + currentIndex: i, + totalFiles: okFiles.length, + }, + ); const [shouldContinue, shouldUploadAll] = await finished; if (!shouldContinue) break; if (shouldUploadAll) { @@ -489,7 +496,7 @@ export default class ContentMessages { if (file.type.indexOf('image/') === 0) { content.msgtype = 'm.image'; infoForImageFile(matrixClient, roomId, file).then((imageInfo) => { - extend(content.info, imageInfo); + Object.assign(content.info, imageInfo); resolve(); }, (e) => { console.error(e); @@ -502,7 +509,7 @@ export default class ContentMessages { } else if (file.type.indexOf('video/') === 0) { content.msgtype = 'm.video'; infoForVideoFile(matrixClient, roomId, file).then((videoInfo) => { - extend(content.info, videoInfo); + Object.assign(content.info, videoInfo); resolve(); }, (e) => { content.msgtype = 'm.file'; diff --git a/src/CrossSigningManager.js b/src/CrossSigningManager.js deleted file mode 100644 index 676c41d7d7..0000000000 --- a/src/CrossSigningManager.js +++ /dev/null @@ -1,248 +0,0 @@ -/* -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 Modal from './Modal'; -import * as sdk from './index'; -import {MatrixClientPeg} from './MatrixClientPeg'; -import { deriveKey } from 'matrix-js-sdk/src/crypto/key_passphrase'; -import { decodeRecoveryKey } from 'matrix-js-sdk/src/crypto/recoverykey'; -import { _t } from './languageHandler'; -import {encodeBase64} from "matrix-js-sdk/src/crypto/olmlib"; - -// This stores the secret storage private keys in memory for the JS SDK. This is -// only meant to act as a cache to avoid prompting the user multiple times -// during the same single operation. Use `accessSecretStorage` below to scope a -// single secret storage operation, as it will clear the cached keys once the -// operation ends. -let secretStorageKeys = {}; -let secretStorageBeingAccessed = false; - -function isCachingAllowed() { - return secretStorageBeingAccessed; -} - -export class AccessCancelledError extends Error { - constructor() { - super("Secret storage access canceled"); - } -} - -async function confirmToDismiss() { - const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); - const [sure] = await Modal.createDialog(QuestionDialog, { - title: _t("Cancel entering passphrase?"), - description: _t("Are you sure you want to cancel entering passphrase?"), - danger: false, - button: _t("Go Back"), - cancelButton: _t("Cancel"), - }).finished; - return !sure; -} - -async function getSecretStorageKey({ keys: keyInfos }, ssssItemName) { - const keyInfoEntries = Object.entries(keyInfos); - if (keyInfoEntries.length > 1) { - throw new Error("Multiple storage key requests not implemented"); - } - const [name, info] = keyInfoEntries[0]; - - // Check the in-memory cache - if (isCachingAllowed() && secretStorageKeys[name]) { - return [name, secretStorageKeys[name]]; - } - - const inputToKey = async ({ passphrase, recoveryKey }) => { - if (passphrase) { - return deriveKey( - passphrase, - info.passphrase.salt, - info.passphrase.iterations, - ); - } else { - return decodeRecoveryKey(recoveryKey); - } - }; - const AccessSecretStorageDialog = - sdk.getComponent("dialogs.secretstorage.AccessSecretStorageDialog"); - const { finished } = Modal.createTrackedDialog("Access Secret Storage dialog", "", - AccessSecretStorageDialog, - /* props= */ - { - keyInfo: info, - checkPrivateKey: async (input) => { - const key = await inputToKey(input); - return await MatrixClientPeg.get().checkSecretStorageKey(key, info); - }, - }, - /* className= */ null, - /* isPriorityModal= */ false, - /* isStaticModal= */ false, - /* options= */ { - onBeforeClose: async (reason) => { - if (reason === "backgroundClick") { - return confirmToDismiss(); - } - return true; - }, - }, - ); - const [input] = await finished; - if (!input) { - throw new AccessCancelledError(); - } - const key = await inputToKey(input); - - // Save to cache to avoid future prompts in the current session - if (isCachingAllowed()) { - secretStorageKeys[name] = key; - } - - return [name, key]; -} - -const onSecretRequested = async function({ - user_id: userId, - device_id: deviceId, - request_id: requestId, - name, - device_trust: deviceTrust, -}) { - console.log("onSecretRequested", userId, deviceId, requestId, name, deviceTrust); - const client = MatrixClientPeg.get(); - if (userId !== client.getUserId()) { - return; - } - if (!deviceTrust || !deviceTrust.isVerified()) { - console.log(`CrossSigningManager: Ignoring request from untrusted device ${deviceId}`); - return; - } - if ( - name === "m.cross_signing.master" || - name === "m.cross_signing.self_signing" || - name === "m.cross_signing.user_signing" - ) { - const callbacks = client.getCrossSigningCacheCallbacks(); - if (!callbacks.getCrossSigningKeyCache) return; - const keyId = name.replace("m.cross_signing.", ""); - const key = await callbacks.getCrossSigningKeyCache(keyId); - if (!key) { - console.log( - `${keyId} requested by ${deviceId}, but not found in cache`, - ); - } - return key && encodeBase64(key); - } else if (name === "m.megolm_backup.v1") { - const key = await client._crypto.getSessionBackupPrivateKey(); - if (!key) { - console.log( - `session backup key requested by ${deviceId}, but not found in cache`, - ); - } - return key && encodeBase64(key); - } - console.warn("onSecretRequested didn't recognise the secret named ", name); -}; - -export const crossSigningCallbacks = { - getSecretStorageKey, - onSecretRequested, -}; - -export async function promptForBackupPassphrase() { - let key; - - const RestoreKeyBackupDialog = sdk.getComponent('dialogs.keybackup.RestoreKeyBackupDialog'); - const { finished } = Modal.createTrackedDialog('Restore Backup', '', RestoreKeyBackupDialog, { - showSummary: false, keyCallback: k => key = k, - }, null, /* priority = */ false, /* static = */ true); - - const success = await finished; - if (!success) throw new Error("Key backup prompt cancelled"); - - return key; -} - -/** - * This helper should be used whenever you need to access secret storage. It - * ensures that secret storage (and also cross-signing since they each depend on - * each other in a cycle of sorts) have been bootstrapped before running the - * provided function. - * - * Bootstrapping secret storage may take one of these paths: - * 1. Create secret storage from a passphrase and store cross-signing keys - * in secret storage. - * 2. Access existing secret storage by requesting passphrase and accessing - * cross-signing keys as needed. - * 3. All keys are loaded and there's nothing to do. - * - * Additionally, the secret storage keys are cached during the scope of this function - * to ensure the user is prompted only once for their secret storage - * passphrase. The cache is then cleared once the provided function completes. - * - * @param {Function} [func] An operation to perform once secret storage has been - * bootstrapped. Optional. - * @param {bool} [forceReset] Reset secret storage even if it's already set up - */ -export async function accessSecretStorage(func = async () => { }, forceReset = false) { - const cli = MatrixClientPeg.get(); - secretStorageBeingAccessed = true; - try { - if (!await cli.hasSecretStorageKey() || forceReset) { - // This dialog calls bootstrap itself after guiding the user through - // passphrase creation. - const { finished } = Modal.createTrackedDialogAsync('Create Secret Storage dialog', '', - import("./async-components/views/dialogs/secretstorage/CreateSecretStorageDialog"), - { - force: forceReset, - }, - null, /* priority = */ false, /* static = */ true, - ); - const [confirmed] = await finished; - if (!confirmed) { - throw new Error("Secret storage creation canceled"); - } - } else { - const InteractiveAuthDialog = sdk.getComponent("dialogs.InteractiveAuthDialog"); - await cli.bootstrapSecretStorage({ - authUploadDeviceSigningKeys: async (makeRequest) => { - const { finished } = Modal.createTrackedDialog( - 'Cross-signing keys dialog', '', InteractiveAuthDialog, - { - title: _t("Setting up keys"), - matrixClient: MatrixClientPeg.get(), - makeRequest, - }, - ); - const [confirmed] = await finished; - if (!confirmed) { - throw new Error("Cross-signing key upload auth canceled"); - } - }, - getBackupPassphrase: promptForBackupPassphrase, - }); - } - - // `return await` needed here to ensure `finally` block runs after the - // inner operation completes. - return await func(); - } finally { - // Clear secret storage key cache now that work is complete - secretStorageBeingAccessed = false; - if (!isCachingAllowed()) { - secretStorageKeys = {}; - } - } -} diff --git a/src/DateUtils.js b/src/DateUtils.ts similarity index 85% rename from src/DateUtils.js rename to src/DateUtils.ts index 108697238c..9b1edf0775 100644 --- a/src/DateUtils.js +++ b/src/DateUtils.ts @@ -17,7 +17,7 @@ limitations under the License. import { _t } from './languageHandler'; -function getDaysArray() { +function getDaysArray(): string[] { return [ _t('Sun'), _t('Mon'), @@ -29,7 +29,7 @@ function getDaysArray() { ]; } -function getMonthsArray() { +function getMonthsArray(): string[] { return [ _t('Jan'), _t('Feb'), @@ -46,11 +46,11 @@ function getMonthsArray() { ]; } -function pad(n) { +function pad(n: number): string { return (n < 10 ? '0' : '') + n; } -function twelveHourTime(date, showSeconds=false) { +function twelveHourTime(date: Date, showSeconds = false): string { let hours = date.getHours() % 12; const minutes = pad(date.getMinutes()); const ampm = date.getHours() >= 12 ? _t('PM') : _t('AM'); @@ -62,7 +62,7 @@ function twelveHourTime(date, showSeconds=false) { return `${hours}:${minutes}${ampm}`; } -export function formatDate(date, showTwelveHour=false) { +export function formatDate(date: Date, showTwelveHour = false): string { const now = new Date(); const days = getDaysArray(); const months = getMonthsArray(); @@ -86,7 +86,7 @@ export function formatDate(date, showTwelveHour=false) { return formatFullDate(date, showTwelveHour); } -export function formatFullDateNoTime(date) { +export function formatFullDateNoTime(date: Date): string { const days = getDaysArray(); const months = getMonthsArray(); return _t('%(weekDayName)s, %(monthName)s %(day)s %(fullYear)s', { @@ -97,7 +97,7 @@ export function formatFullDateNoTime(date) { }); } -export function formatFullDate(date, showTwelveHour=false) { +export function formatFullDate(date: Date, showTwelveHour = false): string { const days = getDaysArray(); const months = getMonthsArray(); return _t('%(weekDayName)s, %(monthName)s %(day)s %(fullYear)s %(time)s', { @@ -109,14 +109,14 @@ export function formatFullDate(date, showTwelveHour=false) { }); } -export function formatFullTime(date, showTwelveHour=false) { +export function formatFullTime(date: Date, showTwelveHour = false): string { if (showTwelveHour) { return twelveHourTime(date, true); } return pad(date.getHours()) + ':' + pad(date.getMinutes()) + ':' + pad(date.getSeconds()); } -export function formatTime(date, showTwelveHour=false) { +export function formatTime(date: Date, showTwelveHour = false): string { if (showTwelveHour) { return twelveHourTime(date); } @@ -124,7 +124,7 @@ export function formatTime(date, showTwelveHour=false) { } const MILLIS_IN_DAY = 86400000; -export function wantsDateSeparator(prevEventDate, nextEventDate) { +export function wantsDateSeparator(prevEventDate: Date, nextEventDate: Date): boolean { if (!nextEventDate || !prevEventDate) { return false; } diff --git a/src/DeviceListener.ts b/src/DeviceListener.ts index a37521118f..df494e6bdd 100644 --- a/src/DeviceListener.ts +++ b/src/DeviceListener.ts @@ -15,6 +15,7 @@ limitations under the License. */ import {MatrixClientPeg} from './MatrixClientPeg'; +import dis from "./dispatcher/dispatcher"; import { hideToast as hideBulkUnverifiedSessionsToast, showToast as showBulkUnverifiedSessionsToast, @@ -28,11 +29,15 @@ import { hideToast as hideUnverifiedSessionsToast, showToast as showUnverifiedSessionsToast, } from "./toasts/UnverifiedSessionToast"; -import {privateShouldBeEncrypted} from "./createRoom"; +import { isSecretStorageBeingAccessed, accessSecretStorage } from "./SecurityManager"; +import { isSecureBackupRequired } from './utils/WellKnownUtils'; +import { isLoggedIn } from './components/structures/MatrixChat'; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; const KEY_BACKUP_POLL_INTERVAL = 5 * 60 * 1000; export default class DeviceListener { + private dispatcherRef: string; // device IDs for which the user has dismissed the verify toast ('Later') private dismissed = new Set(); // has the user dismissed any of the various nag toasts to setup encryption on this device? @@ -60,6 +65,8 @@ export default class DeviceListener { MatrixClientPeg.get().on('crossSigning.keysChanged', this._onCrossSingingKeysChanged); MatrixClientPeg.get().on('accountData', this._onAccountData); MatrixClientPeg.get().on('sync', this._onSync); + MatrixClientPeg.get().on('RoomState.events', this._onRoomStateEvents); + this.dispatcherRef = dis.register(this._onAction); this._recheck(); } @@ -72,6 +79,11 @@ export default class DeviceListener { MatrixClientPeg.get().removeListener('crossSigning.keysChanged', this._onCrossSingingKeysChanged); MatrixClientPeg.get().removeListener('accountData', this._onAccountData); MatrixClientPeg.get().removeListener('sync', this._onSync); + MatrixClientPeg.get().removeListener('RoomState.events', this._onRoomStateEvents); + } + if (this.dispatcherRef) { + dis.unregister(this.dispatcherRef); + this.dispatcherRef = null; } this.dismissed.clear(); this.dismissedThisDeviceToast = false; @@ -158,6 +170,21 @@ export default class DeviceListener { if (state === 'PREPARED' && prevState === null) this._recheck(); }; + _onRoomStateEvents = (ev: MatrixEvent) => { + if (ev.getType() !== "m.room.encryption") { + return; + } + + // If a room changes to encrypted, re-check as it may be our first + // encrypted room. This also catches encrypted room creation as well. + this._recheck(); + }; + + _onAction = ({ action }) => { + if (action !== "on_logged_in") return; + this._recheck(); + }; + // The server doesn't tell us when key backup is set up, so we poll // & cache the result async _getKeyBackupInfo() { @@ -170,9 +197,10 @@ export default class DeviceListener { } private shouldShowSetupEncryptionToast() { - // In a default configuration, show the toasts. If the well-known config causes e2ee default to be false - // then do not show the toasts until user is in at least one encrypted room. - if (privateShouldBeEncrypted()) return true; + // If we're in the middle of a secret storage operation, we're likely + // modifying the state involved here, so don't add new toasts to setup. + if (isSecretStorageBeingAccessed()) return false; + // Show setup toasts once the user is in at least one encrypted room. const cli = MatrixClientPeg.get(); return cli && cli.getRooms().some(r => cli.isRoomEncrypted(r.roomId)); } @@ -189,15 +217,20 @@ export default class DeviceListener { if (!cli.isInitialSyncComplete()) return; const crossSigningReady = await cli.isCrossSigningReady(); + const secretStorageReady = await cli.isSecretStorageReady(); + const allSystemsReady = crossSigningReady && secretStorageReady; - if (this.dismissedThisDeviceToast || crossSigningReady) { + if (this.dismissedThisDeviceToast || allSystemsReady) { hideSetupEncryptionToast(); } else if (this.shouldShowSetupEncryptionToast()) { // make sure our keys are finished downloading await cli.downloadKeys([cli.getUserId()]); // cross signing isn't enabled - nag to enable it // There are 3 different toasts for: - if (cli.getStoredCrossSigningForUser(cli.getUserId())) { + if ( + !cli.getCrossSigningId() && + cli.getStoredCrossSigningForUser(cli.getUserId()) + ) { // Cross-signing on account but this device doesn't trust the master key (verify this session) showSetupEncryptionToast(SetupKind.VERIFY_THIS_SESSION); } else { @@ -207,7 +240,15 @@ export default class DeviceListener { showSetupEncryptionToast(SetupKind.UPGRADE_ENCRYPTION); } else { // No cross-signing or key backup on account (set up encryption) - showSetupEncryptionToast(SetupKind.SET_UP_ENCRYPTION); + await cli.waitForClientWellKnown(); + if (isSecureBackupRequired() && isLoggedIn()) { + // If we're meant to set up, and Secure Backup is required, + // trigger the flow directly without a toast once logged in. + hideSetupEncryptionToast(); + accessSecretStorage(); + } else { + showSetupEncryptionToast(SetupKind.SET_UP_ENCRYPTION); + } } } } diff --git a/src/FromWidgetPostMessageApi.js b/src/FromWidgetPostMessageApi.js deleted file mode 100644 index d5d7c08d50..0000000000 --- a/src/FromWidgetPostMessageApi.js +++ /dev/null @@ -1,275 +0,0 @@ -/* -Copyright 2018 New Vector Ltd -Copyright 2019 Travis Ralston -Copyright 2019 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the 'License'); -you may not use this file except in compliance with the License. -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 URL from 'url'; -import dis from './dispatcher/dispatcher'; -import WidgetMessagingEndpoint from './WidgetMessagingEndpoint'; -import ActiveWidgetStore from './stores/ActiveWidgetStore'; -import {MatrixClientPeg} from "./MatrixClientPeg"; -import RoomViewStore from "./stores/RoomViewStore"; -import {IntegrationManagers} from "./integrations/IntegrationManagers"; -import SettingsStore from "./settings/SettingsStore"; -import {Capability} from "./widgets/WidgetApi"; -import {objectClone} from "./utils/objects"; - -const WIDGET_API_VERSION = '0.0.2'; // Current API version -const SUPPORTED_WIDGET_API_VERSIONS = [ - '0.0.1', - '0.0.2', -]; -const INBOUND_API_NAME = 'fromWidget'; - -// Listen for and handle incoming requests using the 'fromWidget' postMessage -// API and initiate responses -export default class FromWidgetPostMessageApi { - constructor() { - this.widgetMessagingEndpoints = []; - this.widgetListeners = {}; // {action: func[]} - - this.start = this.start.bind(this); - this.stop = this.stop.bind(this); - this.onPostMessage = this.onPostMessage.bind(this); - } - - start() { - window.addEventListener('message', this.onPostMessage); - } - - stop() { - window.removeEventListener('message', this.onPostMessage); - } - - /** - * Adds a listener for a given action - * @param {string} action The action to listen for. - * @param {Function} callbackFn A callback function to be called when the action is - * encountered. Called with two parameters: the interesting request information and - * the raw event received from the postMessage API. The raw event is meant to be used - * for sendResponse and similar functions. - */ - addListener(action, callbackFn) { - if (!this.widgetListeners[action]) this.widgetListeners[action] = []; - this.widgetListeners[action].push(callbackFn); - } - - /** - * Removes a listener for a given action. - * @param {string} action The action that was subscribed to. - * @param {Function} callbackFn The original callback function that was used to subscribe - * to updates. - */ - removeListener(action, callbackFn) { - if (!this.widgetListeners[action]) return; - - const idx = this.widgetListeners[action].indexOf(callbackFn); - if (idx !== -1) this.widgetListeners[action].splice(idx, 1); - } - - /** - * Register a widget endpoint for trusted postMessage communication - * @param {string} widgetId Unique widget identifier - * @param {string} endpointUrl Widget wurl origin (protocol + (optional port) + host) - */ - addEndpoint(widgetId, endpointUrl) { - const u = URL.parse(endpointUrl); - if (!u || !u.protocol || !u.host) { - console.warn('Add FromWidgetPostMessageApi endpoint - Invalid origin:', endpointUrl); - return; - } - - const origin = u.protocol + '//' + u.host; - const endpoint = new WidgetMessagingEndpoint(widgetId, origin); - if (this.widgetMessagingEndpoints.some(function(ep) { - return (ep.widgetId === widgetId && ep.endpointUrl === endpointUrl); - })) { - // Message endpoint already registered - console.warn('Add FromWidgetPostMessageApi - Endpoint already registered'); - return; - } else { - console.log(`Adding fromWidget messaging endpoint for ${widgetId}`, endpoint); - this.widgetMessagingEndpoints.push(endpoint); - } - } - - /** - * De-register a widget endpoint from trusted communication sources - * @param {string} widgetId Unique widget identifier - * @param {string} endpointUrl Widget wurl origin (protocol + (optional port) + host) - * @return {boolean} True if endpoint was successfully removed - */ - removeEndpoint(widgetId, endpointUrl) { - const u = URL.parse(endpointUrl); - if (!u || !u.protocol || !u.host) { - console.warn('Remove widget messaging endpoint - Invalid origin'); - return; - } - - const origin = u.protocol + '//' + u.host; - if (this.widgetMessagingEndpoints && this.widgetMessagingEndpoints.length > 0) { - const length = this.widgetMessagingEndpoints.length; - this.widgetMessagingEndpoints = this.widgetMessagingEndpoints - .filter((endpoint) => endpoint.widgetId !== widgetId || endpoint.endpointUrl !== origin); - return (length > this.widgetMessagingEndpoints.length); - } - return false; - } - - /** - * Handle widget postMessage events - * Messages are only handled where a valid, registered messaging endpoints - * @param {Event} event Event to handle - * @return {undefined} - */ - onPostMessage(event) { - if (!event.origin) { // Handle chrome - event.origin = event.originalEvent.origin; - } - - // Event origin is empty string if undefined - if ( - event.origin.length === 0 || - !this.trustedEndpoint(event.origin) || - event.data.api !== INBOUND_API_NAME || - !event.data.widgetId - ) { - return; // don't log this - debugging APIs like to spam postMessage which floods the log otherwise - } - - // Call any listeners we have registered - if (this.widgetListeners[event.data.action]) { - for (const fn of this.widgetListeners[event.data.action]) { - fn(event.data, event); - } - } - - // Although the requestId is required, we don't use it. We'll be nice and process the message - // if the property is missing, but with a warning for widget developers. - if (!event.data.requestId) { - console.warn("fromWidget action '" + event.data.action + "' does not have a requestId"); - } - - const action = event.data.action; - const widgetId = event.data.widgetId; - if (action === 'content_loaded') { - console.log('Widget reported content loaded for', widgetId); - dis.dispatch({ - action: 'widget_content_loaded', - widgetId: widgetId, - }); - this.sendResponse(event, {success: true}); - } else if (action === 'supported_api_versions') { - this.sendResponse(event, { - api: INBOUND_API_NAME, - supported_versions: SUPPORTED_WIDGET_API_VERSIONS, - }); - } else if (action === 'api_version') { - this.sendResponse(event, { - api: INBOUND_API_NAME, - version: WIDGET_API_VERSION, - }); - } else if (action === 'm.sticker') { - // console.warn('Got sticker message from widget', widgetId); - // NOTE -- The widgetData field is deprecated (in favour of the 'data' field) and will be removed eventually - const data = event.data.data || event.data.widgetData; - dis.dispatch({action: 'm.sticker', data: data, widgetId: event.data.widgetId}); - } else if (action === 'integration_manager_open') { - // Close the stickerpicker - dis.dispatch({action: 'stickerpicker_close'}); - // Open the integration manager - // NOTE -- The widgetData field is deprecated (in favour of the 'data' field) and will be removed eventually - const data = event.data.data || event.data.widgetData; - const integType = (data && data.integType) ? data.integType : null; - const integId = (data && data.integId) ? data.integId : null; - - // TODO: Open the right integration manager for the widget - if (SettingsStore.getValue("feature_many_integration_managers")) { - IntegrationManagers.sharedInstance().openAll( - MatrixClientPeg.get().getRoom(RoomViewStore.getRoomId()), - `type_${integType}`, - integId, - ); - } else { - IntegrationManagers.sharedInstance().getPrimaryManager().open( - MatrixClientPeg.get().getRoom(RoomViewStore.getRoomId()), - `type_${integType}`, - integId, - ); - } - } else if (action === 'set_always_on_screen') { - // This is a new message: there is no reason to support the deprecated widgetData here - const data = event.data.data; - const val = data.value; - - if (ActiveWidgetStore.widgetHasCapability(widgetId, Capability.AlwaysOnScreen)) { - ActiveWidgetStore.setWidgetPersistence(widgetId, val); - } - } else if (action === 'get_openid') { - // Handled by caller - } else { - console.warn('Widget postMessage event unhandled'); - this.sendError(event, {message: 'The postMessage was unhandled'}); - } - } - - /** - * Check if message origin is registered as trusted - * @param {string} origin PostMessage origin to check - * @return {boolean} True if trusted - */ - trustedEndpoint(origin) { - if (!origin) { - return false; - } - - return this.widgetMessagingEndpoints.some((endpoint) => { - // TODO / FIXME -- Should this also check the widgetId? - return endpoint.endpointUrl === origin; - }); - } - - /** - * Send a postmessage response to a postMessage request - * @param {Event} event The original postMessage request event - * @param {Object} res Response data - */ - sendResponse(event, res) { - const data = objectClone(event.data); - data.response = res; - event.source.postMessage(data, event.origin); - } - - /** - * Send an error response to a postMessage request - * @param {Event} event The original postMessage request event - * @param {string} msg Error message - * @param {Error} nestedError Nested error event (optional) - */ - sendError(event, msg, nestedError) { - console.error('Action:' + event.data.action + ' failed with message: ' + msg); - const data = objectClone(event.data); - data.response = { - error: { - message: msg, - }, - }; - if (nestedError) { - data.response.error._error = nestedError; - } - event.source.postMessage(data, event.origin); - } -} diff --git a/src/HtmlUtils.tsx b/src/HtmlUtils.tsx index 5d33645bb7..c503247bf7 100644 --- a/src/HtmlUtils.tsx +++ b/src/HtmlUtils.tsx @@ -19,6 +19,7 @@ limitations under the License. import React from 'react'; import sanitizeHtml from 'sanitize-html'; +import { IExtendedSanitizeOptions } from './@types/sanitize-html'; import * as linkify from 'linkifyjs'; import linkifyMatrix from './linkify-matrix'; import _linkifyElement from 'linkifyjs/element'; @@ -52,7 +53,7 @@ const BIGEMOJI_REGEX = new RegExp(`^(${EMOJIBASE_REGEX.source})+$`, 'i'); const COLOR_REGEX = /^#[0-9a-fA-F]{6}$/; -const PERMITTED_URL_SCHEMES = ['http', 'https', 'ftp', 'mailto', 'magnet']; +export const PERMITTED_URL_SCHEMES = ['http', 'https', 'ftp', 'mailto', 'magnet']; /* * Return true if the given string contains emoji @@ -151,7 +152,7 @@ export function isUrlPermitted(inputUrl: string) { } } -const transformTags: sanitizeHtml.IOptions["transformTags"] = { // custom to matrix +const transformTags: IExtendedSanitizeOptions["transformTags"] = { // custom to matrix // add blank targets to all hyperlinks except vector URLs 'a': function(tagName: string, attribs: sanitizeHtml.Attributes) { if (attribs.href) { @@ -224,7 +225,7 @@ const transformTags: sanitizeHtml.IOptions["transformTags"] = { // custom to mat }, }; -const sanitizeHtmlParams: sanitizeHtml.IOptions = { +const sanitizeHtmlParams: IExtendedSanitizeOptions = { allowedTags: [ 'font', // custom to matrix for IRC-style font coloring 'del', // for markdown @@ -245,13 +246,14 @@ const sanitizeHtmlParams: sanitizeHtml.IOptions = { selfClosing: ['img', 'br', 'hr', 'area', 'base', 'basefont', 'input', 'link', 'meta'], // URL schemes we permit allowedSchemes: PERMITTED_URL_SCHEMES, - allowProtocolRelative: false, transformTags, + // 50 levels deep "should be enough for anyone" + nestingLimit: 50, }; // this is the same as the above except with less rewriting -const composerSanitizeHtmlParams: sanitizeHtml.IOptions = { +const composerSanitizeHtmlParams: IExtendedSanitizeOptions = { ...sanitizeHtmlParams, transformTags: { 'code': transformTags['code'], @@ -339,33 +341,9 @@ class HtmlHighlighter extends BaseHighlighter { } } -class TextHighlighter extends BaseHighlighter { - private key = 0; - - /* create a node to hold the given content - * - * snippet: content of the span - * highlight: true to highlight as a search match - * - * returns a React node - */ - protected processSnippet(snippet: string, highlight: boolean): React.ReactNode { - const key = this.key++; - - let node = - { snippet } - ; - - if (highlight && this.highlightLink) { - node = { node }; - } - - return node; - } -} - interface IContent { format?: string; + // eslint-disable-next-line camelcase formatted_body?: string; body: string; } @@ -474,8 +452,13 @@ export function bodyToHtml(content: IContent, highlights: string[], opts: IOpts }); return isDisplayedWithHtml ? - : - { strippedBody }; + : { strippedBody }; } /** diff --git a/src/Lifecycle.js b/src/Lifecycle.ts similarity index 79% rename from src/Lifecycle.js rename to src/Lifecycle.ts index d2de31eb80..6293de063d 100644 --- a/src/Lifecycle.js +++ b/src/Lifecycle.ts @@ -17,9 +17,13 @@ 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 { InvalidStoreError } from "matrix-js-sdk/src/errors"; +import { MatrixClient } from "matrix-js-sdk/src/client"; -import {MatrixClientPeg} from './MatrixClientPeg'; +import {IMatrixClientCreds, MatrixClientPeg} from './MatrixClientPeg'; +import SecurityCustomisations from "./customisations/Security"; import EventIndexPeg from './indexing/EventIndexPeg'; import createMatrixClient from './utils/createMatrixClient'; import Analytics from './Analytics'; @@ -42,48 +46,51 @@ import {Mjolnir} from "./mjolnir/Mjolnir"; import DeviceListener from "./DeviceListener"; import {Jitsi} from "./widgets/Jitsi"; import {SSO_HOMESERVER_URL_KEY, SSO_ID_SERVER_URL_KEY} from "./BasePlatform"; +import ThreepidInviteStore from "./stores/ThreepidInviteStore"; const HOMESERVER_URL_KEY = "mx_hs_url"; const ID_SERVER_URL_KEY = "mx_is_url"; +interface ILoadSessionOpts { + enableGuest?: boolean; + guestHsUrl?: string; + guestIsUrl?: string; + ignoreGuest?: boolean; + defaultDeviceDisplayName?: string; + fragmentQueryParams?: Record; +} + /** * Called at startup, to attempt to build a logged-in Matrix session. It tries * a number of things: * - * * 1. if we have a guest access token in the fragment query params, it uses * that. - * * 2. if an access token is stored in local storage (from a previous session), * it uses that. - * * 3. it attempts to auto-register as a guest user. * * If any of steps 1-4 are successful, it will call {_doSetLoggedIn}, which in * turn will raise on_logged_in and will_start_client events. * - * @param {object} opts - * - * @param {object} opts.fragmentQueryParams: string->string map of the + * @param {object} [opts] + * @param {object} [opts.fragmentQueryParams]: string->string map of the * query-parameters extracted from the #-fragment of the starting URI. - * - * @param {boolean} opts.enableGuest: set to true to enable guest access tokens - * and auto-guest registrations. - * - * @params {string} opts.guestHsUrl: homeserver URL. Only used if enableGuest is - * true; defines the HS to register against. - * - * @params {string} opts.guestIsUrl: homeserver URL. Only used if enableGuest is - * true; defines the IS to use. - * - * @params {bool} opts.ignoreGuest: If the stored session is a guest account, ignore - * it and don't load it. - * + * @param {boolean} [opts.enableGuest]: set to true to enable guest access + * tokens and auto-guest registrations. + * @param {string} [opts.guestHsUrl]: homeserver URL. Only used if enableGuest + * is true; defines the HS to register against. + * @param {string} [opts.guestIsUrl]: homeserver URL. Only used if enableGuest + * is true; defines the IS to use. + * @param {bool} [opts.ignoreGuest]: If the stored session is a guest account, + * ignore it and don't load it. + * @param {string} [opts.defaultDeviceDisplayName]: Default display name to use + * when registering as a guest. * @returns {Promise} a promise which resolves when the above process completes. * Resolves to `true` if we ended up starting a session, or `false` if we * failed. */ -export async function loadSession(opts) { +export async function loadSession(opts: ILoadSessionOpts = {}): Promise { try { let enableGuest = opts.enableGuest || false; const guestHsUrl = opts.guestHsUrl; @@ -96,12 +103,13 @@ export async function loadSession(opts) { enableGuest = false; } - if (enableGuest && + if ( + enableGuest && fragmentQueryParams.guest_user_id && fragmentQueryParams.guest_access_token - ) { + ) { console.log("Using guest access credentials"); - return _doSetLoggedIn({ + return doSetLoggedIn({ userId: fragmentQueryParams.guest_user_id, accessToken: fragmentQueryParams.guest_access_token, homeserverUrl: guestHsUrl, @@ -109,7 +117,7 @@ export async function loadSession(opts) { guest: true, }, true).then(() => true); } - const success = await _restoreFromLocalStorage({ + const success = await restoreFromLocalStorage({ ignoreGuest: Boolean(opts.ignoreGuest), }); if (success) { @@ -117,7 +125,7 @@ export async function loadSession(opts) { } if (enableGuest) { - return _registerAsGuest(guestHsUrl, guestIsUrl, defaultDeviceDisplayName); + return registerAsGuest(guestHsUrl, guestIsUrl, defaultDeviceDisplayName); } // fall back to welcome screen @@ -128,7 +136,7 @@ export async function loadSession(opts) { // need to show the general failure dialog. Instead, just go back to welcome. return false; } - return _handleLoadSessionFailure(e); + return handleLoadSessionFailure(e); } } @@ -138,7 +146,7 @@ export async function loadSession(opts) { * is associated with them. The session is not loaded. * @returns {String} The persisted session's owner, if an owner exists. Null otherwise. */ -export function getStoredSessionOwner() { +export function getStoredSessionOwner(): string { const {hsUrl, userId, accessToken} = getLocalStorageSessionVars(); return hsUrl && userId && accessToken ? userId : null; } @@ -147,7 +155,7 @@ export function getStoredSessionOwner() { * @returns {bool} True if the stored session is for a guest user or false if it is * for a real user. If there is no stored session, return null. */ -export function getStoredSessionIsGuest() { +export function getStoredSessionIsGuest(): boolean { const sessVars = getLocalStorageSessionVars(); return sessVars.hsUrl && sessVars.userId && sessVars.accessToken ? sessVars.isGuest : null; } @@ -162,7 +170,10 @@ export function getStoredSessionIsGuest() { * @returns {Promise} promise which resolves to true if we completed the token * login, else false */ -export function attemptTokenLogin(queryParams, defaultDeviceDisplayName) { +export function attemptTokenLogin( + queryParams: Record, + defaultDeviceDisplayName?: string, +): Promise { if (!queryParams.loginToken) { return Promise.resolve(false); } @@ -183,8 +194,10 @@ export function attemptTokenLogin(queryParams, defaultDeviceDisplayName) { }, ).then(function(creds) { console.log("Logged in with token"); - return _clearStorage().then(() => { - _persistCredentialsToLocalStorage(creds); + return clearStorage().then(() => { + persistCredentialsToLocalStorage(creds); + // remember that we just logged in + sessionStorage.setItem("mx_fresh_login", String(true)); return true; }); }).catch((err) => { @@ -194,8 +207,8 @@ export function attemptTokenLogin(queryParams, defaultDeviceDisplayName) { }); } -export function handleInvalidStoreError(e) { - if (e.reason === Matrix.InvalidStoreError.TOGGLED_LAZY_LOADING) { +export function handleInvalidStoreError(e: InvalidStoreError): Promise { + if (e.reason === InvalidStoreError.TOGGLED_LAZY_LOADING) { return Promise.resolve().then(() => { const lazyLoadEnabled = e.value; if (lazyLoadEnabled) { @@ -228,7 +241,11 @@ export function handleInvalidStoreError(e) { } } -function _registerAsGuest(hsUrl, isUrl, defaultDeviceDisplayName) { +function registerAsGuest( + hsUrl: string, + isUrl: string, + defaultDeviceDisplayName: string, +): Promise { console.log(`Doing guest login on ${hsUrl}`); // create a temporary MatrixClient to do the login @@ -242,7 +259,7 @@ function _registerAsGuest(hsUrl, isUrl, defaultDeviceDisplayName) { }, }).then((creds) => { console.log(`Registered as guest: ${creds.user_id}`); - return _doSetLoggedIn({ + return doSetLoggedIn({ userId: creds.user_id, deviceId: creds.device_id, accessToken: creds.access_token, @@ -256,12 +273,21 @@ function _registerAsGuest(hsUrl, isUrl, defaultDeviceDisplayName) { }); } +export interface ILocalStorageSession { + hsUrl: string; + isUrl: string; + accessToken: string; + userId: string; + deviceId: string; + isGuest: boolean; +} + /** * Retrieves information about the stored session in localstorage. The session * may not be valid, as it is not tested for consistency here. * @returns {Object} Information about the session - see implementation for variables. */ -export function getLocalStorageSessionVars() { +export function getLocalStorageSessionVars(): ILocalStorageSession { const hsUrl = localStorage.getItem(HOMESERVER_URL_KEY); const isUrl = localStorage.getItem(ID_SERVER_URL_KEY); const accessToken = localStorage.getItem("mx_access_token"); @@ -289,8 +315,8 @@ export function getLocalStorageSessionVars() { // The plan is to gradually move the localStorage access done here into // SessionStore to avoid bugs where the view becomes out-of-sync with // localStorage (e.g. isGuest etc.) -async function _restoreFromLocalStorage(opts) { - const ignoreGuest = opts.ignoreGuest; +async function restoreFromLocalStorage(opts?: { ignoreGuest?: boolean }): Promise { + const ignoreGuest = opts?.ignoreGuest; if (!localStorage) { return false; @@ -311,8 +337,11 @@ async function _restoreFromLocalStorage(opts) { console.log("No pickle key available"); } + const freshLogin = sessionStorage.getItem("mx_fresh_login") === "true"; + sessionStorage.removeItem("mx_fresh_login"); + console.log(`Restoring session for ${userId}`); - await _doSetLoggedIn({ + await doSetLoggedIn({ userId: userId, deviceId: deviceId, accessToken: accessToken, @@ -320,6 +349,7 @@ async function _restoreFromLocalStorage(opts) { identityServerUrl: isUrl, guest: isGuest, pickleKey: pickleKey, + freshLogin: freshLogin, }, false); return true; } else { @@ -328,7 +358,7 @@ async function _restoreFromLocalStorage(opts) { } } -async function _handleLoadSessionFailure(e) { +async function handleLoadSessionFailure(e: Error): Promise { console.error("Unable to load session", e); const SessionRestoreErrorDialog = @@ -341,7 +371,7 @@ async function _handleLoadSessionFailure(e) { const [success] = await modal.finished; if (success) { // user clicked continue. - await _clearStorage(); + await clearStorage(); return false; } @@ -362,11 +392,12 @@ async function _handleLoadSessionFailure(e) { * * @returns {Promise} promise which resolves to the new MatrixClient once it has been started */ -export async function setLoggedIn(credentials) { +export async function setLoggedIn(credentials: IMatrixClientCreds): Promise { + credentials.freshLogin = true; stopMatrixClient(); const pickleKey = credentials.userId && credentials.deviceId - ? await PlatformPeg.get().createPickleKey(credentials.userId, credentials.deviceId) - : null; + ? await PlatformPeg.get().createPickleKey(credentials.userId, credentials.deviceId) + : null; if (pickleKey) { console.log("Created pickle key"); @@ -374,7 +405,7 @@ export async function setLoggedIn(credentials) { console.log("Pickle key not created"); } - return _doSetLoggedIn(Object.assign({}, credentials, {pickleKey}), true); + return doSetLoggedIn(Object.assign({}, credentials, {pickleKey}), true); } /** @@ -392,7 +423,7 @@ export async function setLoggedIn(credentials) { * * @returns {Promise} promise which resolves to the new MatrixClient once it has been started */ -export function hydrateSession(credentials) { +export function hydrateSession(credentials: IMatrixClientCreds): Promise { const oldUserId = MatrixClientPeg.get().getUserId(); const oldDeviceId = MatrixClientPeg.get().getDeviceId(); @@ -405,7 +436,7 @@ export function hydrateSession(credentials) { console.warn("Clearing all data: Old session belongs to a different user/session"); } - return _doSetLoggedIn(credentials, overwrite); + return doSetLoggedIn(credentials, overwrite); } /** @@ -417,7 +448,10 @@ export function hydrateSession(credentials) { * * @returns {Promise} promise which resolves to the new MatrixClient once it has been started */ -async function _doSetLoggedIn(credentials, clearStorage) { +async function doSetLoggedIn( + credentials: IMatrixClientCreds, + clearStorageEnabled: boolean, +): Promise { credentials.guest = Boolean(credentials.guest); const softLogout = isSoftLogout(); @@ -428,6 +462,7 @@ async function _doSetLoggedIn(credentials, clearStorage) { " guest: " + credentials.guest + " hs: " + credentials.homeserverUrl + " softLogout: " + softLogout, + " freshLogin: " + credentials.freshLogin, ); // This is dispatched to indicate that the user is still in the process of logging in @@ -439,8 +474,8 @@ async function _doSetLoggedIn(credentials, clearStorage) { // (dis.dispatch uses `setTimeout`, which does not guarantee ordering.) dis.dispatch({action: 'on_logging_in'}, true); - if (clearStorage) { - await _clearStorage(); + if (clearStorageEnabled) { + await clearStorage(); } const results = await StorageManager.checkConsistency(); @@ -448,9 +483,9 @@ async function _doSetLoggedIn(credentials, clearStorage) { // crypto store, we'll be generally confused when handling encrypted data. // Show a modal recommending a full reset of storage. if (results.dataInLocalStorage && results.cryptoInited && !results.dataInCryptoStore) { - const signOut = await _showStorageEvictedDialog(); + const signOut = await showStorageEvictedDialog(); if (signOut) { - await _clearStorage(); + await clearStorage(); // This error feels a bit clunky, but we want to make sure we don't go any // further and instead head back to sign in. throw new AbortLoginAndRebuildStorage( @@ -461,19 +496,26 @@ async function _doSetLoggedIn(credentials, clearStorage) { Analytics.setLoggedIn(credentials.guest, credentials.homeserverUrl); + MatrixClientPeg.replaceUsingCreds(credentials); + const client = MatrixClientPeg.get(); + + if (credentials.freshLogin && SettingsStore.getValue("feature_dehydration")) { + // If we just logged in, try to rehydrate a device instead of using a + // new device. If it succeeds, we'll get a new device ID, so make sure + // we persist that ID to localStorage + const newDeviceId = await client.rehydrateDevice(); + if (newDeviceId) { + credentials.deviceId = newDeviceId; + } + + delete credentials.freshLogin; + } + if (localStorage) { try { - _persistCredentialsToLocalStorage(credentials); - - // The user registered as a PWLU (PassWord-Less User), the generated password - // is cached here such that the user can change it at a later time. - if (credentials.password) { - // Update SessionStore - dis.dispatch({ - action: 'cached_password', - cachedPassword: credentials.password, - }); - } + persistCredentialsToLocalStorage(credentials); + // make sure we don't think that it's a fresh login any more + sessionStorage.removeItem("mx_fresh_login"); } catch (e) { console.warn("Error using local storage: can't persist session!", e); } @@ -481,15 +523,13 @@ async function _doSetLoggedIn(credentials, clearStorage) { console.warn("No local storage available: can't persist session!"); } - MatrixClientPeg.replaceUsingCreds(credentials); - dis.dispatch({ action: 'on_logged_in' }); await startMatrixClient(/*startSyncing=*/!softLogout); - return MatrixClientPeg.get(); + return client; } -function _showStorageEvictedDialog() { +function showStorageEvictedDialog(): Promise { const StorageEvictedDialog = sdk.getComponent('views.dialogs.StorageEvictedDialog'); return new Promise(resolve => { Modal.createTrackedDialog('Storage evicted', '', StorageEvictedDialog, { @@ -502,7 +542,7 @@ function _showStorageEvictedDialog() { // `instanceof`. Babel 7 supports this natively in their class handling. class AbortLoginAndRebuildStorage extends Error { } -function _persistCredentialsToLocalStorage(credentials) { +function persistCredentialsToLocalStorage(credentials: IMatrixClientCreds): void { localStorage.setItem(HOMESERVER_URL_KEY, credentials.homeserverUrl); if (credentials.identityServerUrl) { localStorage.setItem(ID_SERVER_URL_KEY, credentials.identityServerUrl); @@ -512,7 +552,7 @@ function _persistCredentialsToLocalStorage(credentials) { localStorage.setItem("mx_is_guest", JSON.stringify(credentials.guest)); if (credentials.pickleKey) { - localStorage.setItem("mx_has_pickle_key", true); + localStorage.setItem("mx_has_pickle_key", String(true)); } else { if (localStorage.getItem("mx_has_pickle_key")) { console.error("Expected a pickle key, but none provided. Encryption may not work."); @@ -528,6 +568,8 @@ function _persistCredentialsToLocalStorage(credentials) { localStorage.setItem("mx_device_id", credentials.deviceId); } + SecurityCustomisations.persistCredentials?.(credentials); + console.log(`Session persisted for ${credentials.userId}`); } @@ -536,7 +578,7 @@ let _isLoggingOut = false; /** * Logs the current session out and transitions to the logged-out state */ -export function logout() { +export function logout(): void { if (!MatrixClientPeg.get()) return; if (MatrixClientPeg.get().isGuest()) { @@ -565,7 +607,7 @@ export function logout() { ); } -export function softLogout() { +export function softLogout(): void { if (!MatrixClientPeg.get()) return; // Track that we've detected and trapped a soft logout. This helps prevent other @@ -586,11 +628,11 @@ export function softLogout() { // DO NOT CALL LOGOUT. A soft logout preserves data, logout does not. } -export function isSoftLogout() { +export function isSoftLogout(): boolean { return localStorage.getItem("mx_soft_logout") === "true"; } -export function isLoggingOut() { +export function isLoggingOut(): boolean { return _isLoggingOut; } @@ -600,7 +642,7 @@ export function isLoggingOut() { * @param {boolean} startSyncing True (default) to actually start * syncing the client. */ -async function startMatrixClient(startSyncing=true) { +async function startMatrixClient(startSyncing = true): Promise { console.log(`Lifecycle: Starting MatrixClient`); // dispatch this before starting the matrix client: it's used @@ -659,24 +701,37 @@ async function startMatrixClient(startSyncing=true) { * Stops a running client and all related services, and clears persistent * storage. Used after a session has been logged out. */ -export async function onLoggedOut() { +export async function onLoggedOut(): Promise { _isLoggingOut = false; // Ensure that we dispatch a view change **before** stopping the client so // so that React components unmount first. This avoids React soft crashes // that can occur when components try to use a null client. dis.dispatch({action: 'on_logged_out'}, true); stopMatrixClient(); - await _clearStorage(); + await clearStorage({deleteEverything: true}); } /** + * @param {object} opts Options for how to clear storage. * @returns {Promise} promise which resolves once the stores have been cleared */ -async function _clearStorage() { +async function clearStorage(opts?: { deleteEverything?: boolean }): Promise { Analytics.disable(); if (window.localStorage) { + // try to save any 3pid invites from being obliterated + const pendingInvites = ThreepidInviteStore.instance.getWireInvites(); + window.localStorage.clear(); + + // now restore those invites + if (!opts?.deleteEverything) { + pendingInvites.forEach(i => { + const roomId = i.roomId; + delete i.roomId; // delete to avoid confusing the store + ThreepidInviteStore.instance.storeInvite(roomId, i); + }); + } } if (window.sessionStorage) { @@ -698,7 +753,7 @@ async function _clearStorage() { * @param {boolean} unsetClient True (default) to abandon the client * on MatrixClientPeg after stopping. */ -export function stopMatrixClient(unsetClient=true) { +export function stopMatrixClient(unsetClient = true): void { Notifier.stop(); UserActivity.sharedInstance().stop(); TypingStore.sharedInstance().reset(); diff --git a/src/Login.js b/src/Login.ts similarity index 54% rename from src/Login.js rename to src/Login.ts index 04805b4af9..ae4aa226ed 100644 --- a/src/Login.js +++ b/src/Login.ts @@ -18,35 +18,73 @@ 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 { MatrixClient } from "matrix-js-sdk/src/client"; +import { IMatrixClientCreds } from "./MatrixClientPeg"; +import SecurityCustomisations from "./customisations/Security"; + +interface ILoginOptions { + defaultDeviceDisplayName?: string; +} + +// TODO: Move this to JS SDK +interface ILoginFlow { + type: string; +} + +// TODO: Move this to JS SDK +/* eslint-disable camelcase */ +interface ILoginParams { + identifier?: string; + password?: string; + token?: string; + device_id?: string; + initial_device_display_name?: string; +} +/* eslint-enable camelcase */ export default class Login { - constructor(hsUrl, isUrl, fallbackHsUrl, opts) { - this._hsUrl = hsUrl; - this._isUrl = isUrl; - this._fallbackHsUrl = fallbackHsUrl; - this._currentFlowIndex = 0; - this._flows = []; - this._defaultDeviceDisplayName = opts.defaultDeviceDisplayName; - this._tempClient = null; // memoize + private hsUrl: string; + private isUrl: string; + private fallbackHsUrl: string; + private currentFlowIndex: number; + // TODO: Flows need a type in JS SDK + private flows: Array; + private defaultDeviceDisplayName: string; + private tempClient: MatrixClient; + + constructor( + hsUrl: string, + isUrl: string, + fallbackHsUrl?: string, + opts?: ILoginOptions, + ) { + this.hsUrl = hsUrl; + this.isUrl = isUrl; + this.fallbackHsUrl = fallbackHsUrl; + this.currentFlowIndex = 0; + this.flows = []; + this.defaultDeviceDisplayName = opts.defaultDeviceDisplayName; + this.tempClient = null; // memoize } - getHomeserverUrl() { - return this._hsUrl; + public getHomeserverUrl(): string { + return this.hsUrl; } - getIdentityServerUrl() { - return this._isUrl; + public getIdentityServerUrl(): string { + return this.isUrl; } - setHomeserverUrl(hsUrl) { - this._tempClient = null; // clear memoization - this._hsUrl = hsUrl; + public setHomeserverUrl(hsUrl: string): void { + this.tempClient = null; // clear memoization + this.hsUrl = hsUrl; } - setIdentityServerUrl(isUrl) { - this._tempClient = null; // clear memoization - this._isUrl = isUrl; + public setIdentityServerUrl(isUrl: string): void { + this.tempClient = null; // clear memoization + this.isUrl = isUrl; } /** @@ -54,40 +92,41 @@ export default class Login { * requests. * @returns {MatrixClient} */ - createTemporaryClient() { - if (this._tempClient) return this._tempClient; // use memoization - return this._tempClient = Matrix.createClient({ - baseUrl: this._hsUrl, - idBaseUrl: this._isUrl, + public createTemporaryClient(): MatrixClient { + if (this.tempClient) return this.tempClient; // use memoization + return this.tempClient = Matrix.createClient({ + baseUrl: this.hsUrl, + idBaseUrl: this.isUrl, }); } - getFlows() { - const self = this; + public async getFlows(): Promise> { const client = this.createTemporaryClient(); - return client.loginFlows().then(function(result) { - self._flows = result.flows; - self._currentFlowIndex = 0; - // technically the UI should display options for all flows for the - // user to then choose one, so return all the flows here. - return self._flows; - }); + const { flows } = await client.loginFlows(); + this.flows = flows; + this.currentFlowIndex = 0; + // technically the UI should display options for all flows for the + // user to then choose one, so return all the flows here. + return this.flows; } - chooseFlow(flowIndex) { - this._currentFlowIndex = flowIndex; + public chooseFlow(flowIndex): void { + this.currentFlowIndex = flowIndex; } - getCurrentFlowStep() { + public getCurrentFlowStep(): string { // technically the flow can have multiple steps, but no one does this // for login so we can ignore it. - const flowStep = this._flows[this._currentFlowIndex]; + const flowStep = this.flows[this.currentFlowIndex]; return flowStep ? flowStep.type : null; } - loginViaPassword(username, phoneCountry, phoneNumber, pass) { - const self = this; - + public loginViaPassword( + username: string, + phoneCountry: string, + phoneNumber: string, + password: string, + ): Promise { const isEmail = username.indexOf("@") > 0; let identifier; @@ -113,14 +152,14 @@ export default class Login { } const loginParams = { - password: pass, - identifier: identifier, - initial_device_display_name: this._defaultDeviceDisplayName, + password, + identifier, + initial_device_display_name: this.defaultDeviceDisplayName, }; const tryFallbackHs = (originalError) => { return sendLoginRequest( - self._fallbackHsUrl, this._isUrl, 'm.login.password', loginParams, + this.fallbackHsUrl, this.isUrl, 'm.login.password', loginParams, ).catch((fallbackError) => { console.log("fallback HS login failed", fallbackError); // throw the original error @@ -130,11 +169,11 @@ export default class Login { let originalLoginError = null; return sendLoginRequest( - self._hsUrl, self._isUrl, 'm.login.password', loginParams, + this.hsUrl, this.isUrl, 'm.login.password', loginParams, ).catch((error) => { originalLoginError = error; if (error.httpStatus === 403) { - if (self._fallbackHsUrl) { + if (this.fallbackHsUrl) { return tryFallbackHs(originalLoginError); } } @@ -154,11 +193,16 @@ export default class Login { * @param {string} hsUrl the base url of the Homeserver used to log in. * @param {string} isUrl the base url of the default identity server * @param {string} loginType the type of login to do - * @param {object} loginParams the parameters for the login + * @param {ILoginParams} loginParams the parameters for the login * * @returns {MatrixClientCreds} */ -export async function sendLoginRequest(hsUrl, isUrl, loginType, loginParams) { +export async function sendLoginRequest( + hsUrl: string, + isUrl: string, + loginType: string, + loginParams: ILoginParams, +): Promise { const client = Matrix.createClient({ baseUrl: hsUrl, idBaseUrl: isUrl, @@ -179,11 +223,15 @@ export async function sendLoginRequest(hsUrl, isUrl, loginType, loginParams) { } } - return { + const creds: IMatrixClientCreds = { homeserverUrl: hsUrl, identityServerUrl: isUrl, userId: data.user_id, deviceId: data.device_id, accessToken: data.access_token, }; + + SecurityCustomisations.examineLoginResponse?.(data, creds); + + return creds; } diff --git a/src/Markdown.js b/src/Markdown.js index e57507b4de..492450e87d 100644 --- a/src/Markdown.js +++ b/src/Markdown.js @@ -15,7 +15,7 @@ limitations under the License. */ import commonmark from 'commonmark'; -import escape from 'lodash/escape'; +import {escape} from "lodash"; const ALLOWED_HTML_TAGS = ['sub', 'sup', 'del', 'u']; diff --git a/src/MatrixClientPeg.ts b/src/MatrixClientPeg.ts index be16f5fe10..5bb10dfa89 100644 --- a/src/MatrixClientPeg.ts +++ b/src/MatrixClientPeg.ts @@ -17,6 +17,7 @@ See the License for the specific language governing permissions and limitations under the License. */ +import { ICreateClientOpts } from 'matrix-js-sdk/src/matrix'; import {MatrixClient} from 'matrix-js-sdk/src/client'; import {MemoryStore} from 'matrix-js-sdk/src/store/memory'; import * as utils from 'matrix-js-sdk/src/utils'; @@ -31,17 +32,18 @@ import {verificationMethods} from 'matrix-js-sdk/src/crypto'; import MatrixClientBackedSettingsHandler from "./settings/handlers/MatrixClientBackedSettingsHandler"; import * as StorageManager from './utils/StorageManager'; import IdentityAuthClient from './IdentityAuthClient'; -import { crossSigningCallbacks } from './CrossSigningManager'; +import { crossSigningCallbacks, tryToUnlockSecretStorageWithDehydrationKey } from './SecurityManager'; import {SHOW_QR_CODE_METHOD} from "matrix-js-sdk/src/crypto/verification/QRCode"; export interface IMatrixClientCreds { homeserverUrl: string; identityServerUrl: string; userId: string; - deviceId: string; + deviceId?: string; accessToken: string; - guest: boolean; + guest?: boolean; pickleKey?: string; + freshLogin?: boolean; } // TODO: Move this to the js-sdk @@ -192,6 +194,7 @@ class _MatrixClientPeg implements IMatrixClientPeg { this.matrixClient.setCryptoTrustCrossSignedDevices( !SettingsStore.getValue('e2ee.manuallyVerifyAllSessions'), ); + await tryToUnlockSecretStorageWithDehydrationKey(this.matrixClient); StorageManager.setCryptoInitialised(true); } } catch (e) { @@ -247,8 +250,7 @@ class _MatrixClientPeg implements IMatrixClientPeg { } private createClient(creds: IMatrixClientCreds): void { - // TODO: Make these opts typesafe with the js-sdk - const opts = { + const opts: ICreateClientOpts = { baseUrl: creds.homeserverUrl, idBaseUrl: creds.identityServerUrl, accessToken: creds.accessToken, diff --git a/src/Modal.tsx b/src/Modal.tsx index 82ed33b794..b0f6ef988e 100644 --- a/src/Modal.tsx +++ b/src/Modal.tsx @@ -132,7 +132,7 @@ export class ModalManager { public createTrackedDialogAsync( analyticsAction: string, analyticsInfo: string, - ...rest: Parameters + ...rest: Parameters ) { Analytics.trackEvent('Modal', analyticsAction, analyticsInfo); return this.createDialogAsync(...rest); @@ -151,7 +151,7 @@ export class ModalManager { prom: Promise, props?: IProps, className?: string, - options?: IOptions + options?: IOptions, ) { const modal: IModal = { onFinished: props ? props.onFinished : null, @@ -182,7 +182,7 @@ export class ModalManager { private getCloseFn( modal: IModal, - props: IProps + props: IProps, ): [IHandle["close"], IHandle["finished"]] { const deferred = defer(); return [async (...args: T) => { @@ -264,7 +264,7 @@ export class ModalManager { className?: string, isPriorityModal = false, isStaticModal = false, - options: IOptions = {} + options: IOptions = {}, ): IHandle { const {modal, closeDialog, onFinishedProm} = this.buildModal(prom, props, className, options); if (isPriorityModal) { @@ -287,7 +287,7 @@ export class ModalManager { private appendDialogAsync( prom: Promise, props?: IProps, - className?: string + className?: string, ): IHandle { const {modal, closeDialog, onFinishedProm} = this.buildModal(prom, props, className, {}); diff --git a/src/Notifier.ts b/src/Notifier.ts index 473de6c161..1899896f9b 100644 --- a/src/Notifier.ts +++ b/src/Notifier.ts @@ -33,6 +33,7 @@ import Modal from './Modal'; import SettingsStore from "./settings/SettingsStore"; import { hideToast as hideNotificationsToast } from "./toasts/DesktopNotificationsToast"; import {SettingLevel} from "./settings/SettingLevel"; +import {isPushNotifyDisabled} from "./settings/controllers/NotificationControllers"; /* * Dispatches: @@ -217,7 +218,7 @@ export const Notifier = { // calculated value. It is determined based upon whether or not the master rule is enabled // and other flags. Setting it here would cause a circular reference. - Analytics.trackEvent('Notifier', 'Set Enabled', enable); + Analytics.trackEvent('Notifier', 'Set Enabled', String(enable)); // make sure that we persist the current setting audio_enabled setting // before changing anything @@ -258,7 +259,7 @@ export const Notifier = { } // set the notifications_hidden flag, as the user has knowingly interacted // with the setting we shouldn't nag them any further - this.setToolbarHidden(true); + this.setPromptHidden(true); }, isEnabled: function() { @@ -283,10 +284,10 @@ export const Notifier = { return SettingsStore.getValue("audioNotificationsEnabled"); }, - setToolbarHidden: function(hidden: boolean, persistent = true) { + setPromptHidden: function(hidden: boolean, persistent = true) { this.toolbarHidden = hidden; - Analytics.trackEvent('Notifier', 'Set Toolbar Hidden', hidden); + Analytics.trackEvent('Notifier', 'Set Toolbar Hidden', String(hidden)); hideNotificationsToast(); @@ -296,17 +297,17 @@ export const Notifier = { } }, - shouldShowToolbar: function() { + shouldShowPrompt: function() { const client = MatrixClientPeg.get(); if (!client) { return false; } const isGuest = client.isGuest(); - return !isGuest && this.supportsDesktopNotifications() && - !this.isEnabled() && !this._isToolbarHidden(); + return !isGuest && this.supportsDesktopNotifications() && !isPushNotifyDisabled() && + !this.isEnabled() && !this._isPromptHidden(); }, - _isToolbarHidden: function() { + _isPromptHidden: function() { // Check localStorage for any such meta data if (global.localStorage) { return global.localStorage.getItem("notifications_hidden") === "true"; diff --git a/src/Presence.js b/src/Presence.ts similarity index 65% rename from src/Presence.js rename to src/Presence.ts index 42bca35f96..660bb0ac94 100644 --- a/src/Presence.js +++ b/src/Presence.ts @@ -19,30 +19,34 @@ limitations under the License. import {MatrixClientPeg} from "./MatrixClientPeg"; import dis from "./dispatcher/dispatcher"; import Timer from './utils/Timer'; +import {ActionPayload} from "./dispatcher/payloads"; - // Time in ms after that a user is considered as unavailable/away +// Time in ms after that a user is considered as unavailable/away const UNAVAILABLE_TIME_MS = 3 * 60 * 1000; // 3 mins -const PRESENCE_STATES = ["online", "offline", "unavailable"]; + +enum State { + Online = "online", + Offline = "offline", + Unavailable = "unavailable", +} class Presence { - constructor() { - this._activitySignal = null; - this._unavailableTimer = null; - this._onAction = this._onAction.bind(this); - this._dispatcherRef = null; - } + private unavailableTimer: Timer = null; + private dispatcherRef: string = null; + private state: State = null; + /** * Start listening the user activity to evaluate his presence state. * Any state change will be sent to the homeserver. */ - async start() { - this._unavailableTimer = new Timer(UNAVAILABLE_TIME_MS); + public async start() { + this.unavailableTimer = new Timer(UNAVAILABLE_TIME_MS); // the user_activity_start action starts the timer - this._dispatcherRef = dis.register(this._onAction); - while (this._unavailableTimer) { + this.dispatcherRef = dis.register(this.onAction); + while (this.unavailableTimer) { try { - await this._unavailableTimer.finished(); - this.setState("unavailable"); + await this.unavailableTimer.finished(); + this.setState(State.Unavailable); } catch (e) { /* aborted, stop got called */ } } } @@ -50,14 +54,14 @@ class Presence { /** * Stop tracking user activity */ - stop() { - if (this._dispatcherRef) { - dis.unregister(this._dispatcherRef); - this._dispatcherRef = null; + public stop() { + if (this.dispatcherRef) { + dis.unregister(this.dispatcherRef); + this.dispatcherRef = null; } - if (this._unavailableTimer) { - this._unavailableTimer.abort(); - this._unavailableTimer = null; + if (this.unavailableTimer) { + this.unavailableTimer.abort(); + this.unavailableTimer = null; } } @@ -65,14 +69,14 @@ class Presence { * Get the current presence state. * @returns {string} the presence state (see PRESENCE enum) */ - getState() { + public getState() { return this.state; } - _onAction(payload) { + private onAction = (payload: ActionPayload) => { if (payload.action === 'user_activity') { - this.setState("online"); - this._unavailableTimer.restart(); + this.setState(State.Online); + this.unavailableTimer.restart(); } } @@ -81,13 +85,11 @@ class Presence { * If the state has changed, the homeserver will be notified. * @param {string} newState the new presence state (see PRESENCE enum) */ - async setState(newState) { + private async setState(newState: State) { if (newState === this.state) { return; } - if (PRESENCE_STATES.indexOf(newState) === -1) { - throw new Error("Bad presence state: " + newState); - } + const oldState = this.state; this.state = newState; diff --git a/src/Registration.js b/src/Registration.js index 9c0264c067..0df2ec3eb3 100644 --- a/src/Registration.js +++ b/src/Registration.js @@ -24,7 +24,6 @@ import dis from './dispatcher/dispatcher'; import * as sdk from './index'; import Modal from './Modal'; import { _t } from './languageHandler'; -// import {MatrixClientPeg} from './MatrixClientPeg'; // Regex for what a "safe" or "Matrix-looking" localpart would be. // TODO: Update as needed for https://github.com/matrix-org/matrix-doc/issues/1514 @@ -44,70 +43,27 @@ export const SAFE_LOCALPART_REGEX = /^[a-z0-9=_\-./]+$/; */ export async function startAnyRegistrationFlow(options) { if (options === undefined) options = {}; - // look for an ILAG compatible flow. We define this as one - // which has only dummy or recaptcha flows. In practice it - // would support any stage InteractiveAuth supports, just not - // ones like email & msisdn which require the user to supply - // the relevant details in advance. We err on the side of - // caution though. - - // XXX: ILAG is disabled for now, - // see https://github.com/vector-im/element-web/issues/8222 - - // const flows = await _getRegistrationFlows(); - // const hasIlagFlow = flows.some((flow) => { - // return flow.stages.every((stage) => { - // return ['m.login.dummy', 'm.login.recaptcha', 'm.login.terms'].includes(stage); - // }); - // }); - - // if (hasIlagFlow) { - // dis.dispatch({ - // action: 'view_set_mxid', - // go_home_on_cancel: options.go_home_on_cancel, - // }); - //} else { - const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); - const modal = Modal.createTrackedDialog('Registration required', '', QuestionDialog, { - hasCancelButton: true, - quitOnly: true, - title: _t("Sign In or Create Account"), - description: _t("Use your account or create a new one to continue."), - button: _t("Create Account"), - extraButtons: [ - , - ], - onFinished: (proceed) => { - if (proceed) { - dis.dispatch({action: 'start_registration', screenAfterLogin: options.screen_after}); - } else if (options.go_home_on_cancel) { - dis.dispatch({action: 'view_home_page'}); - } else if (options.go_welcome_on_cancel) { - dis.dispatch({action: 'view_welcome_page'}); - } - }, - }); - //} + const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); + const modal = Modal.createTrackedDialog('Registration required', '', QuestionDialog, { + hasCancelButton: true, + quitOnly: true, + title: _t("Sign In or Create Account"), + description: _t("Use your account or create a new one to continue."), + button: _t("Create Account"), + extraButtons: [ + , + ], + onFinished: (proceed) => { + if (proceed) { + dis.dispatch({action: 'start_registration', screenAfterLogin: options.screen_after}); + } else if (options.go_home_on_cancel) { + dis.dispatch({action: 'view_home_page'}); + } else if (options.go_welcome_on_cancel) { + dis.dispatch({action: 'view_welcome_page'}); + } + }, + }); } - -// async function _getRegistrationFlows() { -// try { -// await MatrixClientPeg.get().register( -// null, -// null, -// undefined, -// {}, -// {}, -// ); -// console.log("Register request succeeded when it should have returned 401!"); -// } catch (e) { -// if (e.httpStatus === 401) { -// return e.data.flows; -// } -// throw e; -// } -// throw new Error("Register request succeeded when it should have returned 401!"); -// } diff --git a/src/Roles.js b/src/Roles.ts similarity index 87% rename from src/Roles.js rename to src/Roles.ts index 7cc3c880d7..b4be97fdce 100644 --- a/src/Roles.js +++ b/src/Roles.ts @@ -13,9 +13,10 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ + import { _t } from './languageHandler'; -export function levelRoleMap(usersDefault) { +export function levelRoleMap(usersDefault: number) { return { undefined: _t('Default'), 0: _t('Restricted'), @@ -25,7 +26,7 @@ export function levelRoleMap(usersDefault) { }; } -export function textualPowerLevel(level, usersDefault) { +export function textualPowerLevel(level: number, usersDefault: number): string { const LEVEL_ROLE_MAP = levelRoleMap(usersDefault); if (LEVEL_ROLE_MAP[level]) { return LEVEL_ROLE_MAP[level]; diff --git a/src/RoomInvite.js b/src/RoomInvite.js index 839d677069..7eb7f5dbb2 100644 --- a/src/RoomInvite.js +++ b/src/RoomInvite.js @@ -23,6 +23,8 @@ import Modal from './Modal'; import * as sdk from './'; import { _t } from './languageHandler'; import {KIND_DM, KIND_INVITE} from "./components/views/dialogs/InviteDialog"; +import CommunityPrototypeInviteDialog from "./components/views/dialogs/CommunityPrototypeInviteDialog"; +import {CommunityPrototypeStore} from "./stores/CommunityPrototypeStore"; /** * Invites multiple addresses to a room @@ -56,6 +58,23 @@ export function showRoomInviteDialog(roomId) { ); } +export function showCommunityRoomInviteDialog(roomId, communityName) { + Modal.createTrackedDialog( + 'Invite Users to Community', '', CommunityPrototypeInviteDialog, {communityName, roomId}, + /*className=*/null, /*isPriority=*/false, /*isStatic=*/true, + ); +} + +export function showCommunityInviteDialog(communityId) { + const chat = CommunityPrototypeStore.instance.getGeneralChat(communityId); + if (chat) { + const name = CommunityPrototypeStore.instance.getCommunityName(communityId); + showCommunityRoomInviteDialog(chat.roomId, name); + } else { + throw new Error("Failed to locate appropriate room to start an invite in"); + } +} + /** * Checks if the given MatrixEvent is a valid 3rd party user invite. * @param {MatrixEvent} event The event to check @@ -77,7 +96,7 @@ export function isValid3pidInvite(event) { export function inviteUsersToRoom(roomId, userIds) { return inviteMultipleToRoom(roomId, userIds).then((result) => { const room = MatrixClientPeg.get().getRoom(roomId); - return _showAnyInviteErrors(result.states, room, result.inviter); + showAnyInviteErrors(result.states, room, result.inviter); }).catch((err) => { console.error(err.stack); const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); @@ -88,7 +107,7 @@ export function inviteUsersToRoom(roomId, userIds) { }); } -function _showAnyInviteErrors(addrs, room, inviter) { +export function showAnyInviteErrors(addrs, room, inviter) { // Show user any errors const failedUsers = Object.keys(addrs).filter(a => addrs[a] === 'error'); if (failedUsers.length === 1 && inviter.fatal) { @@ -100,6 +119,7 @@ function _showAnyInviteErrors(addrs, room, inviter) { title: _t("Failed to invite users to the room:", {roomName: room.name}), description: inviter.getErrorText(failedUsers[0]), }); + return false; } else { const errorList = []; for (const addr of failedUsers) { @@ -118,8 +138,9 @@ function _showAnyInviteErrors(addrs, room, inviter) { title: _t("Failed to invite the following users to the %(roomName)s room:", {roomName: room.name}), description, }); + return false; } } - return addrs; + return true; } diff --git a/src/Rooms.js b/src/Rooms.js index 218e970f35..3da2b9bc14 100644 --- a/src/Rooms.js +++ b/src/Rooms.js @@ -26,58 +26,6 @@ export function getDisplayAliasForRoom(room) { return room.getCanonicalAlias() || room.getAltAliases()[0]; } -/** - * If the room contains only two members including the logged-in user, - * return the other one. Otherwise, return null. - */ -export function getOnlyOtherMember(room, myUserId) { - if (room.currentState.getJoinedMemberCount() === 2) { - return room.getJoinedMembers().filter(function(m) { - return m.userId !== myUserId; - })[0]; - } - - return null; -} - -function _isConfCallRoom(room, myUserId, conferenceHandler) { - if (!conferenceHandler) return false; - - const myMembership = room.getMyMembership(); - if (myMembership != "join") { - return false; - } - - const otherMember = getOnlyOtherMember(room, myUserId); - if (!otherMember) { - return false; - } - - if (conferenceHandler.isConferenceUser(otherMember.userId)) { - return true; - } - - return false; -} - -// Cache whether a room is a conference call. Assumes that rooms will always -// either will or will not be a conference call room. -const isConfCallRoomCache = { - // $roomId: bool -}; - -export function isConfCallRoom(room, myUserId, conferenceHandler) { - if (isConfCallRoomCache[room.roomId] !== undefined) { - return isConfCallRoomCache[room.roomId]; - } - - const result = _isConfCallRoom(room, myUserId, conferenceHandler); - - isConfCallRoomCache[room.roomId] = result; - - return result; -} - export function looksLikeDirectMessageRoom(room, myUserId) { const myMembership = room.getMyMembership(); const me = room.getMember(myUserId); diff --git a/src/SdkConfig.ts b/src/SdkConfig.ts index b914aaaf6d..7d7caa2d24 100644 --- a/src/SdkConfig.ts +++ b/src/SdkConfig.ts @@ -33,6 +33,11 @@ export const DEFAULTS: ConfigOptions = { // Default conference domain preferredDomain: "jitsi.riot.im", }, + desktopBuilds: { + available: true, + logo: require("../res/img/element-desktop-logo.svg"), + url: "https://element.io/get-started", + }, }; export default class SdkConfig { diff --git a/src/SecurityManager.ts b/src/SecurityManager.ts new file mode 100644 index 0000000000..220320470a --- /dev/null +++ b/src/SecurityManager.ts @@ -0,0 +1,442 @@ +/* +Copyright 2019, 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { ICryptoCallbacks, IDeviceTrustLevel, ISecretStorageKeyInfo } from 'matrix-js-sdk/src/matrix'; +import { MatrixClient } from 'matrix-js-sdk/src/client'; +import Modal from './Modal'; +import * as sdk from './index'; +import {MatrixClientPeg} from './MatrixClientPeg'; +import { deriveKey } from 'matrix-js-sdk/src/crypto/key_passphrase'; +import { decodeRecoveryKey } from 'matrix-js-sdk/src/crypto/recoverykey'; +import { _t } from './languageHandler'; +import { encodeBase64 } from "matrix-js-sdk/src/crypto/olmlib"; +import { isSecureBackupRequired } from './utils/WellKnownUtils'; +import AccessSecretStorageDialog from './components/views/dialogs/security/AccessSecretStorageDialog'; +import RestoreKeyBackupDialog from './components/views/dialogs/security/RestoreKeyBackupDialog'; +import SettingsStore from "./settings/SettingsStore"; +import SecurityCustomisations from "./customisations/Security"; + +// This stores the secret storage private keys in memory for the JS SDK. This is +// only meant to act as a cache to avoid prompting the user multiple times +// during the same single operation. Use `accessSecretStorage` below to scope a +// single secret storage operation, as it will clear the cached keys once the +// operation ends. +let secretStorageKeys: Record = {}; +let secretStorageKeyInfo: Record = {}; +let secretStorageBeingAccessed = false; + +let nonInteractive = false; + +let dehydrationCache: { + key?: Uint8Array, + keyInfo?: ISecretStorageKeyInfo, +} = {}; + +function isCachingAllowed(): boolean { + return secretStorageBeingAccessed; +} + +/** + * This can be used by other components to check if secret storage access is in + * progress, so that we can e.g. avoid intermittently showing toasts during + * secret storage setup. + * + * @returns {bool} + */ +export function isSecretStorageBeingAccessed(): boolean { + return secretStorageBeingAccessed; +} + +export class AccessCancelledError extends Error { + constructor() { + super("Secret storage access canceled"); + } +} + +async function confirmToDismiss(): Promise { + const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); + const [sure] = await Modal.createDialog(QuestionDialog, { + title: _t("Cancel entering passphrase?"), + description: _t("Are you sure you want to cancel entering passphrase?"), + danger: false, + button: _t("Go Back"), + cancelButton: _t("Cancel"), + }).finished; + return !sure; +} + +function makeInputToKey( + keyInfo: ISecretStorageKeyInfo, +): (keyParams: { passphrase: string, recoveryKey: string }) => Promise { + return async ({ passphrase, recoveryKey }) => { + if (passphrase) { + return deriveKey( + passphrase, + keyInfo.passphrase.salt, + keyInfo.passphrase.iterations, + ); + } else { + return decodeRecoveryKey(recoveryKey); + } + }; +} + +async function getSecretStorageKey( + { keys: keyInfos }: { keys: Record }, + ssssItemName, +): Promise<[string, Uint8Array]> { + const keyInfoEntries = Object.entries(keyInfos); + if (keyInfoEntries.length > 1) { + throw new Error("Multiple storage key requests not implemented"); + } + const [keyId, keyInfo] = keyInfoEntries[0]; + + // Check the in-memory cache + if (isCachingAllowed() && secretStorageKeys[keyId]) { + return [keyId, secretStorageKeys[keyId]]; + } + + if (dehydrationCache.key) { + if (await MatrixClientPeg.get().checkSecretStorageKey(dehydrationCache.key, keyInfo)) { + cacheSecretStorageKey(keyId, keyInfo, dehydrationCache.key); + return [keyId, dehydrationCache.key]; + } + } + + const keyFromCustomisations = SecurityCustomisations.getSecretStorageKey?.(); + if (keyFromCustomisations) { + console.log("Using key from security customisations (secret storage)") + cacheSecretStorageKey(keyId, keyInfo, keyFromCustomisations); + return [keyId, keyFromCustomisations]; + } + + if (nonInteractive) { + throw new Error("Could not unlock non-interactively"); + } + + const inputToKey = makeInputToKey(keyInfo); + const { finished } = Modal.createTrackedDialog("Access Secret Storage dialog", "", + AccessSecretStorageDialog, + /* props= */ + { + keyInfo, + checkPrivateKey: async (input) => { + const key = await inputToKey(input); + return await MatrixClientPeg.get().checkSecretStorageKey(key, keyInfo); + }, + }, + /* className= */ null, + /* isPriorityModal= */ false, + /* isStaticModal= */ false, + /* options= */ { + onBeforeClose: async (reason) => { + if (reason === "backgroundClick") { + return confirmToDismiss(); + } + return true; + }, + }, + ); + const [input] = await finished; + if (!input) { + throw new AccessCancelledError(); + } + const key = await inputToKey(input); + + // Save to cache to avoid future prompts in the current session + cacheSecretStorageKey(keyId, keyInfo, key); + + return [keyId, key]; +} + +export async function getDehydrationKey( + keyInfo: ISecretStorageKeyInfo, + checkFunc: (Uint8Array) => void, +): Promise { + const keyFromCustomisations = SecurityCustomisations.getSecretStorageKey?.(); + if (keyFromCustomisations) { + console.log("Using key from security customisations (dehydration)") + return keyFromCustomisations; + } + + const inputToKey = makeInputToKey(keyInfo); + const { finished } = Modal.createTrackedDialog("Access Secret Storage dialog", "", + AccessSecretStorageDialog, + /* props= */ + { + keyInfo, + checkPrivateKey: async (input) => { + const key = await inputToKey(input); + try { + checkFunc(key); + return true; + } catch (e) { + return false; + } + }, + }, + /* className= */ null, + /* isPriorityModal= */ false, + /* isStaticModal= */ false, + /* options= */ { + onBeforeClose: async (reason) => { + if (reason === "backgroundClick") { + return confirmToDismiss(); + } + return true; + }, + }, + ); + const [input] = await finished; + if (!input) { + throw new AccessCancelledError(); + } + const key = await inputToKey(input); + + // need to copy the key because rehydration (unpickling) will clobber it + dehydrationCache = {key: new Uint8Array(key), keyInfo}; + + return key; +} + +function cacheSecretStorageKey( + keyId: string, + keyInfo: ISecretStorageKeyInfo, + key: Uint8Array, +): void { + if (isCachingAllowed()) { + secretStorageKeys[keyId] = key; + secretStorageKeyInfo[keyId] = keyInfo; + } +} + +async function onSecretRequested( + userId: string, + deviceId: string, + requestId: string, + name: string, + deviceTrust: IDeviceTrustLevel, +): Promise { + console.log("onSecretRequested", userId, deviceId, requestId, name, deviceTrust); + const client = MatrixClientPeg.get(); + if (userId !== client.getUserId()) { + return; + } + if (!deviceTrust || !deviceTrust.isVerified()) { + console.log(`Ignoring secret request from untrusted device ${deviceId}`); + return; + } + if ( + name === "m.cross_signing.master" || + name === "m.cross_signing.self_signing" || + name === "m.cross_signing.user_signing" + ) { + const callbacks = client.getCrossSigningCacheCallbacks(); + if (!callbacks.getCrossSigningKeyCache) return; + const keyId = name.replace("m.cross_signing.", ""); + const key = await callbacks.getCrossSigningKeyCache(keyId); + if (!key) { + console.log( + `${keyId} requested by ${deviceId}, but not found in cache`, + ); + } + return key && encodeBase64(key); + } else if (name === "m.megolm_backup.v1") { + const key = await client._crypto.getSessionBackupPrivateKey(); + if (!key) { + console.log( + `session backup key requested by ${deviceId}, but not found in cache`, + ); + } + return key && encodeBase64(key); + } + console.warn("onSecretRequested didn't recognise the secret named ", name); +} + +export const crossSigningCallbacks: ICryptoCallbacks = { + getSecretStorageKey, + cacheSecretStorageKey, + onSecretRequested, + getDehydrationKey, +}; + +export async function promptForBackupPassphrase(): Promise { + let key; + + const { finished } = Modal.createTrackedDialog('Restore Backup', '', RestoreKeyBackupDialog, { + showSummary: false, keyCallback: k => key = k, + }, null, /* priority = */ false, /* static = */ true); + + const success = await finished; + if (!success) throw new Error("Key backup prompt cancelled"); + + return key; +} + +/** + * This helper should be used whenever you need to access secret storage. It + * ensures that secret storage (and also cross-signing since they each depend on + * each other in a cycle of sorts) have been bootstrapped before running the + * provided function. + * + * Bootstrapping secret storage may take one of these paths: + * 1. Create secret storage from a passphrase and store cross-signing keys + * in secret storage. + * 2. Access existing secret storage by requesting passphrase and accessing + * cross-signing keys as needed. + * 3. All keys are loaded and there's nothing to do. + * + * Additionally, the secret storage keys are cached during the scope of this function + * to ensure the user is prompted only once for their secret storage + * passphrase. The cache is then cleared once the provided function completes. + * + * @param {Function} [func] An operation to perform once secret storage has been + * bootstrapped. Optional. + * @param {bool} [forceReset] Reset secret storage even if it's already set up + */ +export async function accessSecretStorage(func = async () => { }, forceReset = false) { + const cli = MatrixClientPeg.get(); + secretStorageBeingAccessed = true; + try { + if (!await cli.hasSecretStorageKey() || forceReset) { + // This dialog calls bootstrap itself after guiding the user through + // passphrase creation. + const { finished } = Modal.createTrackedDialogAsync('Create Secret Storage dialog', '', + import("./async-components/views/dialogs/security/CreateSecretStorageDialog"), + { + forceReset, + }, + null, + /* priority = */ false, + /* static = */ true, + /* options = */ { + onBeforeClose: async (reason) => { + // If Secure Backup is required, you cannot leave the modal. + if (reason === "backgroundClick") { + return !isSecureBackupRequired(); + } + return true; + }, + }, + ); + const [confirmed] = await finished; + if (!confirmed) { + throw new Error("Secret storage creation canceled"); + } + } else { + const InteractiveAuthDialog = sdk.getComponent("dialogs.InteractiveAuthDialog"); + await cli.bootstrapCrossSigning({ + authUploadDeviceSigningKeys: async (makeRequest) => { + 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"); + } + }, + }); + await cli.bootstrapSecretStorage({ + getKeyBackupPassphrase: promptForBackupPassphrase, + }); + + const keyId = Object.keys(secretStorageKeys)[0]; + if (keyId && SettingsStore.getValue("feature_dehydration")) { + let dehydrationKeyInfo = {}; + if (secretStorageKeyInfo[keyId] && secretStorageKeyInfo[keyId].passphrase) { + dehydrationKeyInfo = { passphrase: secretStorageKeyInfo[keyId].passphrase }; + } + console.log("Setting dehydration key"); + await cli.setDehydrationKey(secretStorageKeys[keyId], dehydrationKeyInfo, "Backup device"); + } else if (!keyId) { + console.warn("Not setting dehydration key: no SSSS key found"); + } else { + console.log("Not setting dehydration key: feature disabled"); + } + } + + // `return await` needed here to ensure `finally` block runs after the + // inner operation completes. + return await func(); + } catch (e) { + SecurityCustomisations.catchAccessSecretStorageError?.(e); + console.error(e); + } finally { + // Clear secret storage key cache now that work is complete + secretStorageBeingAccessed = false; + if (!isCachingAllowed()) { + secretStorageKeys = {}; + secretStorageKeyInfo = {}; + } + } +} + +// FIXME: this function name is a bit of a mouthful +export async function tryToUnlockSecretStorageWithDehydrationKey( + client: MatrixClient, +): Promise { + const key = dehydrationCache.key; + let restoringBackup = false; + if (key && await client.isSecretStorageReady()) { + console.log("Trying to set up cross-signing using dehydration key"); + secretStorageBeingAccessed = true; + nonInteractive = true; + try { + await client.checkOwnCrossSigningTrust(); + + // we also need to set a new dehydrated device to replace the + // device we rehydrated + let dehydrationKeyInfo = {}; + if (dehydrationCache.keyInfo && dehydrationCache.keyInfo.passphrase) { + dehydrationKeyInfo = { passphrase: dehydrationCache.keyInfo.passphrase }; + } + await client.setDehydrationKey(key, dehydrationKeyInfo, "Backup device"); + + // and restore from backup + const backupInfo = await client.getKeyBackupVersion(); + if (backupInfo) { + restoringBackup = true; + // don't await, because this can take a long time + client.restoreKeyBackupWithSecretStorage(backupInfo) + .finally(() => { + secretStorageBeingAccessed = false; + nonInteractive = false; + if (!isCachingAllowed()) { + secretStorageKeys = {}; + secretStorageKeyInfo = {}; + } + }); + } + } finally { + dehydrationCache = {}; + // the secret storage cache is needed for restoring from backup, so + // don't clear it yet if we're restoring from backup + if (!restoringBackup) { + secretStorageBeingAccessed = false; + nonInteractive = false; + if (!isCachingAllowed()) { + secretStorageKeys = {}; + secretStorageKeyInfo = {}; + } + } + } + } +} diff --git a/src/SendHistoryManager.js b/src/SendHistoryManager.ts similarity index 57% rename from src/SendHistoryManager.js rename to src/SendHistoryManager.ts index 794a58ad6f..e9268ad642 100644 --- a/src/SendHistoryManager.js +++ b/src/SendHistoryManager.ts @@ -15,13 +15,22 @@ See the License for the specific language governing permissions and limitations under the License. */ -import _clamp from 'lodash/clamp'; +import {clamp} from "lodash"; +import {MatrixEvent} from "matrix-js-sdk/src/models/event"; + +import {SerializedPart} from "./editor/parts"; +import EditorModel from "./editor/model"; + +interface IHistoryItem { + parts: SerializedPart[]; + replyEventId?: string; +} export default class SendHistoryManager { - history: Array = []; + history: Array = []; prefix: string; - lastIndex: number = 0; // used for indexing the storage - currentIndex: number = 0; // used for indexing the loaded validated history Array + lastIndex = 0; // used for indexing the storage + currentIndex = 0; // used for indexing the loaded validated history Array constructor(roomId: string, prefix: string) { this.prefix = prefix + roomId; @@ -32,8 +41,7 @@ export default class SendHistoryManager { while (itemJSON = sessionStorage.getItem(`${this.prefix}[${index}]`)) { try { - const serializedParts = JSON.parse(itemJSON); - this.history.push(serializedParts); + this.history.push(JSON.parse(itemJSON)); } catch (e) { console.warn("Throwing away unserialisable history", e); break; @@ -45,16 +53,23 @@ export default class SendHistoryManager { this.currentIndex = this.lastIndex + 1; } - save(editorModel: Object) { - const serializedParts = editorModel.serializeParts(); - this.history.push(serializedParts); - this.currentIndex = this.history.length; - this.lastIndex += 1; - sessionStorage.setItem(`${this.prefix}[${this.lastIndex}]`, JSON.stringify(serializedParts)); + static createItem(model: EditorModel, replyEvent?: MatrixEvent): IHistoryItem { + return { + parts: model.serializeParts(), + replyEventId: replyEvent ? replyEvent.getId() : undefined, + }; } - getItem(offset: number): ?HistoryItem { - this.currentIndex = _clamp(this.currentIndex + offset, 0, this.history.length - 1); + save(editorModel: EditorModel, replyEvent?: MatrixEvent) { + const item = SendHistoryManager.createItem(editorModel, replyEvent); + this.history.push(item); + this.currentIndex = this.history.length; + this.lastIndex += 1; + sessionStorage.setItem(`${this.prefix}[${this.lastIndex}]`, JSON.stringify(item)); + } + + getItem(offset: number): IHistoryItem { + this.currentIndex = clamp(this.currentIndex + offset, 0, this.history.length - 1); return this.history[this.currentIndex]; } } diff --git a/src/SlashCommands.tsx b/src/SlashCommands.tsx index 28eaa8123b..87dc1ccdfd 100644 --- a/src/SlashCommands.tsx +++ b/src/SlashCommands.tsx @@ -38,13 +38,14 @@ import {inviteUsersToRoom} from "./RoomInvite"; import { WidgetType } from "./widgets/WidgetType"; import { Jitsi } from "./widgets/Jitsi"; import { parseFragment as parseHtml } from "parse5"; -import sendBugReport from "./rageshake/submit-rageshake"; -import SdkConfig from "./SdkConfig"; +import BugReportDialog from "./components/views/dialogs/BugReportDialog"; import { ensureDMExists } from "./createRoom"; import { ViewUserPayload } from "./dispatcher/payloads/ViewUserPayload"; import { Action } from "./dispatcher/actions"; import { EffectiveMembership, getEffectiveMembership, leaveRoomBehaviour } from "./utils/membership"; +import SdkConfig from "./SdkConfig"; import SettingsStore from "./settings/SettingsStore"; +import {UIFeature} from "./settings/UIFeature"; // XXX: workaround for https://github.com/microsoft/TypeScript/issues/31816 interface HTMLInputEvent extends Event { @@ -89,6 +90,7 @@ interface ICommandOpts { runFn?: RunFn; category: string; hideCompletionAfterSpace?: boolean; + isEnabled?(): boolean; } export class Command { @@ -99,6 +101,7 @@ export class Command { runFn: undefined | RunFn; category: string; hideCompletionAfterSpace: boolean; + _isEnabled?: () => boolean; constructor(opts: ICommandOpts) { this.command = opts.command; @@ -108,6 +111,7 @@ export class Command { this.runFn = opts.runFn; this.category = opts.category || CommandCategories.other; this.hideCompletionAfterSpace = opts.hideCompletionAfterSpace || false; + this._isEnabled = opts.isEnabled; } getCommand() { @@ -127,6 +131,10 @@ export class Command { getUsage() { return _t('Usage') + ': ' + this.getCommandWithArgs(); } + + isEnabled() { + return this._isEnabled ? this._isEnabled() : true; + } } function reject(error) { @@ -155,6 +163,19 @@ export const Commands = [ }, category: CommandCategories.messages, }), + new Command({ + command: 'lenny', + args: '', + description: _td('Prepends ( ͡° ͜ʖ ͡°) to a plain-text message'), + runFn: function(roomId, args) { + let message = '( ͡° ͜ʖ ͡°)'; + if (args) { + message = message + ' ' + args; + } + return success(MatrixClientPeg.get().sendTextMessage(roomId, message)); + }, + category: CommandCategories.messages, + }), new Command({ command: 'plain', args: '', @@ -778,6 +799,7 @@ export const Commands = [ command: 'addwidget', args: '', description: _td('Adds a custom widget by URL to the room'), + isEnabled: () => SettingsStore.getValue(UIFeature.Widgets), runFn: function(roomId, widgetUrl) { if (!widgetUrl) { return reject(_t("Please supply a widget URL or embed code")); @@ -861,12 +883,12 @@ export const Commands = [ _t('WARNING: KEY VERIFICATION FAILED! The signing key for %(userId)s and session' + ' %(deviceId)s is "%(fprint)s" which does not match the provided key ' + '"%(fingerprint)s". This could mean your communications are being intercepted!', - { - fprint, - userId, - deviceId, - fingerprint, - })); + { + fprint, + userId, + deviceId, + fingerprint, + })); } await cli.setDeviceVerified(userId, deviceId, true); @@ -880,7 +902,7 @@ export const Commands = [ { _t('The signing key you provided matches the signing key you received ' + 'from %(userId)s\'s session %(deviceId)s. Session marked as verified.', - {userId, deviceId}) + {userId, deviceId}) }

, @@ -960,19 +982,13 @@ export const Commands = [ command: "rageshake", aliases: ["bugreport"], description: _td("Send a bug report with logs"), + isEnabled: () => !!SdkConfig.get().bug_report_endpoint_url, args: "", runFn: function(roomId, args) { return success( - sendBugReport(SdkConfig.get().bug_report_endpoint_url, { - userText: args, - sendLogs: true, - }).then(() => { - const InfoDialog = sdk.getComponent('dialogs.InfoDialog'); - Modal.createTrackedDialog('Slash Commands', 'Rageshake sent', InfoDialog, { - title: _t('Logs sent'), - description: _t('Thank you!'), - }); - }), + Modal.createTrackedDialog('Slash Commands', 'Bug Report Dialog', BugReportDialog, { + initialText: args, + }).finished, ); }, category: CommandCategories.advanced, @@ -1064,7 +1080,7 @@ Commands.forEach(cmd => { }); }); -export function parseCommandString(input) { +export function parseCommandString(input: string) { // trim any trailing whitespace, as it can confuse the parser for // IRC-style commands input = input.replace(/\s+$/, ''); @@ -1091,10 +1107,10 @@ export function parseCommandString(input) { * 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, input) { +export function getCommand(roomId: string, input: string) { const {cmd, args} = parseCommandString(input); - if (CommandMap.has(cmd)) { + if (CommandMap.has(cmd) && CommandMap.get(cmd).isEnabled()) { return () => CommandMap.get(cmd).run(roomId, args, cmd); } } diff --git a/src/TextForEvent.js b/src/TextForEvent.js index c55380bd9b..d86d88a697 100644 --- a/src/TextForEvent.js +++ b/src/TextForEvent.js @@ -14,7 +14,6 @@ See the License for the specific language governing permissions and limitations under the License. */ import {MatrixClientPeg} from './MatrixClientPeg'; -import CallHandler from './CallHandler'; import { _t } from './languageHandler'; import * as Roles from './Roles'; import {isValid3pidInvite} from "./RoomInvite"; @@ -28,7 +27,6 @@ function textForMemberEvent(ev) { const prevContent = ev.getPrevContent(); const content = ev.getContent(); - const ConferenceHandler = CallHandler.getConferenceHandler(); const reason = content.reason ? (_t('Reason') + ': ' + content.reason) : ''; switch (content.membership) { case 'invite': { @@ -43,11 +41,7 @@ function textForMemberEvent(ev) { return _t('%(targetName)s accepted an invitation.', {targetName}); } } else { - if (ConferenceHandler && ConferenceHandler.isConferenceUser(ev.getStateKey())) { - return _t('%(senderName)s requested a VoIP conference.', {senderName}); - } else { - return _t('%(senderName)s invited %(targetName)s.', {senderName, targetName}); - } + return _t('%(senderName)s invited %(targetName)s.', {senderName, targetName}); } } case 'ban': @@ -84,17 +78,11 @@ function textForMemberEvent(ev) { } } else { if (!ev.target) console.warn("Join message has no target! -- " + ev.getContent().state_key); - if (ConferenceHandler && ConferenceHandler.isConferenceUser(ev.getStateKey())) { - return _t('VoIP conference started.'); - } else { - return _t('%(targetName)s joined the room.', {targetName}); - } + return _t('%(targetName)s joined the room.', {targetName}); } case 'leave': if (ev.getSender() === ev.getStateKey()) { - if (ConferenceHandler && ConferenceHandler.isConferenceUser(ev.getStateKey())) { - return _t('VoIP conference finished.'); - } else if (prevContent.membership === "invite") { + if (prevContent.membership === "invite") { return _t('%(targetName)s rejected the invitation.', {targetName}); } else { return _t('%(targetName)s left the room.', {targetName}); @@ -210,59 +198,30 @@ function textForRelatedGroupsEvent(ev) { function textForServerACLEvent(ev) { const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); const prevContent = ev.getPrevContent(); - const changes = []; const current = ev.getContent(); const prev = { deny: Array.isArray(prevContent.deny) ? prevContent.deny : [], allow: Array.isArray(prevContent.allow) ? prevContent.allow : [], allow_ip_literals: !(prevContent.allow_ip_literals === false), }; + let text = ""; if (prev.deny.length === 0 && prev.allow.length === 0) { - text = `${senderDisplayName} set server ACLs for this room: `; + text = _t("%(senderDisplayName)s set the server ACLs for this room.", {senderDisplayName}); } else { - text = `${senderDisplayName} changed the server ACLs for this room: `; + text = _t("%(senderDisplayName)s changed the server ACLs for this room.", {senderDisplayName}); } if (!Array.isArray(current.allow)) { current.allow = []; } - /* If we know for sure everyone is banned, don't bother showing the diff view */ + + // If we know for sure everyone is banned, mark the room as obliterated if (current.allow.length === 0) { - return text + "🎉 All servers are banned from participating! This room can no longer be used."; + return text + " " + _t("🎉 All servers are banned from participating! This room can no longer be used."); } - if (!Array.isArray(current.deny)) { - current.deny = []; - } - - const bannedServers = current.deny.filter((srv) => typeof(srv) === 'string' && !prev.deny.includes(srv)); - const unbannedServers = prev.deny.filter((srv) => typeof(srv) === 'string' && !current.deny.includes(srv)); - const allowedServers = current.allow.filter((srv) => typeof(srv) === 'string' && !prev.allow.includes(srv)); - const unallowedServers = prev.allow.filter((srv) => typeof(srv) === 'string' && !current.allow.includes(srv)); - - if (bannedServers.length > 0) { - changes.push(`Servers matching ${bannedServers.join(", ")} are now banned.`); - } - - if (unbannedServers.length > 0) { - changes.push(`Servers matching ${unbannedServers.join(", ")} were removed from the ban list.`); - } - - if (allowedServers.length > 0) { - changes.push(`Servers matching ${allowedServers.join(", ")} are now allowed.`); - } - - if (unallowedServers.length > 0) { - changes.push(`Servers matching ${unallowedServers.join(", ")} were removed from the allowed list.`); - } - - if (prev.allow_ip_literals !== current.allow_ip_literals) { - const allowban = current.allow_ip_literals ? "allowed" : "banned"; - changes.push(`Participating from a server using an IP literal hostname is now ${allowban}.`); - } - - return text + changes.join(" "); + return text; } function textForMessageEvent(ev) { @@ -341,14 +300,27 @@ function textForCallHangupEvent(event) { reason = _t('(not supported by this browser)'); } else if (eventContent.reason) { if (eventContent.reason === "ice_failed") { + // We couldn't establish a connection at all reason = _t('(could not connect media)'); + } else if (eventContent.reason === "ice_timeout") { + // We established a connection but it died + reason = _t('(connection failed)'); + } else if (eventContent.reason === "user_media_failed") { + // The other side couldn't open capture devices + reason = _t("(their device couldn't start the camera / microphone)"); + } else if (eventContent.reason === "unknown_error") { + // An error code the other side doesn't have a way to express + // (as opposed to an error code they gave but we don't know about, + // in which case we show the error code) + reason = _t("(an error occurred)"); } else if (eventContent.reason === "invite_timeout") { reason = _t('(no answer)'); - } else if (eventContent.reason === "user hangup") { + } else if (eventContent.reason === "user hangup" || eventContent.reason === "user_hangup") { // workaround for https://github.com/vector-im/element-web/issues/5178 // it seems Android randomly sets a reason of "user hangup" which is // interpreted as an error code :( // https://github.com/vector-im/riot-android/issues/2623 + // Also the correct hangup code as of VoIP v1 (with underscore) reason = ''; } else { reason = _t('(unknown failure: %(reason)s)', {reason: eventContent.reason}); @@ -357,6 +329,11 @@ function textForCallHangupEvent(event) { return _t('%(senderName)s ended the call.', {senderName}) + ' ' + reason; } +function textForCallRejectEvent(event) { + const senderName = event.sender ? event.sender.name : _t('Someone'); + return _t('%(senderName)s declined the call.', {senderName}); +} + function textForCallInviteEvent(event) { const senderName = event.sender ? event.sender.name : _t('Someone'); // FIXME: Find a better way to determine this from the event? @@ -586,6 +563,7 @@ const handlers = { 'm.call.invite': textForCallInviteEvent, 'm.call.answer': textForCallAnswerEvent, 'm.call.hangup': textForCallHangupEvent, + 'm.call.reject': textForCallRejectEvent, }; const stateHandlers = { diff --git a/src/ToWidgetPostMessageApi.js b/src/ToWidgetPostMessageApi.js deleted file mode 100644 index 00309d252c..0000000000 --- a/src/ToWidgetPostMessageApi.js +++ /dev/null @@ -1,84 +0,0 @@ -/* -Copyright 2018 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. -*/ - -// const OUTBOUND_API_NAME = 'toWidget'; - -// Initiate requests using the "toWidget" postMessage API and handle responses -// NOTE: ToWidgetPostMessageApi only handles message events with a data payload with a -// response field -export default class ToWidgetPostMessageApi { - constructor(timeoutMs) { - this._timeoutMs = timeoutMs || 5000; // default to 5s timer - this._counter = 0; - this._requestMap = { - // $ID: {resolve, reject} - }; - this.start = this.start.bind(this); - this.stop = this.stop.bind(this); - this.onPostMessage = this.onPostMessage.bind(this); - } - - start() { - window.addEventListener('message', this.onPostMessage); - } - - stop() { - window.removeEventListener('message', this.onPostMessage); - } - - onPostMessage(ev) { - // THIS IS ALL UNSAFE EXECUTION. - // We do not verify who the sender of `ev` is! - const payload = ev.data; - // NOTE: Workaround for running in a mobile WebView where a - // postMessage immediately triggers this callback even though it is - // not the response. - if (payload.response === undefined) { - return; - } - const promise = this._requestMap[payload.requestId]; - if (!promise) { - return; - } - delete this._requestMap[payload.requestId]; - promise.resolve(payload); - } - - // Initiate outbound requests (toWidget) - exec(action, targetWindow, targetOrigin) { - targetWindow = targetWindow || window.parent; // default to parent window - targetOrigin = targetOrigin || "*"; - this._counter += 1; - action.requestId = Date.now() + "-" + Math.random().toString(36) + "-" + this._counter; - - return new Promise((resolve, reject) => { - this._requestMap[action.requestId] = {resolve, reject}; - targetWindow.postMessage(action, targetOrigin); - - if (this._timeoutMs > 0) { - setTimeout(() => { - if (!this._requestMap[action.requestId]) { - return; - } - console.error("postMessage request timed out. Sent object: " + JSON.stringify(action), - this._requestMap); - this._requestMap[action.requestId].reject(new Error("Timed out")); - delete this._requestMap[action.requestId]; - }, this._timeoutMs); - } - }); - } -} diff --git a/src/UserActivity.js b/src/UserActivity.ts similarity index 61% rename from src/UserActivity.js rename to src/UserActivity.ts index 0174aebaf5..606075ec7c 100644 --- a/src/UserActivity.js +++ b/src/UserActivity.ts @@ -38,26 +38,23 @@ const RECENTLY_ACTIVE_THRESHOLD_MS = 2 * 60 * 1000; * see doc on the userActive* functions for what these mean. */ export default class UserActivity { - constructor(windowObj, documentObj) { - this._window = windowObj; - this._document = documentObj; + private readonly activeNowTimeout: Timer; + private readonly activeRecentlyTimeout: Timer; + private attachedActiveNowTimers: Timer[] = []; + private attachedActiveRecentlyTimers: Timer[] = []; + private lastScreenX = 0; + private lastScreenY = 0; - this._attachedActiveNowTimers = []; - this._attachedActiveRecentlyTimers = []; - this._activeNowTimeout = new Timer(CURRENTLY_ACTIVE_THRESHOLD_MS); - this._activeRecentlyTimeout = new Timer(RECENTLY_ACTIVE_THRESHOLD_MS); - this._onUserActivity = this._onUserActivity.bind(this); - this._onWindowBlurred = this._onWindowBlurred.bind(this); - this._onPageVisibilityChanged = this._onPageVisibilityChanged.bind(this); - this.lastScreenX = 0; - this.lastScreenY = 0; + constructor(private readonly window: Window, private readonly document: Document) { + this.activeNowTimeout = new Timer(CURRENTLY_ACTIVE_THRESHOLD_MS); + this.activeRecentlyTimeout = new Timer(RECENTLY_ACTIVE_THRESHOLD_MS); } static sharedInstance() { - if (global.mxUserActivity === undefined) { - global.mxUserActivity = new UserActivity(window, document); + if (window.mxUserActivity === undefined) { + window.mxUserActivity = new UserActivity(window, document); } - return global.mxUserActivity; + return window.mxUserActivity; } /** @@ -69,8 +66,8 @@ export default class UserActivity { * later on when the user does become active. * @param {Timer} timer the timer to use */ - timeWhileActiveNow(timer) { - this._timeWhile(timer, this._attachedActiveNowTimers); + public timeWhileActiveNow(timer: Timer) { + this.timeWhile(timer, this.attachedActiveNowTimers); if (this.userActiveNow()) { timer.start(); } @@ -85,14 +82,14 @@ export default class UserActivity { * later on when the user does become active. * @param {Timer} timer the timer to use */ - timeWhileActiveRecently(timer) { - this._timeWhile(timer, this._attachedActiveRecentlyTimers); + public timeWhileActiveRecently(timer: Timer) { + this.timeWhile(timer, this.attachedActiveRecentlyTimers); if (this.userActiveRecently()) { timer.start(); } } - _timeWhile(timer, attachedTimers) { + private timeWhile(timer: Timer, attachedTimers: Timer[]) { // important this happens first const index = attachedTimers.indexOf(timer); if (index === -1) { @@ -112,36 +109,36 @@ export default class UserActivity { /** * Start listening to user activity */ - start() { - this._document.addEventListener('mousedown', this._onUserActivity); - this._document.addEventListener('mousemove', this._onUserActivity); - this._document.addEventListener('keydown', this._onUserActivity); - this._document.addEventListener("visibilitychange", this._onPageVisibilityChanged); - this._window.addEventListener("blur", this._onWindowBlurred); - this._window.addEventListener("focus", this._onUserActivity); + public start() { + this.document.addEventListener('mousedown', this.onUserActivity); + this.document.addEventListener('mousemove', this.onUserActivity); + this.document.addEventListener('keydown', this.onUserActivity); + this.document.addEventListener("visibilitychange", this.onPageVisibilityChanged); + this.window.addEventListener("blur", this.onWindowBlurred); + this.window.addEventListener("focus", this.onUserActivity); // can't use document.scroll here because that's only the document // itself being scrolled. Need to use addEventListener's useCapture. // also this needs to be the wheel event, not scroll, as scroll is // fired when the view scrolls down for a new message. - this._window.addEventListener('wheel', this._onUserActivity, { - passive: true, capture: true, + this.window.addEventListener('wheel', this.onUserActivity, { + passive: true, + capture: true, }); } /** * Stop tracking user activity */ - stop() { - this._document.removeEventListener('mousedown', this._onUserActivity); - this._document.removeEventListener('mousemove', this._onUserActivity); - this._document.removeEventListener('keydown', this._onUserActivity); - this._window.removeEventListener('wheel', this._onUserActivity, { - passive: true, capture: true, + public stop() { + this.document.removeEventListener('mousedown', this.onUserActivity); + this.document.removeEventListener('mousemove', this.onUserActivity); + this.document.removeEventListener('keydown', this.onUserActivity); + this.window.removeEventListener('wheel', this.onUserActivity, { + capture: true, }); - - this._document.removeEventListener("visibilitychange", this._onPageVisibilityChanged); - this._window.removeEventListener("blur", this._onWindowBlurred); - this._window.removeEventListener("focus", this._onUserActivity); + this.document.removeEventListener("visibilitychange", this.onPageVisibilityChanged); + this.window.removeEventListener("blur", this.onWindowBlurred); + this.window.removeEventListener("focus", this.onUserActivity); } /** @@ -151,8 +148,8 @@ export default class UserActivity { * user's attention at any given moment. * @returns {boolean} true if user is currently 'active' */ - userActiveNow() { - return this._activeNowTimeout.isRunning(); + public userActiveNow() { + return this.activeNowTimeout.isRunning(); } /** @@ -163,27 +160,27 @@ export default class UserActivity { * (or they may have gone to make tea and left the window focused). * @returns {boolean} true if user has been active recently */ - userActiveRecently() { - return this._activeRecentlyTimeout.isRunning(); + public userActiveRecently() { + return this.activeRecentlyTimeout.isRunning(); } - _onPageVisibilityChanged(e) { - if (this._document.visibilityState === "hidden") { - this._activeNowTimeout.abort(); - this._activeRecentlyTimeout.abort(); + private onPageVisibilityChanged = e => { + if (this.document.visibilityState === "hidden") { + this.activeNowTimeout.abort(); + this.activeRecentlyTimeout.abort(); } else { - this._onUserActivity(e); + this.onUserActivity(e); } - } + }; - _onWindowBlurred() { - this._activeNowTimeout.abort(); - this._activeRecentlyTimeout.abort(); - } + private onWindowBlurred = () => { + this.activeNowTimeout.abort(); + this.activeRecentlyTimeout.abort(); + }; - _onUserActivity(event) { + private onUserActivity = (event: MouseEvent) => { // ignore anything if the window isn't focused - if (!this._document.hasFocus()) return; + if (!this.document.hasFocus()) return; if (event.screenX && event.type === "mousemove") { if (event.screenX === this.lastScreenX && event.screenY === this.lastScreenY) { @@ -195,25 +192,25 @@ export default class UserActivity { } dis.dispatch({action: 'user_activity'}); - if (!this._activeNowTimeout.isRunning()) { - this._activeNowTimeout.start(); + if (!this.activeNowTimeout.isRunning()) { + this.activeNowTimeout.start(); dis.dispatch({action: 'user_activity_start'}); - this._runTimersUntilTimeout(this._attachedActiveNowTimers, this._activeNowTimeout); + UserActivity.runTimersUntilTimeout(this.attachedActiveNowTimers, this.activeNowTimeout); } else { - this._activeNowTimeout.restart(); + this.activeNowTimeout.restart(); } - if (!this._activeRecentlyTimeout.isRunning()) { - this._activeRecentlyTimeout.start(); + if (!this.activeRecentlyTimeout.isRunning()) { + this.activeRecentlyTimeout.start(); - this._runTimersUntilTimeout(this._attachedActiveRecentlyTimers, this._activeRecentlyTimeout); + UserActivity.runTimersUntilTimeout(this.attachedActiveRecentlyTimers, this.activeRecentlyTimeout); } else { - this._activeRecentlyTimeout.restart(); + this.activeRecentlyTimeout.restart(); } - } + }; - async _runTimersUntilTimeout(attachedTimers, timeout) { + private static async runTimersUntilTimeout(attachedTimers: Timer[], timeout: Timer) { attachedTimers.forEach((t) => t.start()); try { await timeout.finished(); diff --git a/src/VectorConferenceHandler.js b/src/VectorConferenceHandler.js deleted file mode 100644 index c10bc659ae..0000000000 --- a/src/VectorConferenceHandler.js +++ /dev/null @@ -1,135 +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 {createNewMatrixCall as jsCreateNewMatrixCall, Room} from "matrix-js-sdk"; -import CallHandler from './CallHandler'; -import {MatrixClientPeg} from "./MatrixClientPeg"; - -// FIXME: this is Element specific code, but will be removed shortly when we -// switch over to Jitsi entirely for video conferencing. - -// FIXME: This currently forces Element to try to hit the matrix.org AS for -// conferencing. This is bad because it prevents people running their own ASes -// from being used. This isn't permanent and will be customisable in the future: -// see the proposal at docs/conferencing.md for more info. -const USER_PREFIX = "fs_"; -const DOMAIN = "matrix.org"; - -export function ConferenceCall(matrixClient, groupChatRoomId) { - this.client = matrixClient; - this.groupRoomId = groupChatRoomId; - this.confUserId = getConferenceUserIdForRoom(this.groupRoomId); -} - -ConferenceCall.prototype.setup = function() { - const self = this; - return this._joinConferenceUser().then(function() { - return self._getConferenceUserRoom(); - }).then(function(room) { - // return a call for *this* room to be placed. We also tack on - // confUserId to speed up lookups (else we'd need to loop every room - // looking for a 1:1 room with this conf user ID!) - const call = jsCreateNewMatrixCall(self.client, room.roomId); - call.confUserId = self.confUserId; - call.groupRoomId = self.groupRoomId; - return call; - }); -}; - -ConferenceCall.prototype._joinConferenceUser = function() { - // Make sure the conference user is in the group chat room - const groupRoom = this.client.getRoom(this.groupRoomId); - if (!groupRoom) { - return Promise.reject("Bad group room ID"); - } - const member = groupRoom.getMember(this.confUserId); - if (member && member.membership === "join") { - return Promise.resolve(); - } - return this.client.invite(this.groupRoomId, this.confUserId); -}; - -ConferenceCall.prototype._getConferenceUserRoom = function() { - // Use an existing 1:1 with the conference user; else make one - const rooms = this.client.getRooms(); - let confRoom = null; - for (let i = 0; i < rooms.length; i++) { - const confUser = rooms[i].getMember(this.confUserId); - if (confUser && confUser.membership === "join" && - rooms[i].getJoinedMemberCount() === 2) { - confRoom = rooms[i]; - break; - } - } - if (confRoom) { - return Promise.resolve(confRoom); - } - return this.client.createRoom({ - preset: "private_chat", - invite: [this.confUserId], - }).then(function(res) { - return new Room(res.room_id, null, MatrixClientPeg.get().getUserId()); - }); -}; - -/** - * Check if this user ID is in fact a conference bot. - * @param {string} userId The user ID to check. - * @return {boolean} True if it is a conference bot. - */ -export function isConferenceUser(userId) { - if (userId.indexOf("@" + USER_PREFIX) !== 0) { - return false; - } - const base64part = userId.split(":")[0].substring(1 + USER_PREFIX.length); - if (base64part) { - const decoded = new Buffer(base64part, "base64").toString(); - // ! $STUFF : $STUFF - return /^!.+:.+/.test(decoded); - } - return false; -} - -export function getConferenceUserIdForRoom(roomId) { - // abuse browserify's core node Buffer support (strip padding ='s) - const base64RoomId = new Buffer(roomId).toString("base64").replace(/=/g, ""); - return "@" + USER_PREFIX + base64RoomId + ":" + DOMAIN; -} - -export function createNewMatrixCall(client, roomId) { - const confCall = new ConferenceCall( - client, roomId, - ); - return confCall.setup(); -} - -export function getConferenceCallForRoom(roomId) { - // search for a conference 1:1 call for this group chat room ID - const activeCall = CallHandler.getAnyActiveCall(); - if (activeCall && activeCall.confUserId) { - const thisRoomConfUserId = getConferenceUserIdForRoom( - roomId, - ); - if (thisRoomConfUserId === activeCall.confUserId) { - return activeCall; - } - } - return null; -} - -// TODO: Document this. -export const slot = 'conference'; diff --git a/src/WhoIsTyping.js b/src/WhoIsTyping.ts similarity index 72% rename from src/WhoIsTyping.js rename to src/WhoIsTyping.ts index d11cddf487..a8ca425ea8 100644 --- a/src/WhoIsTyping.js +++ b/src/WhoIsTyping.ts @@ -14,19 +14,18 @@ See the License for the specific language governing permissions and limitations under the License. */ +import {Room} from "matrix-js-sdk/src/models/room"; +import {RoomMember} from "matrix-js-sdk/src/models/room-member"; + import {MatrixClientPeg} from "./MatrixClientPeg"; import { _t } from './languageHandler'; -export function usersTypingApartFromMeAndIgnored(room) { - return usersTyping( - room, [MatrixClientPeg.get().credentials.userId].concat(MatrixClientPeg.get().getIgnoredUsers()), - ); +export function usersTypingApartFromMeAndIgnored(room: Room): RoomMember[] { + return usersTyping(room, [MatrixClientPeg.get().getUserId()].concat(MatrixClientPeg.get().getIgnoredUsers())); } -export function usersTypingApartFromMe(room) { - return usersTyping( - room, [MatrixClientPeg.get().credentials.userId], - ); +export function usersTypingApartFromMe(room: Room): RoomMember[] { + return usersTyping(room, [MatrixClientPeg.get().getUserId()]); } /** @@ -34,15 +33,11 @@ export function usersTypingApartFromMe(room) { * to exclude, return a list of user objects who are typing. * @param {Room} room: room object to get users from. * @param {string[]} exclude: list of user mxids to exclude. - * @returns {string[]} list of user objects who are typing. + * @returns {RoomMember[]} list of user objects who are typing. */ -export function usersTyping(room, exclude) { +export function usersTyping(room: Room, exclude: string[] = []): RoomMember[] { const whoIsTyping = []; - if (exclude === undefined) { - exclude = []; - } - const memberKeys = Object.keys(room.currentState.members); for (let i = 0; i < memberKeys.length; ++i) { const userId = memberKeys[i]; @@ -57,20 +52,21 @@ export function usersTyping(room, exclude) { return whoIsTyping; } -export function whoIsTypingString(whoIsTyping, limit) { +export function whoIsTypingString(whoIsTyping: RoomMember[], limit: number): string { let othersCount = 0; if (whoIsTyping.length > limit) { othersCount = whoIsTyping.length - limit + 1; } + if (whoIsTyping.length === 0) { return ''; } else if (whoIsTyping.length === 1) { return _t('%(displayName)s is typing …', {displayName: whoIsTyping[0].name}); } - const names = whoIsTyping.map(function(m) { - return m.name; - }); - if (othersCount>=1) { + + const names = whoIsTyping.map(m => m.name); + + if (othersCount >= 1) { return _t('%(names)s and %(count)s others are typing …', { names: names.slice(0, limit - 1).join(', '), count: othersCount, diff --git a/src/WidgetMessaging.js b/src/WidgetMessaging.js deleted file mode 100644 index 6aed08c39d..0000000000 --- a/src/WidgetMessaging.js +++ /dev/null @@ -1,205 +0,0 @@ -/* -Copyright 2017 New Vector Ltd -Copyright 2019 Travis Ralston - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -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. -*/ - -/* -* See - https://docs.google.com/document/d/1uPF7XWY_dXTKVKV7jZQ2KmsI19wn9-kFRgQ1tFQP7wQ/edit?usp=sharing for -* spec. details / documentation. -*/ - -import FromWidgetPostMessageApi from './FromWidgetPostMessageApi'; -import ToWidgetPostMessageApi from './ToWidgetPostMessageApi'; -import Modal from "./Modal"; -import {MatrixClientPeg} from "./MatrixClientPeg"; -import SettingsStore from "./settings/SettingsStore"; -import WidgetOpenIDPermissionsDialog from "./components/views/dialogs/WidgetOpenIDPermissionsDialog"; -import WidgetUtils from "./utils/WidgetUtils"; -import {KnownWidgetActions} from "./widgets/WidgetApi"; - -if (!global.mxFromWidgetMessaging) { - global.mxFromWidgetMessaging = new FromWidgetPostMessageApi(); - global.mxFromWidgetMessaging.start(); -} -if (!global.mxToWidgetMessaging) { - global.mxToWidgetMessaging = new ToWidgetPostMessageApi(); - global.mxToWidgetMessaging.start(); -} - -const OUTBOUND_API_NAME = 'toWidget'; - -export default class WidgetMessaging { - /** - * @param {string} widgetId The widget's ID - * @param {string} wurl The raw URL of the widget as in the event (the 'wURL') - * @param {string} renderedUrl The url used in the widget's iframe (either similar to the wURL - * or a different URL of the clients choosing if it is using its own impl). - * @param {bool} isUserWidget If true, the widget is a user widget, otherwise it's a room widget - * @param {object} target Where widget messages should be sent (eg. the iframe object) - */ - constructor(widgetId, wurl, renderedUrl, isUserWidget, target) { - this.widgetId = widgetId; - this.wurl = wurl; - this.renderedUrl = renderedUrl; - this.isUserWidget = isUserWidget; - this.target = target; - this.fromWidget = global.mxFromWidgetMessaging; - this.toWidget = global.mxToWidgetMessaging; - this._onOpenIdRequest = this._onOpenIdRequest.bind(this); - this.start(); - } - - messageToWidget(action) { - action.widgetId = this.widgetId; // Required to be sent for all outbound requests - - return this.toWidget.exec(action, this.target).then((data) => { - // Check for errors and reject if found - if (data.response === undefined) { // null is valid - throw new Error("Missing 'response' field"); - } - if (data.response && data.response.error) { - const err = data.response.error; - const msg = String(err.message ? err.message : "An error was returned"); - if (err._error) { - console.error(err._error); - } - // Potential XSS attack if 'msg' is not appropriately sanitized, - // as it is untrusted input by our parent window (which we assume is Element). - // We can't aggressively sanitize [A-z0-9] since it might be a translation. - throw new Error(msg); - } - // Return the response field for the request - return data.response; - }); - } - - /** - * Tells the widget that the client is ready to handle further widget requests. - * @returns {Promise<*>} Resolves after the widget has acknowledged the ready message. - */ - flagReadyToContinue() { - return this.messageToWidget({ - api: OUTBOUND_API_NAME, - action: KnownWidgetActions.ClientReady, - }); - } - - /** - * Tells the widget that it should terminate now. - * @returns {Promise<*>} Resolves when widget has acknowledged the message. - */ - terminate() { - return this.messageToWidget({ - api: OUTBOUND_API_NAME, - action: KnownWidgetActions.Terminate, - }); - } - - /** - * Request a screenshot from a widget - * @return {Promise} To be resolved with screenshot data when it has been generated - */ - getScreenshot() { - console.log('Requesting screenshot for', this.widgetId); - return this.messageToWidget({ - api: OUTBOUND_API_NAME, - action: "screenshot", - }) - .catch((error) => new Error("Failed to get screenshot: " + error.message)) - .then((response) => response.screenshot); - } - - /** - * Request capabilities required by the widget - * @return {Promise} To be resolved with an array of requested widget capabilities - */ - getCapabilities() { - console.log('Requesting capabilities for', this.widgetId); - return this.messageToWidget({ - api: OUTBOUND_API_NAME, - action: "capabilities", - }).then((response) => { - console.log('Got capabilities for', this.widgetId, response.capabilities); - return response.capabilities; - }); - } - - sendVisibility(visible) { - return this.messageToWidget({ - api: OUTBOUND_API_NAME, - action: "visibility", - visible, - }) - .catch((error) => { - console.error("Failed to send visibility: ", error); - }); - } - - start() { - this.fromWidget.addEndpoint(this.widgetId, this.renderedUrl); - this.fromWidget.addListener("get_openid", this._onOpenIdRequest); - } - - stop() { - this.fromWidget.removeEndpoint(this.widgetId, this.renderedUrl); - this.fromWidget.removeListener("get_openid", this._onOpenIdRequest); - } - - async _onOpenIdRequest(ev, rawEv) { - if (ev.widgetId !== this.widgetId) return; // not interesting - - const widgetSecurityKey = WidgetUtils.getWidgetSecurityKey(this.widgetId, this.wurl, this.isUserWidget); - - const settings = SettingsStore.getValue("widgetOpenIDPermissions"); - if (settings.deny && settings.deny.includes(widgetSecurityKey)) { - this.fromWidget.sendResponse(rawEv, {state: "blocked"}); - return; - } - if (settings.allow && settings.allow.includes(widgetSecurityKey)) { - const responseBody = {state: "allowed"}; - const credentials = await MatrixClientPeg.get().getOpenIdToken(); - Object.assign(responseBody, credentials); - this.fromWidget.sendResponse(rawEv, responseBody); - return; - } - - // Confirm that we received the request - this.fromWidget.sendResponse(rawEv, {state: "request"}); - - // Actually ask for permission to send the user's data - Modal.createTrackedDialog("OpenID widget permissions", '', - WidgetOpenIDPermissionsDialog, { - widgetUrl: this.wurl, - widgetId: this.widgetId, - isUserWidget: this.isUserWidget, - - onFinished: async (confirm) => { - const responseBody = {success: confirm}; - if (confirm) { - const credentials = await MatrixClientPeg.get().getOpenIdToken(); - Object.assign(responseBody, credentials); - } - this.messageToWidget({ - api: OUTBOUND_API_NAME, - action: "openid_credentials", - data: responseBody, - }).catch((error) => { - console.error("Failed to send OpenID credentials: ", error); - }); - }, - }, - ); - } -} diff --git a/src/WidgetMessagingEndpoint.js b/src/WidgetMessagingEndpoint.js deleted file mode 100644 index 9114e12137..0000000000 --- a/src/WidgetMessagingEndpoint.js +++ /dev/null @@ -1,37 +0,0 @@ -/* -Copyright 2018 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. -*/ - - -/** - * Represents mapping of widget instance to URLs for trusted postMessage communication. - */ -export default class WidgetMessageEndpoint { - /** - * Mapping of widget instance to URL for trusted postMessage communication. - * @param {string} widgetId Unique widget identifier - * @param {string} endpointUrl Widget wurl origin. - */ - constructor(widgetId, endpointUrl) { - if (!widgetId) { - throw new Error("No widgetId specified in widgetMessageEndpoint constructor"); - } - if (!endpointUrl) { - throw new Error("No endpoint specified in widgetMessageEndpoint constructor"); - } - this.widgetId = widgetId; - this.endpointUrl = endpointUrl; - } -} diff --git a/src/accessibility/KeyboardShortcuts.tsx b/src/accessibility/KeyboardShortcuts.tsx index f527ab4a14..58d8124122 100644 --- a/src/accessibility/KeyboardShortcuts.tsx +++ b/src/accessibility/KeyboardShortcuts.tsx @@ -168,7 +168,7 @@ const shortcuts: Record = { key: Key.U, }], description: _td("Upload a file"), - } + }, ], [Categories.ROOM_LIST]: [ diff --git a/src/accessibility/RovingTabIndex.tsx b/src/accessibility/RovingTabIndex.tsx index 5a650d4b6e..434b931296 100644 --- a/src/accessibility/RovingTabIndex.tsx +++ b/src/accessibility/RovingTabIndex.tsx @@ -166,7 +166,8 @@ export const RovingTabIndexProvider: React.FC = ({children, handleHomeEn const onKeyDownHandler = useCallback((ev) => { let handled = false; - if (handleHomeEnd) { + // Don't interfere with input default keydown behaviour + if (handleHomeEnd && ev.target.tagName !== "INPUT") { // check if we actually have any items switch (ev.key) { case Key.HOME: @@ -190,7 +191,7 @@ export const RovingTabIndexProvider: React.FC = ({children, handleHomeEn ev.preventDefault(); ev.stopPropagation(); } else if (onKeyDown) { - return onKeyDown(ev, state); + return onKeyDown(ev, context.state); } }, [context.state, onKeyDown, handleHomeEnd]); diff --git a/src/accessibility/Toolbar.tsx b/src/accessibility/Toolbar.tsx index 0e968461a8..e756d948e5 100644 --- a/src/accessibility/Toolbar.tsx +++ b/src/accessibility/Toolbar.tsx @@ -28,8 +28,12 @@ interface IProps extends Omit, "onKeyDown"> { const Toolbar: React.FC = ({children, ...props}) => { const onKeyDown = (ev: React.KeyboardEvent, state: IState) => { const target = ev.target as HTMLElement; + // Don't interfere with input default keydown behaviour + if (target.tagName === "INPUT") return; + let handled = true; + // HOME and END are handled by RovingTabIndexProvider switch (ev.key) { case Key.ARROW_UP: case Key.ARROW_DOWN: @@ -47,8 +51,6 @@ const Toolbar: React.FC = ({children, ...props}) => { } break; - // HOME and END are handled by RovingTabIndexProvider - default: handled = false; } diff --git a/src/accessibility/context_menu/ContextMenuTooltipButton.tsx b/src/accessibility/context_menu/ContextMenuTooltipButton.tsx index abc5412100..49f57ca7b6 100644 --- a/src/accessibility/context_menu/ContextMenuTooltipButton.tsx +++ b/src/accessibility/context_menu/ContextMenuTooltipButton.tsx @@ -20,7 +20,7 @@ import React from "react"; import AccessibleTooltipButton from "../../components/views/elements/AccessibleTooltipButton"; -interface IProps extends React.ComponentProps { +interface IProps extends React.ComponentProps { // whether or not the context menu is currently open isExpanded: boolean; } diff --git a/src/accessibility/context_menu/MenuItem.tsx b/src/accessibility/context_menu/MenuItem.tsx index 64233e51ad..0bb169abf8 100644 --- a/src/accessibility/context_menu/MenuItem.tsx +++ b/src/accessibility/context_menu/MenuItem.tsx @@ -26,8 +26,9 @@ interface IProps extends React.ComponentProps { // Semantic component for representing a role=menuitem export const MenuItem: React.FC = ({children, label, ...props}) => { + const ariaLabel = props["aria-label"] || label; return ( - + { children } ); diff --git a/src/accessibility/roving/RovingAccessibleTooltipButton.tsx b/src/accessibility/roving/RovingAccessibleTooltipButton.tsx index cc824fef22..2cb974d60e 100644 --- a/src/accessibility/roving/RovingAccessibleTooltipButton.tsx +++ b/src/accessibility/roving/RovingAccessibleTooltipButton.tsx @@ -20,7 +20,8 @@ import AccessibleTooltipButton from "../../components/views/elements/AccessibleT import {useRovingTabIndex} from "../RovingTabIndex"; import {Ref} from "./types"; -interface IProps extends Omit, "onFocus" | "inputRef" | "tabIndex"> { +type ATBProps = React.ComponentProps; +interface IProps extends Omit { inputRef?: Ref; } diff --git a/src/accessibility/roving/RovingTabIndexWrapper.tsx b/src/accessibility/roving/RovingTabIndexWrapper.tsx index c826b74497..5211f30215 100644 --- a/src/accessibility/roving/RovingTabIndexWrapper.tsx +++ b/src/accessibility/roving/RovingTabIndexWrapper.tsx @@ -16,7 +16,6 @@ limitations under the License. import React from "react"; -import AccessibleButton from "../../components/views/elements/AccessibleButton"; import {useRovingTabIndex} from "../RovingTabIndex"; import {FocusHandler, Ref} from "./types"; diff --git a/src/actions/TagOrderActions.ts b/src/actions/TagOrderActions.ts index c203172874..021cd11b55 100644 --- a/src/actions/TagOrderActions.ts +++ b/src/actions/TagOrderActions.ts @@ -17,14 +17,14 @@ limitations under the License. import Analytics from '../Analytics'; import { asyncAction } from './actionCreators'; -import TagOrderStore from '../stores/TagOrderStore'; +import GroupFilterOrderStore from '../stores/GroupFilterOrderStore'; import { AsyncActionPayload } from "../dispatcher/payloads"; import { MatrixClient } from "matrix-js-sdk/src/client"; export default class TagOrderActions { /** * Creates an action thunk that will do an asynchronous request to - * move a tag in TagOrderStore to destinationIx. + * move a tag in GroupFilterOrderStore to destinationIx. * * @param {MatrixClient} matrixClient the matrix client to set the * account data on. @@ -36,8 +36,8 @@ export default class TagOrderActions { */ public static moveTag(matrixClient: MatrixClient, tag: string, destinationIx: number): AsyncActionPayload { // Only commit tags if the state is ready, i.e. not null - let tags = TagOrderStore.getOrderedTags(); - let removedTags = TagOrderStore.getRemovedTagsAccountData() || []; + let tags = GroupFilterOrderStore.getOrderedTags(); + let removedTags = GroupFilterOrderStore.getRemovedTagsAccountData() || []; if (!tags) { return; } @@ -47,7 +47,7 @@ export default class TagOrderActions { removedTags = removedTags.filter((t) => t !== tag); - const storeId = TagOrderStore.getStoreId(); + const storeId = GroupFilterOrderStore.getStoreId(); return asyncAction('TagOrderActions.moveTag', () => { Analytics.trackEvent('TagOrderActions', 'commitTagOrdering'); @@ -83,8 +83,8 @@ export default class TagOrderActions { */ public static removeTag(matrixClient: MatrixClient, tag: string): AsyncActionPayload { // Don't change tags, just removedTags - const tags = TagOrderStore.getOrderedTags(); - const removedTags = TagOrderStore.getRemovedTagsAccountData() || []; + const tags = GroupFilterOrderStore.getOrderedTags(); + const removedTags = GroupFilterOrderStore.getRemovedTagsAccountData() || []; if (removedTags.includes(tag)) { // Return a thunk that doesn't do anything, we don't even need @@ -94,7 +94,7 @@ export default class TagOrderActions { removedTags.push(tag); - const storeId = TagOrderStore.getStoreId(); + const storeId = GroupFilterOrderStore.getStoreId(); return asyncAction('TagOrderActions.removeTag', () => { Analytics.trackEvent('TagOrderActions', 'removeTag'); diff --git a/src/async-components/views/dialogs/keybackup/IgnoreRecoveryReminderDialog.js b/src/async-components/views/dialogs/keybackup/IgnoreRecoveryReminderDialog.js deleted file mode 100644 index b79911c66e..0000000000 --- a/src/async-components/views/dialogs/keybackup/IgnoreRecoveryReminderDialog.js +++ /dev/null @@ -1,70 +0,0 @@ -/* -Copyright 2018 New Vector Ltd - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import React from "react"; -import PropTypes from "prop-types"; -import * as sdk from "../../../../index"; -import { _t } from "../../../../languageHandler"; - -export default class IgnoreRecoveryReminderDialog extends React.PureComponent { - static propTypes = { - onDontAskAgain: PropTypes.func.isRequired, - onFinished: PropTypes.func.isRequired, - onSetup: PropTypes.func.isRequired, - } - - onDontAskAgainClick = () => { - this.props.onFinished(); - this.props.onDontAskAgain(); - } - - onSetupClick = () => { - this.props.onFinished(); - this.props.onSetup(); - } - - render() { - const BaseDialog = sdk.getComponent("views.dialogs.BaseDialog"); - const DialogButtons = sdk.getComponent("views.elements.DialogButtons"); - - return ( - -
-

{_t( - "Without setting up Secure Message Recovery, " + - "you'll lose your secure message history when you " + - "log out.", - )}

-

{_t( - "If you don't want to set this up now, you can later " + - "in Settings.", - )}

-
- -
-
-
- ); - } -} diff --git a/src/async-components/views/dialogs/keybackup/CreateKeyBackupDialog.js b/src/async-components/views/dialogs/security/CreateKeyBackupDialog.js similarity index 99% rename from src/async-components/views/dialogs/keybackup/CreateKeyBackupDialog.js rename to src/async-components/views/dialogs/security/CreateKeyBackupDialog.js index c3aef9109a..ab39a094db 100644 --- a/src/async-components/views/dialogs/keybackup/CreateKeyBackupDialog.js +++ b/src/async-components/views/dialogs/security/CreateKeyBackupDialog.js @@ -21,7 +21,7 @@ import * as sdk from '../../../../index'; import {MatrixClientPeg} from '../../../../MatrixClientPeg'; import PropTypes from 'prop-types'; import {_t, _td} from '../../../../languageHandler'; -import { accessSecretStorage } from '../../../../CrossSigningManager'; +import { accessSecretStorage } from '../../../../SecurityManager'; import AccessibleButton from "../../../../components/views/elements/AccessibleButton"; import {copyNode} from "../../../../utils/strings"; import PassphraseField from "../../../../components/views/auth/PassphraseField"; diff --git a/src/async-components/views/dialogs/secretstorage/CreateSecretStorageDialog.js b/src/async-components/views/dialogs/security/CreateSecretStorageDialog.js similarity index 87% rename from src/async-components/views/dialogs/secretstorage/CreateSecretStorageDialog.js rename to src/async-components/views/dialogs/security/CreateSecretStorageDialog.js index 53b3033330..6819742388 100644 --- a/src/async-components/views/dialogs/secretstorage/CreateSecretStorageDialog.js +++ b/src/async-components/views/dialogs/security/CreateSecretStorageDialog.js @@ -22,7 +22,7 @@ import {MatrixClientPeg} from '../../../../MatrixClientPeg'; import FileSaver from 'file-saver'; import {_t, _td} from '../../../../languageHandler'; import Modal from '../../../../Modal'; -import { promptForBackupPassphrase } from '../../../../CrossSigningManager'; +import { promptForBackupPassphrase } from '../../../../SecurityManager'; import {copyNode} from "../../../../utils/strings"; import {SSOAuthEntry} from "../../../../components/views/auth/InteractiveAuthEntryComponents"; import PassphraseField from "../../../../components/views/auth/PassphraseField"; @@ -30,6 +30,9 @@ import StyledRadioButton from '../../../../components/views/elements/StyledRadio import AccessibleButton from "../../../../components/views/elements/AccessibleButton"; import DialogButtons from "../../../../components/views/elements/DialogButtons"; import InlineSpinner from "../../../../components/views/elements/InlineSpinner"; +import RestoreKeyBackupDialog from "../../../../components/views/dialogs/security/RestoreKeyBackupDialog"; +import { getSecureBackupSetupMethods, isSecureBackupRequired } from '../../../../utils/WellKnownUtils'; +import SecurityCustomisations from "../../../../customisations/Security"; const PHASE_LOADING = 0; const PHASE_LOADERROR = 1; @@ -55,12 +58,12 @@ export default class CreateSecretStorageDialog extends React.PureComponent { static propTypes = { hasCancel: PropTypes.bool, accountPassword: PropTypes.string, - force: PropTypes.bool, + forceReset: PropTypes.bool, }; static defaultProps = { hasCancel: true, - force: false, + forceReset: false, }; constructor(props) { @@ -85,13 +88,20 @@ export default class CreateSecretStorageDialog extends React.PureComponent { canUploadKeysWithPasswordOnly: null, accountPassword: props.accountPassword || "", accountPasswordCorrect: null, - - passPhraseKeySelected: CREATE_STORAGE_OPTION_KEY, + canSkip: !isSecureBackupRequired(), }; + const setupMethods = getSecureBackupSetupMethods(); + if (setupMethods.includes("key")) { + this.state.passPhraseKeySelected = CREATE_STORAGE_OPTION_KEY; + } else { + this.state.passPhraseKeySelected = CREATE_STORAGE_OPTION_PASSPHRASE; + } + this._passphraseField = createRef(); - this._fetchBackupInfo(); + MatrixClientPeg.get().on('crypto.keyBackupStatus', this._onKeyBackupStatusChange); + if (this.state.accountPassword) { // If we have an account password in memory, let's simplify and // assume it means password auth is also supported for device @@ -102,13 +112,27 @@ export default class CreateSecretStorageDialog extends React.PureComponent { this._queryKeyUploadAuth(); } - MatrixClientPeg.get().on('crypto.keyBackupStatus', this._onKeyBackupStatusChange); + this._getInitialPhase(); } componentWillUnmount() { MatrixClientPeg.get().removeListener('crypto.keyBackupStatus', this._onKeyBackupStatusChange); } + _getInitialPhase() { + const keyFromCustomisations = SecurityCustomisations.createSecretStorageKey?.(); + if (keyFromCustomisations) { + console.log("Created key via customisations, jumping to bootstrap step"); + this._recoveryKey = { + privateKey: keyFromCustomisations, + }; + this._bootstrapSecretStorage(); + return; + } + + this._fetchBackupInfo(); + } + async _fetchBackupInfo() { try { const backupInfo = await MatrixClientPeg.get().getKeyBackupVersion(); @@ -117,8 +141,8 @@ export default class CreateSecretStorageDialog extends React.PureComponent { MatrixClientPeg.get().isCryptoEnabled() && await MatrixClientPeg.get().isKeyBackupTrusted(backupInfo) ); - const { force } = this.props; - const phase = (backupInfo && !force) ? PHASE_MIGRATE : PHASE_CHOOSE_KEY_PASSPHRASE; + const { forceReset } = this.props; + const phase = (backupInfo && !forceReset) ? PHASE_MIGRATE : PHASE_CHOOSE_KEY_PASSPHRASE; this.setState({ phase, @@ -276,20 +300,28 @@ export default class CreateSecretStorageDialog extends React.PureComponent { const cli = MatrixClientPeg.get(); - const { force } = this.props; + const { forceReset } = this.props; try { - if (force) { - console.log("Forcing secret storage reset"); // log something so we can debug this later + if (forceReset) { + console.log("Forcing secret storage reset"); await cli.bootstrapSecretStorage({ - authUploadDeviceSigningKeys: this._doBootstrapUIAuth, createSecretStorageKey: async () => this._recoveryKey, setupNewKeyBackup: true, setupNewSecretStorage: true, }); } else { - await cli.bootstrapSecretStorage({ + // For password authentication users after 2020-09, this cross-signing + // step will be a no-op since it is now setup during registration or login + // when needed. We should keep this here to cover other cases such as: + // * Users with existing sessions prior to 2020-09 changes + // * SSO authentication users which require interactive auth to upload + // keys (and also happen to skip all post-authentication flows at the + // moment via token login) + await cli.bootstrapCrossSigning({ authUploadDeviceSigningKeys: this._doBootstrapUIAuth, + }); + await cli.bootstrapSecretStorage({ createSecretStorageKey: async () => this._recoveryKey, keyBackupInfo: this.state.backupInfo, setupNewKeyBackup: !this.state.backupInfo, @@ -332,7 +364,6 @@ export default class CreateSecretStorageDialog extends React.PureComponent { // so let's stash it here, rather than prompting for it twice. const keyCallback = k => this._backupKey = k; - const RestoreKeyBackupDialog = sdk.getComponent('dialogs.keybackup.RestoreKeyBackupDialog'); const { finished } = Modal.createTrackedDialog( 'Restore Backup', '', RestoreKeyBackupDialog, { @@ -432,45 +463,61 @@ export default class CreateSecretStorageDialog extends React.PureComponent { }); } + _renderOptionKey() { + return ( + +
+ + {_t("Generate a Security Key")} +
+
{_t("We’ll generate a Security Key for you to store somewhere safe, like a password manager or a safe.")}
+
+ ); + } + + _renderOptionPassphrase() { + return ( + +
+ + {_t("Enter a Security Phrase")} +
+
{_t("Use a secret phrase only you know, and optionally save a Security Key to use for backup.")}
+
+ ); + } + _renderPhaseChooseKeyPassphrase() { + const setupMethods = getSecureBackupSetupMethods(); + const optionKey = setupMethods.includes("key") ? this._renderOptionKey() : null; + const optionPassphrase = setupMethods.includes("passphrase") ? this._renderOptionPassphrase() : null; + return

{_t( "Safeguard against losing access to encrypted messages & data by " + "backing up encryption keys on your server.", )}

- -
- - {_t("Generate a Security Key")} -
-
{_t("We’ll generate a Security Key for you to store somewhere safe, like a password manager or a safe.")}
-
- -
- - {_t("Enter a Security Phrase")} -
-
{_t("Use a secret phrase only you know, and optionally save a Security Key to use for backup.")}
-
+ {optionKey} + {optionPassphrase}
; } @@ -687,7 +734,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
@@ -714,7 +761,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent { _titleForPhase(phase) { switch (phase) { case PHASE_CHOOSE_KEY_PASSPHRASE: - return _t('Set up Secure backup'); + return _t('Set up Secure Backup'); case PHASE_MIGRATE: return _t('Upgrade your encryption'); case PHASE_PASSPHRASE: @@ -742,7 +789,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
diff --git a/src/async-components/views/dialogs/ExportE2eKeysDialog.js b/src/async-components/views/dialogs/security/ExportE2eKeysDialog.js similarity index 90% rename from src/async-components/views/dialogs/ExportE2eKeysDialog.js rename to src/async-components/views/dialogs/security/ExportE2eKeysDialog.js index a92578a547..4dd296a8f1 100644 --- a/src/async-components/views/dialogs/ExportE2eKeysDialog.js +++ b/src/async-components/views/dialogs/security/ExportE2eKeysDialog.js @@ -17,44 +17,40 @@ limitations under the License. import FileSaver from 'file-saver'; import React, {createRef} from 'react'; import PropTypes from 'prop-types'; -import createReactClass from 'create-react-class'; -import { _t } from '../../../languageHandler'; +import { _t } from '../../../../languageHandler'; import { MatrixClient } from 'matrix-js-sdk'; -import * as MegolmExportEncryption from '../../../utils/MegolmExportEncryption'; -import * as sdk from '../../../index'; +import * as MegolmExportEncryption from '../../../../utils/MegolmExportEncryption'; +import * as sdk from '../../../../index'; const PHASE_EDIT = 1; const PHASE_EXPORTING = 2; -export default createReactClass({ - displayName: 'ExportE2eKeysDialog', - - propTypes: { +export default class ExportE2eKeysDialog extends React.Component { + static propTypes = { matrixClient: PropTypes.instanceOf(MatrixClient).isRequired, onFinished: PropTypes.func.isRequired, - }, + }; - getInitialState: function() { - return { - phase: PHASE_EDIT, - errStr: null, - }; - }, + constructor(props) { + super(props); - // TODO: [REACT-WARNING] Replace component with real class, use constructor for refs - UNSAFE_componentWillMount: function() { this._unmounted = false; this._passphrase1 = createRef(); this._passphrase2 = createRef(); - }, - componentWillUnmount: function() { + this.state = { + phase: PHASE_EDIT, + errStr: null, + }; + } + + componentWillUnmount() { this._unmounted = true; - }, + } - _onPassphraseFormSubmit: function(ev) { + _onPassphraseFormSubmit = (ev) => { ev.preventDefault(); const passphrase = this._passphrase1.current.value; @@ -69,9 +65,9 @@ export default createReactClass({ this._startExport(passphrase); return false; - }, + }; - _startExport: function(passphrase) { + _startExport(passphrase) { // extra Promise.resolve() to turn synchronous exceptions into // asynchronous ones. Promise.resolve().then(() => { @@ -102,15 +98,15 @@ export default createReactClass({ errStr: null, phase: PHASE_EXPORTING, }); - }, + } - _onCancelClick: function(ev) { + _onCancelClick = (ev) => { ev.preventDefault(); this.props.onFinished(false); return false; - }, + }; - render: function() { + render() { const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); const disableForm = (this.state.phase === PHASE_EXPORTING); @@ -184,5 +180,5 @@ export default createReactClass({ ); - }, -}); + } +} diff --git a/src/async-components/views/dialogs/ImportE2eKeysDialog.js b/src/async-components/views/dialogs/security/ImportE2eKeysDialog.js similarity index 89% rename from src/async-components/views/dialogs/ImportE2eKeysDialog.js rename to src/async-components/views/dialogs/security/ImportE2eKeysDialog.js index 6b9d2c7e45..e7bae3578b 100644 --- a/src/async-components/views/dialogs/ImportE2eKeysDialog.js +++ b/src/async-components/views/dialogs/security/ImportE2eKeysDialog.js @@ -16,12 +16,11 @@ limitations under the License. import React, {createRef} from 'react'; import PropTypes from 'prop-types'; -import createReactClass from 'create-react-class'; import { MatrixClient } from 'matrix-js-sdk'; -import * as MegolmExportEncryption from '../../../utils/MegolmExportEncryption'; -import * as sdk from '../../../index'; -import { _t } from '../../../languageHandler'; +import * as MegolmExportEncryption from '../../../../utils/MegolmExportEncryption'; +import * as sdk from '../../../../index'; +import { _t } from '../../../../languageHandler'; function readFileAsArrayBuffer(file) { return new Promise((resolve, reject) => { @@ -38,48 +37,45 @@ function readFileAsArrayBuffer(file) { const PHASE_EDIT = 1; const PHASE_IMPORTING = 2; -export default createReactClass({ - displayName: 'ImportE2eKeysDialog', - - propTypes: { +export default class ImportE2eKeysDialog extends React.Component { + static propTypes = { matrixClient: PropTypes.instanceOf(MatrixClient).isRequired, onFinished: PropTypes.func.isRequired, - }, + }; - getInitialState: function() { - return { - enableSubmit: false, - phase: PHASE_EDIT, - errStr: null, - }; - }, + constructor(props) { + super(props); - // TODO: [REACT-WARNING] Replace component with real class, use constructor for refs - UNSAFE_componentWillMount: function() { this._unmounted = false; this._file = createRef(); this._passphrase = createRef(); - }, - componentWillUnmount: function() { + this.state = { + enableSubmit: false, + phase: PHASE_EDIT, + errStr: null, + }; + } + + componentWillUnmount() { this._unmounted = true; - }, + } - _onFormChange: function(ev) { + _onFormChange = (ev) => { const files = this._file.current.files || []; this.setState({ enableSubmit: (this._passphrase.current.value !== "" && files.length > 0), }); - }, + }; - _onFormSubmit: function(ev) { + _onFormSubmit = (ev) => { ev.preventDefault(); this._startImport(this._file.current.files[0], this._passphrase.current.value); return false; - }, + }; - _startImport: function(file, passphrase) { + _startImport(file, passphrase) { this.setState({ errStr: null, phase: PHASE_IMPORTING, @@ -105,15 +101,15 @@ export default createReactClass({ phase: PHASE_EDIT, }); }); - }, + } - _onCancelClick: function(ev) { + _onCancelClick = (ev) => { ev.preventDefault(); this.props.onFinished(false); return false; - }, + }; - render: function() { + render() { const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); const disableForm = (this.state.phase !== PHASE_EDIT); @@ -188,5 +184,5 @@ export default createReactClass({ ); - }, -}); + } +} diff --git a/src/async-components/views/dialogs/keybackup/NewRecoveryMethodDialog.js b/src/async-components/views/dialogs/security/NewRecoveryMethodDialog.js similarity index 97% rename from src/async-components/views/dialogs/keybackup/NewRecoveryMethodDialog.js rename to src/async-components/views/dialogs/security/NewRecoveryMethodDialog.js index 74552a5c08..9f5045635d 100644 --- a/src/async-components/views/dialogs/keybackup/NewRecoveryMethodDialog.js +++ b/src/async-components/views/dialogs/security/NewRecoveryMethodDialog.js @@ -22,6 +22,7 @@ import {MatrixClientPeg} from '../../../../MatrixClientPeg'; import dis from "../../../../dispatcher/dispatcher"; import { _t } from "../../../../languageHandler"; import Modal from "../../../../Modal"; +import RestoreKeyBackupDialog from "../../../../components/views/dialogs/security/RestoreKeyBackupDialog"; import {Action} from "../../../../dispatcher/actions"; export default class NewRecoveryMethodDialog extends React.PureComponent { @@ -41,7 +42,6 @@ export default class NewRecoveryMethodDialog extends React.PureComponent { } onSetupClick = async () => { - const RestoreKeyBackupDialog = sdk.getComponent('dialogs.keybackup.RestoreKeyBackupDialog'); Modal.createTrackedDialog( 'Restore Backup', '', RestoreKeyBackupDialog, { onFinished: this.props.onFinished, diff --git a/src/async-components/views/dialogs/keybackup/RecoveryMethodRemovedDialog.js b/src/async-components/views/dialogs/security/RecoveryMethodRemovedDialog.js similarity index 100% rename from src/async-components/views/dialogs/keybackup/RecoveryMethodRemovedDialog.js rename to src/async-components/views/dialogs/security/RecoveryMethodRemovedDialog.js diff --git a/src/autocomplete/CommandProvider.tsx b/src/autocomplete/CommandProvider.tsx index e7a6f44536..c2d1290e08 100644 --- a/src/autocomplete/CommandProvider.tsx +++ b/src/autocomplete/CommandProvider.tsx @@ -47,7 +47,7 @@ export default class CommandProvider extends AutocompleteProvider { if (command[0] !== command[1]) { // The input looks like a command with arguments, perform exact match const name = command[1].substr(1); // strip leading `/` - if (CommandMap.has(name)) { + if (CommandMap.has(name) && CommandMap.get(name).isEnabled()) { // some commands, namely `me` and `ddg` don't suit having the usage shown whilst typing their arguments if (CommandMap.get(name).hideCompletionAfterSpace) return []; matches = [CommandMap.get(name)]; @@ -63,7 +63,7 @@ export default class CommandProvider extends AutocompleteProvider { } - return matches.map((result) => { + return matches.filter(cmd => cmd.isEnabled()).map((result) => { let completion = result.getCommand() + ' '; const usedAlias = result.aliases.find(alias => `/${alias}` === command[1]); // If the command (or an alias) is the same as the one they entered, we don't want to discard their arguments @@ -89,7 +89,11 @@ export default class CommandProvider extends AutocompleteProvider { renderCompletions(completions: React.ReactNode[]): React.ReactNode { return ( -
+
{ completions }
); diff --git a/src/autocomplete/CommunityProvider.tsx b/src/autocomplete/CommunityProvider.tsx index f34fee890e..ebf5d536ec 100644 --- a/src/autocomplete/CommunityProvider.tsx +++ b/src/autocomplete/CommunityProvider.tsx @@ -23,7 +23,7 @@ import {MatrixClientPeg} from '../MatrixClientPeg'; import QueryMatcher from './QueryMatcher'; import {PillCompletion} from './Components'; import * as sdk from '../index'; -import _sortBy from 'lodash/sortBy'; +import {sortBy} from "lodash"; import {makeGroupPermalink} from "../utils/permalinks/Permalinks"; import {ICompletion, ISelectionRange} from "./Autocompleter"; import FlairStore from "../stores/FlairStore"; @@ -81,7 +81,7 @@ export default class CommunityProvider extends AutocompleteProvider { const matchedString = command[0]; completions = this.matcher.match(matchedString); - completions = _sortBy(completions, [ + completions = sortBy(completions, [ (c) => score(matchedString, c.groupId), (c) => c.groupId.length, ]).map(({avatarUrl, groupId, name}) => ({ @@ -91,15 +91,15 @@ export default class CommunityProvider extends AutocompleteProvider { href: makeGroupPermalink(groupId), component: ( - + ), range, - })) - .slice(0, 4); + })).slice(0, 4); } return completions; } diff --git a/src/autocomplete/Components.tsx b/src/autocomplete/Components.tsx index 6ac2f4db14..4b0d35698d 100644 --- a/src/autocomplete/Components.tsx +++ b/src/autocomplete/Components.tsx @@ -34,9 +34,9 @@ export const TextualCompletion = forwardRef((props const {title, subtitle, description, className, ...restProps} = props; return (
{ title } { subtitle } @@ -53,9 +53,9 @@ export const PillCompletion = forwardRef((props, ref) const {title, subtitle, description, className, children, ...restProps} = props; return (
{ children } { title } diff --git a/src/autocomplete/EmojiProvider.tsx b/src/autocomplete/EmojiProvider.tsx index 147d68f5ff..705474f8d0 100644 --- a/src/autocomplete/EmojiProvider.tsx +++ b/src/autocomplete/EmojiProvider.tsx @@ -23,8 +23,7 @@ import AutocompleteProvider from './AutocompleteProvider'; import QueryMatcher from './QueryMatcher'; import {PillCompletion} from './Components'; import {ICompletion, ISelectionRange} from './Autocompleter'; -import _uniq from 'lodash/uniq'; -import _sortBy from 'lodash/sortBy'; +import {uniq, sortBy} from 'lodash'; import SettingsStore from "../settings/SettingsStore"; import { shortcodeToUnicode } from '../HtmlUtils'; import { EMOJI, IEmoji } from '../emoji'; @@ -115,7 +114,7 @@ export default class EmojiProvider extends AutocompleteProvider { } // Finally, sort by original ordering sorters.push((c) => c._orderBy); - completions = _sortBy(_uniq(completions), sorters); + completions = sortBy(uniq(completions), sorters); completions = completions.map(({shortname}) => { const unicode = shortcodeToUnicode(shortname); @@ -139,7 +138,11 @@ export default class EmojiProvider extends AutocompleteProvider { renderCompletions(completions: React.ReactNode[]): React.ReactNode { return ( -
+
{ completions }
); diff --git a/src/autocomplete/QueryMatcher.ts b/src/autocomplete/QueryMatcher.ts index 9c91414556..a07ed29c7e 100644 --- a/src/autocomplete/QueryMatcher.ts +++ b/src/autocomplete/QueryMatcher.ts @@ -16,8 +16,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import _at from 'lodash/at'; -import _uniq from 'lodash/uniq'; +import {at, uniq} from 'lodash'; import {removeHiddenChars} from "matrix-js-sdk/src/utils"; interface IOptions { @@ -73,7 +72,7 @@ export default class QueryMatcher { // type for their values. We assume that those values who's keys have // been specified will be string. Also, we cannot infer all the // types of the keys of the objects at compile. - const keyValues = _at(object, this._options.keys); + const keyValues = at(object, this._options.keys); if (this._options.funcs) { for (const f of this._options.funcs) { @@ -137,7 +136,7 @@ export default class QueryMatcher { }); // Now map the keys to the result objects. Also remove any duplicates. - return _uniq(matches.map((match) => match.object)); + return uniq(matches.map((match) => match.object)); } private processQuery(query: string): string { diff --git a/src/autocomplete/RoomProvider.tsx b/src/autocomplete/RoomProvider.tsx index b18b2d132c..74deacf61f 100644 --- a/src/autocomplete/RoomProvider.tsx +++ b/src/autocomplete/RoomProvider.tsx @@ -27,7 +27,7 @@ import {PillCompletion} from './Components'; import * as sdk from '../index'; import {makeRoomPermalink} from "../utils/permalinks/Permalinks"; import {ICompletion, ISelectionRange} from "./Autocompleter"; -import { uniqBy, sortBy } from 'lodash'; +import {uniqBy, sortBy} from "lodash"; const ROOM_REGEX = /\B#\S*/g; @@ -110,9 +110,7 @@ export default class RoomProvider extends AutocompleteProvider { ), range, }; - }) - .filter((completion) => !!completion.completion && completion.completion.length > 0) - .slice(0, 4); + }).filter((completion) => !!completion.completion && completion.completion.length > 0).slice(0, 4); } return completions; } diff --git a/src/autocomplete/UserProvider.tsx b/src/autocomplete/UserProvider.tsx index c957b5e597..32eea55b0b 100644 --- a/src/autocomplete/UserProvider.tsx +++ b/src/autocomplete/UserProvider.tsx @@ -23,7 +23,7 @@ import AutocompleteProvider from './AutocompleteProvider'; import {PillCompletion} from './Components'; import * as sdk from '../index'; import QueryMatcher from './QueryMatcher'; -import _sortBy from 'lodash/sortBy'; +import {sortBy} from 'lodash'; import {MatrixClientPeg} from '../MatrixClientPeg'; import MatrixEvent from "matrix-js-sdk/src/models/event"; @@ -71,8 +71,13 @@ export default class UserProvider extends AutocompleteProvider { } } - private onRoomTimeline = (ev: MatrixEvent, room: Room, toStartOfTimeline: boolean, removed: boolean, - data: IRoomTimelineData) => { + private onRoomTimeline = ( + ev: MatrixEvent, + room: Room, + toStartOfTimeline: boolean, + removed: boolean, + data: IRoomTimelineData, + ) => { if (!room) return; if (removed) return; if (room.roomId !== this.room.roomId) return; @@ -151,7 +156,7 @@ export default class UserProvider extends AutocompleteProvider { const currentUserId = MatrixClientPeg.get().credentials.userId; this.users = this.room.getJoinedMembers().filter(({userId}) => userId !== currentUserId); - this.users = _sortBy(this.users, (member) => 1E20 - lastSpoken[member.userId] || 1E20); + this.users = sortBy(this.users, (member) => 1E20 - lastSpoken[member.userId] || 1E20); this.matcher.setObjects(this.users); } @@ -171,7 +176,11 @@ export default class UserProvider extends AutocompleteProvider { renderCompletions(completions: React.ReactNode[]): React.ReactNode { return ( -
+
{ completions }
); diff --git a/src/components/structures/CompatibilityPage.js b/src/components/structures/CompatibilityPage.js deleted file mode 100644 index 1fa6068675..0000000000 --- a/src/components/structures/CompatibilityPage.js +++ /dev/null @@ -1,90 +0,0 @@ -/* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> -Copyright 2019, 2020 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import React from 'react'; -import createReactClass from 'create-react-class'; -import PropTypes from 'prop-types'; -import { _t } from '../../languageHandler'; -import SdkConfig from '../../SdkConfig'; - -export default createReactClass({ - displayName: 'CompatibilityPage', - propTypes: { - onAccept: PropTypes.func, - }, - - getDefaultProps: function() { - return { - onAccept: function() {}, // NOP - }; - }, - - onAccept: function() { - this.props.onAccept(); - }, - - render: function() { - const brand = SdkConfig.get().brand; - - return ( -
-
-

{_t( - "Sorry, your browser is not able to run %(brand)s.", - { - brand, - }, - { - 'b': (sub) => {sub}, - }) - }

-

- { _t( - "%(brand)s uses many advanced browser features, some of which are not available " + - "or experimental in your current browser.", - { brand }, - ) } -

-

- { _t( - 'Please install Chrome, Firefox, ' + - 'or Safari for the best experience.', - {}, - { - 'chromeLink': (sub) => {sub}, - 'firefoxLink': (sub) => {sub}, - 'safariLink': (sub) => {sub}, - }, - )} -

-

- { _t( - "With your current browser, the look and feel of the application may be " + - "completely incorrect, and some or all features may not function. " + - "If you want to try it anyway you can continue, but you are on your own in terms " + - "of any issues you may encounter!", - ) } -

- -
-
- ); - }, -}); diff --git a/src/components/structures/ContextMenu.tsx b/src/components/structures/ContextMenu.tsx index 587ae2cb6b..884f77aba5 100644 --- a/src/components/structures/ContextMenu.tsx +++ b/src/components/structures/ContextMenu.tsx @@ -16,7 +16,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, {CSSProperties, useRef, useState} from "react"; +import React, {CSSProperties, RefObject, useRef, useState} from "react"; import ReactDOM from "react-dom"; import classNames from "classnames"; @@ -233,8 +233,7 @@ export class ContextMenu extends React.PureComponent { switch (ev.key) { case Key.TAB: case Key.ESCAPE: - // close on left and right arrows too for when it is a context menu on a - case Key.ARROW_LEFT: + case Key.ARROW_LEFT: // close on left and right arrows too for when it is a context menu on a case Key.ARROW_RIGHT: this.props.onFinished(); break; @@ -417,8 +416,8 @@ export const aboveLeftOf = (elementRect: DOMRect, chevronFace = ChevronFace.None return menuOptions; }; -export const useContextMenu = () => { - const button = useRef(null); +export const useContextMenu = (): [boolean, RefObject, () => void, () => void, (val: boolean) => void] => { + const button = useRef(null); const [isOpen, setIsOpen] = useState(false); const open = () => { setIsOpen(true); diff --git a/src/components/structures/EmbeddedPage.js b/src/components/structures/EmbeddedPage.js index 49ba3d1227..cbfeff7582 100644 --- a/src/components/structures/EmbeddedPage.js +++ b/src/components/structures/EmbeddedPage.js @@ -43,8 +43,8 @@ export default class EmbeddedPage extends React.PureComponent { static contextType = MatrixClientContext; - constructor(props) { - super(props); + constructor(props, context) { + super(props, context); this._dispatcherRef = null; diff --git a/src/components/structures/FilePanel.js b/src/components/structures/FilePanel.js index d873dd4094..4836b0f554 100644 --- a/src/components/structures/FilePanel.js +++ b/src/components/structures/FilePanel.js @@ -16,7 +16,6 @@ limitations under the License. */ import React from 'react'; -import createReactClass from 'create-react-class'; import PropTypes from 'prop-types'; import {Filter} from 'matrix-js-sdk'; @@ -24,27 +23,28 @@ import * as sdk from '../../index'; import {MatrixClientPeg} from '../../MatrixClientPeg'; import EventIndexPeg from "../../indexing/EventIndexPeg"; import { _t } from '../../languageHandler'; +import BaseCard from "../views/right_panel/BaseCard"; +import {RightPanelPhases} from "../../stores/RightPanelStorePhases"; +import DesktopBuildsNotice, {WarningKind} from "../views/elements/DesktopBuildsNotice"; /* * Component which shows the filtered file using a TimelinePanel */ -const FilePanel = createReactClass({ - displayName: 'FilePanel', +class FilePanel extends React.Component { + static propTypes = { + roomId: PropTypes.string.isRequired, + onClose: PropTypes.func.isRequired, + }; + // This is used to track if a decrypted event was a live event and should be // added to the timeline. - decryptingEvents: new Set(), + decryptingEvents = new Set(); - propTypes: { - roomId: PropTypes.string.isRequired, - }, + state = { + timelineSet: null, + }; - getInitialState: function() { - return { - timelineSet: null, - }; - }, - - onRoomTimeline(ev, room, toStartOfTimeline, removed, data) { + onRoomTimeline = (ev, room, toStartOfTimeline, removed, data) => { if (room.roomId !== this.props.roomId) return; if (toStartOfTimeline || !data || !data.liveEvent || ev.isRedacted()) return; @@ -53,9 +53,9 @@ const FilePanel = createReactClass({ } else { this.addEncryptedLiveEvent(ev); } - }, + }; - onEventDecrypted(ev, err) { + onEventDecrypted = (ev, err) => { if (ev.getRoomId() !== this.props.roomId) return; const eventId = ev.getId(); @@ -63,7 +63,7 @@ const FilePanel = createReactClass({ if (err) return; this.addEncryptedLiveEvent(ev); - }, + }; addEncryptedLiveEvent(ev, toStartOfTimeline) { if (!this.state.timelineSet) return; @@ -77,7 +77,7 @@ const FilePanel = createReactClass({ if (!this.state.timelineSet.eventIdToTimeline(ev.getId())) { this.state.timelineSet.addEventToTimeline(ev, timeline, false); } - }, + } async componentDidMount() { const client = MatrixClientPeg.get(); @@ -98,7 +98,7 @@ const FilePanel = createReactClass({ client.on('Room.timeline', this.onRoomTimeline); client.on('Event.decrypted', this.onEventDecrypted); } - }, + } componentWillUnmount() { const client = MatrixClientPeg.get(); @@ -110,7 +110,7 @@ const FilePanel = createReactClass({ client.removeListener('Room.timeline', this.onRoomTimeline); client.removeListener('Event.decrypted', this.onEventDecrypted); } - }, + } async fetchFileEventsServer(room) { const client = MatrixClientPeg.get(); @@ -134,9 +134,9 @@ const FilePanel = createReactClass({ const timelineSet = room.getOrCreateFilteredTimelineSet(filter); return timelineSet; - }, + } - onPaginationRequest(timelineWindow, direction, limit) { + onPaginationRequest = (timelineWindow, direction, limit) => { const client = MatrixClientPeg.get(); const eventIndex = EventIndexPeg.get(); const roomId = this.props.roomId; @@ -152,7 +152,7 @@ const FilePanel = createReactClass({ } else { return timelineWindow.paginate(direction, limit); } - }, + }; async updateTimelineSet(roomId: string) { const client = MatrixClientPeg.get(); @@ -188,22 +188,30 @@ const FilePanel = createReactClass({ } else { console.error("Failed to add filtered timelineSet for FilePanel as no room!"); } - }, + } - render: function() { + render() { if (MatrixClientPeg.get().isGuest()) { - return
+ return
{ _t("You must register to use this functionality", {}, { 'a': (sub) => { sub } }) }
-
; + ; } else if (this.noRoom) { - return
+ return
{ _t("You must join the room to see its files") }
-
; + ; } // wrap a TimelinePanel with the jump-to-event bits turned off. @@ -215,12 +223,20 @@ const FilePanel = createReactClass({

{_t('Attach files from chat or just drag and drop them anywhere in a room.')}

); + const isRoomEncrypted = this.noRoom ? false : MatrixClientPeg.get().isRoomEncrypted(this.props.roomId); + if (this.state.timelineSet) { // console.log("rendering TimelinePanel for timelineSet " + this.state.timelineSet.room.roomId + " " + // "(" + this.state.timelineSet._timelines.join(", ") + ")" + " with key " + this.props.roomId); return ( -
- + + -
+ ); } else { return ( -
+ -
+ ); } - }, -}); + } +} export default FilePanel; diff --git a/src/components/structures/TagPanel.js b/src/components/structures/GroupFilterPanel.js similarity index 62% rename from src/components/structures/TagPanel.js rename to src/components/structures/GroupFilterPanel.js index 4f8a051e62..96aa1ba728 100644 --- a/src/components/structures/TagPanel.js +++ b/src/components/structures/GroupFilterPanel.js @@ -16,8 +16,7 @@ limitations under the License. */ import React from 'react'; -import createReactClass from 'create-react-class'; -import TagOrderStore from '../../stores/TagOrderStore'; +import GroupFilterOrderStore from '../../stores/GroupFilterOrderStore'; import GroupActions from '../../actions/GroupActions'; @@ -29,54 +28,50 @@ import { Droppable } from 'react-beautiful-dnd'; import classNames from 'classnames'; import MatrixClientContext from "../../contexts/MatrixClientContext"; import AutoHideScrollbar from "./AutoHideScrollbar"; +import SettingsStore from "../../settings/SettingsStore"; +import UserTagTile from "../views/elements/UserTagTile"; -const TagPanel = createReactClass({ - displayName: 'TagPanel', +class GroupFilterPanel extends React.Component { + static contextType = MatrixClientContext; - statics: { - contextType: MatrixClientContext, - }, + state = { + orderedTags: [], + selectedTags: [], + }; - getInitialState() { - return { - orderedTags: [], - selectedTags: [], - }; - }, - - componentDidMount: function() { + componentDidMount() { this.unmounted = false; this.context.on("Group.myMembership", this._onGroupMyMembership); this.context.on("sync", this._onClientSync); - this._tagOrderStoreToken = TagOrderStore.addListener(() => { + this._groupFilterOrderStoreToken = GroupFilterOrderStore.addListener(() => { if (this.unmounted) { return; } this.setState({ - orderedTags: TagOrderStore.getOrderedTags() || [], - selectedTags: TagOrderStore.getSelectedTags(), + orderedTags: GroupFilterOrderStore.getOrderedTags() || [], + selectedTags: GroupFilterOrderStore.getSelectedTags(), }); }); // This could be done by anything with a matrix client dis.dispatch(GroupActions.fetchJoinedGroups(this.context)); - }, + } componentWillUnmount() { this.unmounted = true; this.context.removeListener("Group.myMembership", this._onGroupMyMembership); this.context.removeListener("sync", this._onClientSync); - if (this._tagOrderStoreToken) { - this._tagOrderStoreToken.remove(); + if (this._groupFilterOrderStoreToken) { + this._groupFilterOrderStoreToken.remove(); } - }, + } - _onGroupMyMembership() { + _onGroupMyMembership = () => { if (this.unmounted) return; dis.dispatch(GroupActions.fetchJoinedGroups(this.context)); - }, + }; - _onClientSync(syncState, prevState) { + _onClientSync = (syncState, prevState) => { // Consider the client reconnected if there is no error with syncing. // This means the state could be RECONNECTING, SYNCING, PREPARED or CATCHUP. const reconnected = syncState !== "ERROR" && prevState !== syncState; @@ -84,29 +79,33 @@ const TagPanel = createReactClass({ // Load joined groups dis.dispatch(GroupActions.fetchJoinedGroups(this.context)); } - }, + }; - onMouseDown(e) { + onMouseDown = e => { // only dispatch if its not a no-op if (this.state.selectedTags.length > 0) { dis.dispatch({action: 'deselect_tags'}); } - }, + }; - onCreateGroupClick(ev) { - ev.stopPropagation(); - dis.dispatch({action: 'view_create_group'}); - }, - - onClearFilterClick(ev) { + onClearFilterClick = ev => { dis.dispatch({action: 'deselect_tags'}); - }, + }; + + renderGlobalIcon() { + if (!SettingsStore.getValue("feature_communities_v2_prototypes")) return null; + + return ( +
+ +
+
+ ); + } render() { const DNDTagTile = sdk.getComponent('elements.DNDTagTile'); - const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); const ActionButton = sdk.getComponent('elements.ActionButton'); - const TintableSvg = sdk.getComponent('elements.TintableSvg'); const tags = this.state.orderedTags.map((tag, index) => { return 0; - - let clearButton; - if (itemsSelected) { - clearButton = - - ; - } - - const classes = classNames('mx_TagPanel', { - mx_TagPanel_items_selected: itemsSelected, + const classes = classNames('mx_GroupFilterPanel', { + mx_GroupFilterPanel_items_selected: itemsSelected, }); - return
-
- { clearButton } -
-
+ let createButton = ( + + ); + + if (SettingsStore.getValue("feature_communities_v2_prototypes")) { + createButton = ( + + ); + } + + return
{ (provided, snapshot) => (
+ { this.renderGlobalIcon() } { tags }
- + {createButton}
{ provided.placeholder }
@@ -167,6 +166,6 @@ const TagPanel = createReactClass({
; - }, -}); -export default TagPanel; + } +} +export default GroupFilterPanel; diff --git a/src/components/structures/GroupView.js b/src/components/structures/GroupView.js index 2e2fa25169..482b9f6da2 100644 --- a/src/components/structures/GroupView.js +++ b/src/components/structures/GroupView.js @@ -17,7 +17,6 @@ limitations under the License. */ import React from 'react'; -import createReactClass from 'create-react-class'; import PropTypes from 'prop-types'; import {MatrixClientPeg} from '../../MatrixClientPeg'; import * as sdk from '../../index'; @@ -70,10 +69,8 @@ const UserSummaryType = PropTypes.shape({ }).isRequired, }); -const CategoryRoomList = createReactClass({ - displayName: 'CategoryRoomList', - - props: { +class CategoryRoomList extends React.Component { + static propTypes = { rooms: PropTypes.arrayOf(RoomSummaryType).isRequired, category: PropTypes.shape({ profile: PropTypes.shape({ @@ -84,9 +81,9 @@ const CategoryRoomList = createReactClass({ // Whether the list should be editable editing: PropTypes.bool.isRequired, - }, + }; - onAddRoomsToSummaryClicked: function(ev) { + onAddRoomsToSummaryClicked = (ev) => { ev.preventDefault(); const AddressPickerDialog = sdk.getComponent("dialogs.AddressPickerDialog"); Modal.createTrackedDialog('Add Rooms to Group Summary', '', AddressPickerDialog, { @@ -122,9 +119,9 @@ const CategoryRoomList = createReactClass({ }); }, }, /*className=*/null, /*isPriority=*/false, /*isStatic=*/true); - }, + }; - render: function() { + render() { const TintableSvg = sdk.getComponent("elements.TintableSvg"); const addButton = this.props.editing ? (; - }, -}); + } +} -const FeaturedRoom = createReactClass({ - displayName: 'FeaturedRoom', - - props: { +class FeaturedRoom extends React.Component { + static propTypes = { summaryInfo: RoomSummaryType.isRequired, editing: PropTypes.bool.isRequired, groupId: PropTypes.string.isRequired, - }, + }; - onClick: function(e) { + onClick = (e) => { e.preventDefault(); e.stopPropagation(); @@ -176,9 +171,9 @@ const FeaturedRoom = createReactClass({ room_alias: this.props.summaryInfo.profile.canonical_alias, room_id: this.props.summaryInfo.room_id, }); - }, + }; - onDeleteClicked: function(e) { + onDeleteClicked = (e) => { e.preventDefault(); e.stopPropagation(); GroupStore.removeRoomFromGroupSummary( @@ -201,9 +196,9 @@ const FeaturedRoom = createReactClass({ description: _t("The room '%(roomName)s' could not be removed from the summary.", {roomName}), }); }); - }, + }; - render: function() { + render() { const RoomAvatar = sdk.getComponent("avatars.RoomAvatar"); const roomName = this.props.summaryInfo.profile.name || @@ -243,13 +238,11 @@ const FeaturedRoom = createReactClass({
{ roomNameNode }
{ deleteButton }
; - }, -}); + } +} -const RoleUserList = createReactClass({ - displayName: 'RoleUserList', - - props: { +class RoleUserList extends React.Component { + static propTypes = { users: PropTypes.arrayOf(UserSummaryType).isRequired, role: PropTypes.shape({ profile: PropTypes.shape({ @@ -260,9 +253,9 @@ const RoleUserList = createReactClass({ // Whether the list should be editable editing: PropTypes.bool.isRequired, - }, + }; - onAddUsersClicked: function(ev) { + onAddUsersClicked = (ev) => { ev.preventDefault(); const AddressPickerDialog = sdk.getComponent("dialogs.AddressPickerDialog"); Modal.createTrackedDialog('Add Users to Group Summary', '', AddressPickerDialog, { @@ -298,9 +291,9 @@ const RoleUserList = createReactClass({ }); }, }, /*className=*/null, /*isPriority=*/false, /*isStatic=*/true); - }, + }; - render: function() { + render() { const TintableSvg = sdk.getComponent("elements.TintableSvg"); const addButton = this.props.editing ? ( @@ -325,19 +318,17 @@ const RoleUserList = createReactClass({ { userNodes } { addButton }
; - }, -}); + } +} -const FeaturedUser = createReactClass({ - displayName: 'FeaturedUser', - - props: { +class FeaturedUser extends React.Component { + static propTypes = { summaryInfo: UserSummaryType.isRequired, editing: PropTypes.bool.isRequired, groupId: PropTypes.string.isRequired, - }, + }; - onClick: function(e) { + onClick = (e) => { e.preventDefault(); e.stopPropagation(); @@ -345,9 +336,9 @@ const FeaturedUser = createReactClass({ action: 'view_start_chat_or_reuse', user_id: this.props.summaryInfo.user_id, }); - }, + }; - onDeleteClicked: function(e) { + onDeleteClicked = (e) => { e.preventDefault(); e.stopPropagation(); GroupStore.removeUserFromGroupSummary( @@ -368,9 +359,9 @@ const FeaturedUser = createReactClass({ description: _t("The user '%(displayName)s' could not be removed from the summary.", {displayName}), }); }); - }, + }; - render: function() { + render() { const BaseAvatar = sdk.getComponent("avatars.BaseAvatar"); const name = this.props.summaryInfo.displayname || this.props.summaryInfo.user_id; @@ -394,41 +385,37 @@ const FeaturedUser = createReactClass({
{ userNameNode }
{ deleteButton } ; - }, -}); + } +} const GROUP_JOINPOLICY_OPEN = "open"; const GROUP_JOINPOLICY_INVITE = "invite"; -export default createReactClass({ - displayName: 'GroupView', - - propTypes: { +export default class GroupView extends React.Component { + static propTypes = { groupId: PropTypes.string.isRequired, // Whether this is the first time the group admin is viewing the group groupIsNew: PropTypes.bool, - }, + }; - getInitialState: function() { - return { - summary: null, - isGroupPublicised: null, - isUserPrivileged: null, - groupRooms: null, - groupRoomsLoading: null, - error: null, - editing: false, - saving: false, - uploadingAvatar: false, - avatarChanged: false, - membershipBusy: false, - publicityBusy: false, - inviterProfile: null, - showRightPanel: RightPanelStore.getSharedInstance().isOpenForGroup, - }; - }, + state = { + summary: null, + isGroupPublicised: null, + isUserPrivileged: null, + groupRooms: null, + groupRoomsLoading: null, + error: null, + editing: false, + saving: false, + uploadingAvatar: false, + avatarChanged: false, + membershipBusy: false, + publicityBusy: false, + inviterProfile: null, + showRightPanel: RightPanelStore.getSharedInstance().isOpenForGroup, + }; - componentDidMount: function() { + componentDidMount() { this._unmounted = false; this._matrixClient = MatrixClientPeg.get(); this._matrixClient.on("Group.myMembership", this._onGroupMyMembership); @@ -437,9 +424,9 @@ export default createReactClass({ this._dispatcherRef = dis.register(this._onAction); this._rightPanelStoreToken = RightPanelStore.getSharedInstance().addListener(this._onRightPanelStoreUpdate); - }, + } - componentWillUnmount: function() { + componentWillUnmount() { this._unmounted = true; this._matrixClient.removeListener("Group.myMembership", this._onGroupMyMembership); dis.unregister(this._dispatcherRef); @@ -448,10 +435,11 @@ export default createReactClass({ if (this._rightPanelStoreToken) { this._rightPanelStoreToken.remove(); } - }, + } // TODO: [REACT-WARNING] Replace with appropriate lifecycle event - UNSAFE_componentWillReceiveProps: function(newProps) { + // eslint-disable-next-line camelcase + UNSAFE_componentWillReceiveProps(newProps) { if (this.props.groupId !== newProps.groupId) { this.setState({ summary: null, @@ -460,24 +448,24 @@ export default createReactClass({ this._initGroupStore(newProps.groupId); }); } - }, + } - _onRightPanelStoreUpdate: function() { + _onRightPanelStoreUpdate = () => { this.setState({ showRightPanel: RightPanelStore.getSharedInstance().isOpenForGroup, }); - }, + }; - _onGroupMyMembership: function(group) { + _onGroupMyMembership = (group) => { if (this._unmounted || group.groupId !== this.props.groupId) return; if (group.myMembership === 'leave') { // Leave settings - the user might have clicked the "Leave" button this._closeSettings(); } this.setState({membershipBusy: false}); - }, + }; - _initGroupStore: function(groupId, firstInit) { + _initGroupStore(groupId, firstInit) { const group = this._matrixClient.getGroup(groupId); if (group && group.inviter && group.inviter.userId) { this._fetchInviterProfile(group.inviter.userId); @@ -506,9 +494,9 @@ export default createReactClass({ }); } }); - }, + } - onGroupStoreUpdated(firstInit) { + onGroupStoreUpdated = (firstInit) => { if (this._unmounted) return; const summary = GroupStore.getSummary(this.props.groupId); if (summary.profile) { @@ -533,7 +521,7 @@ export default createReactClass({ if (this.props.groupIsNew && firstInit) { this._onEditClick(); } - }, + }; _fetchInviterProfile(userId) { this.setState({ @@ -555,9 +543,9 @@ export default createReactClass({ inviterProfileBusy: false, }); }); - }, + } - _onEditClick: function() { + _onEditClick = () => { this.setState({ editing: true, profileForm: Object.assign({}, this.state.summary.profile), @@ -568,20 +556,20 @@ export default createReactClass({ GROUP_JOINPOLICY_INVITE, }, }); - }, + }; - _onShareClick: function() { + _onShareClick = () => { const ShareDialog = sdk.getComponent("dialogs.ShareDialog"); Modal.createTrackedDialog('share community dialog', '', ShareDialog, { target: this._matrixClient.getGroup(this.props.groupId) || new Group(this.props.groupId), }); - }, + }; - _onCancelClick: function() { + _onCancelClick = () => { this._closeSettings(); - }, + }; - _onAction(payload) { + _onAction = (payload) => { switch (payload.action) { // NOTE: close_settings is an app-wide dispatch; as it is dispatched from MatrixChat case 'close_settings': @@ -593,34 +581,34 @@ export default createReactClass({ default: break; } - }, + }; - _closeSettings() { + _closeSettings = () => { dis.dispatch({action: 'close_settings'}); - }, + }; - _onNameChange: function(value) { + _onNameChange = (value) => { const newProfileForm = Object.assign(this.state.profileForm, { name: value }); this.setState({ profileForm: newProfileForm, }); - }, + }; - _onShortDescChange: function(value) { + _onShortDescChange = (value) => { const newProfileForm = Object.assign(this.state.profileForm, { short_description: value }); this.setState({ profileForm: newProfileForm, }); - }, + }; - _onLongDescChange: function(e) { + _onLongDescChange = (e) => { const newProfileForm = Object.assign(this.state.profileForm, { long_description: e.target.value }); this.setState({ profileForm: newProfileForm, }); - }, + }; - _onAvatarSelected: function(ev) { + _onAvatarSelected = ev => { const file = ev.target.files[0]; if (!file) return; @@ -632,7 +620,7 @@ export default createReactClass({ profileForm: newProfileForm, // Indicate that FlairStore needs to be poked to show this change - // in TagTile (TagPanel), Flair and GroupTile (MyGroups). + // in TagTile (GroupFilterPanel), Flair and GroupTile (MyGroups). avatarChanged: true, }); }).catch((e) => { @@ -644,15 +632,15 @@ export default createReactClass({ description: _t('Failed to upload image'), }); }); - }, + }; - _onJoinableChange: function(ev) { + _onJoinableChange = ev => { this.setState({ joinableForm: { policyType: ev.target.value }, }); - }, + }; - _onSaveClick: function() { + _onSaveClick = () => { this.setState({saving: true}); const savePromise = this.state.isUserPrivileged ? this._saveGroup() : Promise.resolve(); savePromise.then((result) => { @@ -661,7 +649,6 @@ export default createReactClass({ editing: false, summary: null, }); - dis.dispatch({action: 'panel_disable'}); this._initGroupStore(this.props.groupId); if (this.state.avatarChanged) { @@ -683,16 +670,16 @@ export default createReactClass({ avatarChanged: false, }); }); - }, + }; - _saveGroup: async function() { + async _saveGroup() { await this._matrixClient.setGroupProfile(this.props.groupId, this.state.profileForm); await this._matrixClient.setGroupJoinPolicy(this.props.groupId, { type: this.state.joinableForm.policyType, }); - }, + } - _onAcceptInviteClick: async function() { + _onAcceptInviteClick = async () => { this.setState({membershipBusy: true}); // Wait 500ms to prevent flashing. Do this before sending a request otherwise we risk the @@ -709,9 +696,9 @@ export default createReactClass({ description: _t("Unable to accept invite"), }); }); - }, + }; - _onRejectInviteClick: async function() { + _onRejectInviteClick = async () => { this.setState({membershipBusy: true}); // Wait 500ms to prevent flashing. Do this before sending a request otherwise we risk the @@ -728,9 +715,9 @@ export default createReactClass({ description: _t("Unable to reject invite"), }); }); - }, + }; - _onJoinClick: async function() { + _onJoinClick = async () => { if (this._matrixClient.isGuest()) { dis.dispatch({action: 'require_registration', screen_after: {screen: `group/${this.props.groupId}`}}); return; @@ -752,9 +739,9 @@ export default createReactClass({ description: _t("Unable to join community"), }); }); - }, + }; - _leaveGroupWarnings: function() { + _leaveGroupWarnings() { const warnings = []; if (this.state.isUserPrivileged) { @@ -768,10 +755,9 @@ export default createReactClass({ } return warnings; - }, + } - - _onLeaveClick: function() { + _onLeaveClick = () => { const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); const warnings = this._leaveGroupWarnings(); @@ -806,13 +792,13 @@ export default createReactClass({ }); }, }); - }, + }; - _onAddRoomsClick: function() { + _onAddRoomsClick = () => { showGroupAddRoomDialog(this.props.groupId); - }, + }; - _getGroupSection: function() { + _getGroupSection() { const groupSettingsSectionClasses = classnames({ "mx_GroupView_group": this.state.editing, "mx_GroupView_group_disabled": this.state.editing && !this.state.isUserPrivileged, @@ -856,9 +842,9 @@ export default createReactClass({ { this._getLongDescriptionNode() } { this._getRoomsNode() }
; - }, + } - _getRoomsNode: function() { + _getRoomsNode() { const RoomDetailList = sdk.getComponent('rooms.RoomDetailList'); const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); const TintableSvg = sdk.getComponent('elements.TintableSvg'); @@ -883,10 +869,7 @@ export default createReactClass({ { _t('Add rooms to this community') }
) :
; - const roomDetailListClassName = classnames({ - "mx_fadable": true, - "mx_fadable_faded": this.state.editing, - }); + return

@@ -897,14 +880,12 @@ export default createReactClass({

{ this.state.groupRoomsLoading ? : - + }
; - }, + } - _getFeaturedRoomsNode: function() { + _getFeaturedRoomsNode() { const summary = this.state.summary; const defaultCategoryRooms = []; @@ -943,9 +924,9 @@ export default createReactClass({ { defaultCategoryNode } { categoryRoomNodes }
; - }, + } - _getFeaturedUsersNode: function() { + _getFeaturedUsersNode() { const summary = this.state.summary; const noRoleUsers = []; @@ -984,9 +965,9 @@ export default createReactClass({ { noRoleNode } { roleUserNodes }
; - }, + } - _getMembershipSection: function() { + _getMembershipSection() { const Spinner = sdk.getComponent("elements.Spinner"); const BaseAvatar = sdk.getComponent("avatars.BaseAvatar"); @@ -1100,9 +1081,9 @@ export default createReactClass({
; - }, + } - _getJoinableNode: function() { + _getJoinableNode() { const InlineSpinner = sdk.getComponent('elements.InlineSpinner'); return this.state.editing ?

@@ -1136,9 +1117,9 @@ export default createReactClass({

: null; - }, + } - _getLongDescriptionNode: function() { + _getLongDescriptionNode() { const summary = this.state.summary; let description = null; if (summary.profile && summary.profile.long_description) { @@ -1175,9 +1156,9 @@ export default createReactClass({
{ description }
; - }, + } - render: function() { + render() { const GroupAvatar = sdk.getComponent("avatars.GroupAvatar"); const Spinner = sdk.getComponent("elements.Spinner"); @@ -1335,7 +1316,7 @@ export default createReactClass({ - + { this._getMembershipSection() } { this._getGroupSection() } @@ -1366,5 +1347,5 @@ export default createReactClass({ console.error("Invalid state for GroupView"); return
; } - }, -}); + } +} diff --git a/src/components/structures/InteractiveAuth.js b/src/components/structures/InteractiveAuth.js index fa7860ccef..c8fcd7e9ca 100644 --- a/src/components/structures/InteractiveAuth.js +++ b/src/components/structures/InteractiveAuth.js @@ -17,7 +17,6 @@ limitations under the License. import {InteractiveAuth} from "matrix-js-sdk"; import React, {createRef} from 'react'; -import createReactClass from 'create-react-class'; import PropTypes from 'prop-types'; import getEntryComponentForLoginType from '../views/auth/InteractiveAuthEntryComponents'; @@ -26,10 +25,8 @@ import * as sdk from '../../index'; export const ERROR_USER_CANCELLED = new Error("User cancelled auth session"); -export default createReactClass({ - displayName: 'InteractiveAuth', - - propTypes: { +export default class InteractiveAuthComponent extends React.Component { + static propTypes = { // matrix client to use for UI auth requests matrixClient: PropTypes.object.isRequired, @@ -86,20 +83,19 @@ export default createReactClass({ // continueText and continueKind are passed straight through to the AuthEntryComponent. continueText: PropTypes.string, continueKind: PropTypes.string, - }, + }; - getInitialState: function() { - return { + constructor(props) { + super(props); + + this.state = { authStage: null, busy: false, errorText: null, stageErrorText: null, submitButtonEnabled: false, }; - }, - // TODO: [REACT-WARNING] Replace component with real class, use constructor for refs - UNSAFE_componentWillMount: function() { this._unmounted = false; this._authLogic = new InteractiveAuth({ authData: this.props.authData, @@ -114,6 +110,18 @@ export default createReactClass({ requestEmailToken: this._requestEmailToken, }); + this._intervalId = null; + if (this.props.poll) { + this._intervalId = setInterval(() => { + this._authLogic.poll(); + }, 2000); + } + + this._stageComponent = createRef(); + } + + // TODO: [REACT-WARNING] Replace component with real class, use constructor for refs + UNSAFE_componentWillMount() { // eslint-disable-line camelcase this._authLogic.attemptAuth().then((result) => { const extra = { emailSid: this._authLogic.getEmailSid(), @@ -132,26 +140,17 @@ export default createReactClass({ errorText: msg, }); }); + } - this._intervalId = null; - if (this.props.poll) { - this._intervalId = setInterval(() => { - this._authLogic.poll(); - }, 2000); - } - - this._stageComponent = createRef(); - }, - - componentWillUnmount: function() { + componentWillUnmount() { this._unmounted = true; if (this._intervalId !== null) { clearInterval(this._intervalId); } - }, + } - _requestEmailToken: async function(...args) { + _requestEmailToken = async (...args) => { this.setState({ busy: true, }); @@ -162,15 +161,15 @@ export default createReactClass({ busy: false, }); } - }, + }; - tryContinue: function() { + tryContinue = () => { if (this._stageComponent.current && this._stageComponent.current.tryContinue) { this._stageComponent.current.tryContinue(); } - }, + }; - _authStateUpdated: function(stageType, stageState) { + _authStateUpdated = (stageType, stageState) => { const oldStage = this.state.authStage; this.setState({ busy: false, @@ -180,16 +179,16 @@ export default createReactClass({ }, () => { if (oldStage != stageType) this._setFocus(); }); - }, + }; - _requestCallback: function(auth) { + _requestCallback = (auth) => { // This wrapper just exists because the js-sdk passes a second // 'busy' param for backwards compat. This throws the tests off // so discard it here. return this.props.makeRequest(auth); - }, + }; - _onBusyChanged: function(busy) { + _onBusyChanged = (busy) => { // if we've started doing stuff, reset the error messages if (busy) { this.setState({ @@ -204,29 +203,29 @@ export default createReactClass({ // there's a new screen to show the user. This is implemented by setting // `busy: false` in `_authStateUpdated`. // See also https://github.com/vector-im/element-web/issues/12546 - }, + }; - _setFocus: function() { + _setFocus() { if (this._stageComponent.current && this._stageComponent.current.focus) { this._stageComponent.current.focus(); } - }, + } - _submitAuthDict: function(authData) { + _submitAuthDict = authData => { this._authLogic.submitAuthDict(authData); - }, + }; - _onPhaseChange: function(newPhase) { + _onPhaseChange = newPhase => { if (this.props.onStagePhaseChange) { this.props.onStagePhaseChange(this.state.authStage, newPhase || 0); } - }, + }; - _onStageCancel: function() { + _onStageCancel = () => { this.props.onAuthFinished(false, ERROR_USER_CANCELLED); - }, + }; - _renderCurrentStage: function() { + _renderCurrentStage() { const stage = this.state.authStage; if (!stage) { if (this.state.busy) { @@ -260,16 +259,17 @@ export default createReactClass({ onCancel={this._onStageCancel} /> ); - }, + } - _onAuthStageFailed: function(e) { + _onAuthStageFailed = e => { this.props.onAuthFinished(false, e); - }, - _setEmailSid: function(sid) { - this._authLogic.setEmailSid(sid); - }, + }; - render: function() { + _setEmailSid = sid => { + this._authLogic.setEmailSid(sid); + }; + + render() { let error = null; if (this.state.errorText) { error = ( @@ -287,5 +287,5 @@ export default createReactClass({
); - }, -}); + } +} diff --git a/src/components/structures/LeftPanel.tsx b/src/components/structures/LeftPanel.tsx index 899dfe222d..262d12a700 100644 --- a/src/components/structures/LeftPanel.tsx +++ b/src/components/structures/LeftPanel.tsx @@ -16,7 +16,7 @@ limitations under the License. import * as React from "react"; import { createRef } from "react"; -import TagPanel from "./TagPanel"; +import GroupFilterPanel from "./GroupFilterPanel"; import CustomRoomTagPanel from "./CustomRoomTagPanel"; import classNames from "classnames"; import dis from "../../dispatcher/dispatcher"; @@ -46,13 +46,13 @@ interface IProps { interface IState { showBreadcrumbs: boolean; - showTagPanel: boolean; + showGroupFilterPanel: boolean; } // List of CSS classes which should be included in keyboard navigation within the room list const cssClasses = [ "mx_RoomSearch_input", - "mx_RoomSearch_icon", // minimized + "mx_RoomSearch_minimizedHandle", // minimized "mx_RoomSublist_headerText", "mx_RoomTile", "mx_RoomSublist_showNButton", @@ -60,7 +60,7 @@ const cssClasses = [ export default class LeftPanel extends React.Component { private listContainerRef: React.RefObject = createRef(); - private tagPanelWatcherRef: string; + private groupFilterPanelWatcherRef: string; private bgImageWatcherRef: string; private focusedElement = null; private isDoingStickyHeaders = false; @@ -70,7 +70,7 @@ export default class LeftPanel extends React.Component { this.state = { showBreadcrumbs: BreadcrumbsStore.instance.visible, - showTagPanel: SettingsStore.getValue('TagPanel.enableTagPanel'), + showGroupFilterPanel: SettingsStore.getValue('TagPanel.enableTagPanel'), }; BreadcrumbsStore.instance.on(UPDATE_EVENT, this.onBreadcrumbsUpdate); @@ -78,8 +78,8 @@ export default class LeftPanel extends React.Component { OwnProfileStore.instance.on(UPDATE_EVENT, this.onBackgroundImageUpdate); this.bgImageWatcherRef = SettingsStore.watchSetting( "RoomList.backgroundImage", null, this.onBackgroundImageUpdate); - this.tagPanelWatcherRef = SettingsStore.watchSetting("TagPanel.enableTagPanel", null, () => { - this.setState({showTagPanel: SettingsStore.getValue("TagPanel.enableTagPanel")}); + this.groupFilterPanelWatcherRef = SettingsStore.watchSetting("TagPanel.enableTagPanel", null, () => { + this.setState({showGroupFilterPanel: SettingsStore.getValue("TagPanel.enableTagPanel")}); }); // We watch the middle panel because we don't actually get resized, the middle panel does. @@ -88,7 +88,7 @@ export default class LeftPanel extends React.Component { } public componentWillUnmount() { - SettingsStore.unwatchSetting(this.tagPanelWatcherRef); + SettingsStore.unwatchSetting(this.groupFilterPanelWatcherRef); SettingsStore.unwatchSetting(this.bgImageWatcherRef); BreadcrumbsStore.instance.off(UPDATE_EVENT, this.onBreadcrumbsUpdate); RoomListStore.instance.off(LISTS_UPDATE_EVENT, this.onBreadcrumbsUpdate); @@ -119,8 +119,11 @@ export default class LeftPanel extends React.Component { if (settingBgMxc) { avatarUrl = MatrixClientPeg.get().mxcUrlToHttp(settingBgMxc, avatarSize, avatarSize); } + const avatarUrlProp = `url(${avatarUrl})`; - if (document.body.style.getPropertyValue("--avatar-url") !== avatarUrlProp) { + if (!avatarUrl) { + document.body.style.removeProperty("--avatar-url"); + } else if (document.body.style.getPropertyValue("--avatar-url") !== avatarUrlProp) { document.body.style.setProperty("--avatar-url", avatarUrlProp); } }; @@ -375,9 +378,9 @@ export default class LeftPanel extends React.Component { } public render(): React.ReactNode { - const tagPanel = !this.state.showTagPanel ? null : ( -
- + const groupFilterPanel = !this.state.showGroupFilterPanel ? null : ( +
+ {SettingsStore.getValue("feature_custom_tags") ? : null}
); @@ -394,7 +397,7 @@ export default class LeftPanel extends React.Component { const containerClasses = classNames({ "mx_LeftPanel": true, - "mx_LeftPanel_hasTagPanel": !!tagPanel, + "mx_LeftPanel_hasGroupFilterPanel": !!groupFilterPanel, "mx_LeftPanel_minimized": this.props.isMinimized, }); @@ -405,7 +408,7 @@ export default class LeftPanel extends React.Component { return (
- {tagPanel} + {groupFilterPanel}
; - }, -}); + } +} diff --git a/src/components/structures/NotificationPanel.js b/src/components/structures/NotificationPanel.js index c1f78cffda..2889afc1fc 100644 --- a/src/components/structures/NotificationPanel.js +++ b/src/components/structures/NotificationPanel.js @@ -17,21 +17,22 @@ limitations under the License. */ import React from 'react'; -import createReactClass from 'create-react-class'; +import PropTypes from "prop-types"; + import { _t } from '../../languageHandler'; import {MatrixClientPeg} from "../../MatrixClientPeg"; import * as sdk from "../../index"; +import BaseCard from "../views/right_panel/BaseCard"; /* * Component which shows the global notification list using a TimelinePanel */ -const NotificationPanel = createReactClass({ - displayName: 'NotificationPanel', +class NotificationPanel extends React.Component { + static propTypes = { + onClose: PropTypes.func.isRequired, + }; - propTypes: { - }, - - render: function() { + render() { // wrap a TimelinePanel with the jump-to-event bits turned off. const TimelinePanel = sdk.getComponent("structures.TimelinePanel"); const Loader = sdk.getComponent("elements.Spinner"); @@ -41,29 +42,28 @@ const NotificationPanel = createReactClass({

{_t('You have no visible notifications in this room.')}

); + let content; const timelineSet = MatrixClientPeg.get().getNotifTimelineSet(); if (timelineSet) { - return ( -
- -
+ content = ( + ); } else { console.error("No notifTimelineSet available!"); - return ( -
- -
- ); + content = ; } - }, -}); + + return + { content } + ; + } +} export default NotificationPanel; diff --git a/src/components/structures/RightPanel.js b/src/components/structures/RightPanel.js index a4e3254e4c..41f4d83743 100644 --- a/src/components/structures/RightPanel.js +++ b/src/components/structures/RightPanel.js @@ -1,9 +1,6 @@ /* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2017 Vector Creations Ltd -Copyright 2017, 2018 New Vector Ltd Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2015 - 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. @@ -20,7 +17,8 @@ limitations under the License. import React from 'react'; import PropTypes from 'prop-types'; -import classNames from 'classnames'; +import {Room} from "matrix-js-sdk/src/models/room"; + import * as sdk from '../../index'; import dis from '../../dispatcher/dispatcher'; import RateLimitedFunc from '../../ratelimitedfunc'; @@ -30,11 +28,14 @@ import {RightPanelPhases, RIGHT_PANEL_PHASES_NO_ARGS} from "../../stores/RightPa import RightPanelStore from "../../stores/RightPanelStore"; 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 defaultDispatcher from "../../dispatcher/dispatcher"; export default class RightPanel extends React.Component { static get propTypes() { return { - roomId: PropTypes.string, // if showing panels for a given room, this is set + room: PropTypes.instanceOf(Room), // if showing panels for a given room, this is set groupId: PropTypes.string, // if showing panels for a given group, this is set user: PropTypes.object, // used if we know the user ahead of opening the panel }; @@ -42,13 +43,13 @@ export default class RightPanel extends React.Component { static contextType = MatrixClientContext; - constructor(props) { - super(props); + constructor(props, context) { + super(props, context); this.state = { + ...RightPanelStore.getSharedInstance().roomPanelPhaseParams, phase: this._getPhaseFromProps(), isUserPrivilegedInGroup: null, member: this._getUserForPanel(), - verificationRequest: RightPanelStore.getSharedInstance().roomPanelPhaseParams.verificationRequest, }; this.onAction = this.onAction.bind(this); this.onRoomStateMember = this.onRoomStateMember.bind(this); @@ -100,10 +101,6 @@ export default class RightPanel extends React.Component { } return RightPanelPhases.RoomMemberInfo; } else { - if (!RIGHT_PANEL_PHASES_NO_ARGS.includes(rps.roomPanelPhase)) { - dis.dispatch({action: Action.SetRightPanelPhase, phase: RightPanelPhases.RoomMemberList}); - return RightPanelPhases.RoomMemberList; - } return rps.roomPanelPhase; } } @@ -161,13 +158,13 @@ export default class RightPanel extends React.Component { } onRoomStateMember(ev, state, member) { - if (member.roomId !== this.props.roomId) { + if (!this.props.room || member.roomId !== this.props.room.roomId) { return; } // redraw the badge on the membership list - if (this.state.phase === RightPanelPhases.RoomMemberList && member.roomId === this.props.roomId) { + if (this.state.phase === RightPanelPhases.RoomMemberList && member.roomId === this.props.room.roomId) { this._delayedUpdate(); - } else if (this.state.phase === RightPanelPhases.RoomMemberInfo && member.roomId === this.props.roomId && + } else if (this.state.phase === RightPanelPhases.RoomMemberInfo && member.roomId === this.props.room.roomId && member.userId === this.state.member.userId) { // refresh the member info (e.g. new power level) this._delayedUpdate(); @@ -184,6 +181,7 @@ export default class RightPanel extends React.Component { event: payload.event, verificationRequest: payload.verificationRequest, verificationRequestPromise: payload.verificationRequestPromise, + widgetId: payload.widgetId, }); } } @@ -200,17 +198,31 @@ export default class RightPanel extends React.Component { dis.dispatch({ action: "view_home_page", }); + } else if (this.state.phase === RightPanelPhases.EncryptionPanel && + this.state.verificationRequest && this.state.verificationRequest.pending + ) { + // When the user clicks close on the encryption panel cancel the pending request first if any + this.state.verificationRequest.cancel(); } else { // Otherwise we have got our user from RoomViewStore which means we're being shown // within a room/group, so go back to the member panel if we were in the encryption panel, // or the member list if we were in the member panel... phew. + const isEncryptionPhase = this.state.phase === RightPanelPhases.EncryptionPanel; dis.dispatch({ action: Action.ViewUser, - member: this.state.phase === RightPanelPhases.EncryptionPanel ? this.state.member : null, + member: isEncryptionPhase ? this.state.member : null, }); } }; + onClose = () => { + // the RightPanelStore has no way of knowing which mode room/group it is in, so we handle closing here + defaultDispatcher.dispatch({ + action: Action.ToggleRightPanel, + type: this.props.groupId ? "group" : "room", + }); + }; + render() { const MemberList = sdk.getComponent('rooms.MemberList'); const UserInfo = sdk.getComponent('right_panel.UserInfo'); @@ -223,36 +235,42 @@ export default class RightPanel extends React.Component { const GroupRoomInfo = sdk.getComponent('groups.GroupRoomInfo'); let panel =
; + const roomId = this.props.room ? this.props.room.roomId : undefined; switch (this.state.phase) { case RightPanelPhases.RoomMemberList: - if (this.props.roomId) { - panel = ; + if (roomId) { + panel = ; } break; + case RightPanelPhases.GroupMemberList: if (this.props.groupId) { panel = ; } break; + case RightPanelPhases.GroupRoomList: panel = ; break; + case RightPanelPhases.RoomMemberInfo: case RightPanelPhases.EncryptionPanel: panel = ; break; + case RightPanelPhases.Room3pidMemberInfo: - panel = ; + panel = ; break; + case RightPanelPhases.GroupMemberInfo: panel = ; break; + case RightPanelPhases.GroupRoomInfo: panel = ; break; + case RightPanelPhases.NotificationPanel: - panel = ; + panel = ; break; + case RightPanelPhases.FilePanel: - panel = ; + panel = ; + break; + + case RightPanelPhases.RoomSummary: + panel = ; + break; + + case RightPanelPhases.Widget: + panel = ; break; } - const classes = classNames("mx_RightPanel", "mx_fadable", { - "collapsed": this.props.collapsed, - "mx_fadable_faded": this.props.disabled, - "dark-panel": true, - }); - return ( -
; - }, + } + + _getCallStatusText() { + switch (this.props.callState) { + case CallState.CreateOffer: + case CallState.InviteSent: + return _t('Calling...'); + case CallState.Connecting: + case CallState.CreateAnswer: + return _t('Call connecting...'); + case CallState.Connected: + return _t('Active call'); + case CallState.WaitLocalMedia: + if (this.props.callType === CallType.Video) { + return _t('Starting camera...'); + } else { + return _t('Starting microphone...'); + } + } + } // return suitable content for the main (text) part of the status bar. - _getContent: function() { + _getContent() { if (this._shouldShowConnectionError()) { return (
@@ -296,10 +317,10 @@ export default createReactClass({ return this._getUnsentMessageContent(); } - if (this.props.hasActiveCall) { + if (this._showCallBar()) { return (
- { _t('Active call') } + { this._getCallStatusText() }
); } @@ -323,9 +344,9 @@ export default createReactClass({ } return null; - }, + } - render: function() { + render() { const content = this._getContent(); const indicator = this._getIndicator(); @@ -339,5 +360,5 @@ export default createReactClass({
); - }, -}); + } +} diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.tsx similarity index 71% rename from src/components/structures/RoomView.js rename to src/components/structures/RoomView.tsx index 53a964fbb8..709864bff6 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.tsx @@ -21,28 +21,27 @@ limitations under the License. // - Search results component // - Drag and drop -import shouldHideEvent from '../../shouldHideEvent'; - import React, {createRef} from 'react'; -import createReactClass from 'create-react-class'; -import PropTypes from 'prop-types'; import classNames from 'classnames'; -import { _t } from '../../languageHandler'; -import {RoomPermalinkCreator} from '../../utils/permalinks/Permalinks'; +import {Room} from "matrix-js-sdk/src/models/room"; +import {MatrixEvent} from "matrix-js-sdk/src/models/event"; +import {EventSubscription} from "fbemitter"; +import shouldHideEvent from '../../shouldHideEvent'; +import {_t} from '../../languageHandler'; +import {RoomPermalinkCreator} from '../../utils/permalinks/Permalinks'; +import ResizeNotifier from '../../utils/ResizeNotifier'; import ContentMessages from '../../ContentMessages'; import Modal from '../../Modal'; import * as sdk from '../../index'; import CallHandler from '../../CallHandler'; import dis from '../../dispatcher/dispatcher'; import Tinter from '../../Tinter'; -import rate_limited_func from '../../ratelimitedfunc'; +import rateLimitedFunc from '../../ratelimitedfunc'; import * as ObjectUtils from '../../ObjectUtils'; import * as Rooms from '../../Rooms'; import eventSearch, {searchPagination} from '../../Searching'; - import {isOnlyCtrlOrCmdIgnoreShiftKeyEvent, isOnlyCtrlOrCmdKeyEvent, Key} from '../../Keyboard'; - import MainSplit from './MainSplit'; import RightPanel from './RightPanel'; import RoomViewStore from '../../stores/RoomViewStore'; @@ -54,13 +53,29 @@ import RightPanelStore from "../../stores/RightPanelStore"; import {haveTileForEvent} from "../views/rooms/EventTile"; import RoomContext from "../../contexts/RoomContext"; import MatrixClientContext from "../../contexts/MatrixClientContext"; -import { shieldStatusForRoom } from '../../utils/ShieldUtils'; +import {E2EStatus, shieldStatusForRoom} from '../../utils/ShieldUtils'; import {Action} from "../../dispatcher/actions"; import {SettingLevel} from "../../settings/SettingLevel"; import {animateConfetti, forceStopConfetti, isConfettiEmoji} from "../views/elements/Confetti"; +import {RightPanelPhases} from "../../stores/RightPanelStorePhases"; +import {IMatrixClientCreds} from "../../MatrixClientPeg"; +import ScrollPanel from "./ScrollPanel"; +import TimelinePanel from "./TimelinePanel"; +import ErrorBoundary from "../views/elements/ErrorBoundary"; +import RoomPreviewBar from "../views/rooms/RoomPreviewBar"; +import ForwardMessage from "../views/rooms/ForwardMessage"; +import SearchBar from "../views/rooms/SearchBar"; +import RoomUpgradeWarningBar from "../views/rooms/RoomUpgradeWarningBar"; +import PinnedEventsPanel from "../views/rooms/PinnedEventsPanel"; +import AuxPanel from "../views/rooms/AuxPanel"; +import RoomHeader from "../views/rooms/RoomHeader"; +import TintableSvg from "../views/elements/TintableSvg"; +import {XOR} from "../../@types/common"; +import { IThreepidInvite } from "../../stores/ThreepidInviteStore"; +import { CallState, CallType, MatrixCall } from "matrix-js-sdk/lib/webrtc/call"; const DEBUG = false; -let debuglog = function() {}; +let debuglog = function(msg: string) {}; const BROWSER_SUPPORTS_SANDBOX = 'sandbox' in document.createElement('iframe'); @@ -69,64 +84,132 @@ if (DEBUG) { debuglog = console.log.bind(console); } -export default createReactClass({ - displayName: 'RoomView', - propTypes: { - ConferenceHandler: PropTypes.any, +interface IProps { + threepidInvite: IThreepidInvite, - // Called with the credentials of a registered user (if they were a ROU that - // transitioned to PWLU) - onRegistered: PropTypes.func, + // Any data about the room that would normally come from the homeserver + // but has been passed out-of-band, eg. the room name and avatar URL + // from an email invite (a workaround for the fact that we can't + // get this information from the HS using an email invite). + // Fields: + // * name (string) The room's name + // * avatarUrl (string) The mxc:// avatar URL for the room + // * inviterName (string) The display name of the person who + // * invited us to the room + oobData?: { + name?: string; + avatarUrl?: string; + inviterName?: string; + }; - // An object representing a third party invite to join this room - // Fields: - // * inviteSignUrl (string) The URL used to join this room from an email invite - // (given as part of the link in the invite email) - // * invitedEmail (string) The email address that was invited to this room - thirdPartyInvite: PropTypes.object, + // Servers the RoomView can use to try and assist joins + viaServers?: string[]; - // Any data about the room that would normally come from the homeserver - // but has been passed out-of-band, eg. the room name and avatar URL - // from an email invite (a workaround for the fact that we can't - // get this information from the HS using an email invite). - // Fields: - // * name (string) The room's name - // * avatarUrl (string) The mxc:// avatar URL for the room - // * inviterName (string) The display name of the person who - // * invited us to the room - oobData: PropTypes.object, + autoJoin?: boolean; + resizeNotifier: ResizeNotifier; - // Servers the RoomView can use to try and assist joins - viaServers: PropTypes.arrayOf(PropTypes.string), - }, + // Called with the credentials of a registered user (if they were a ROU that transitioned to PWLU) + onRegistered?(credentials: IMatrixClientCreds): void; +} - statics: { - contextType: MatrixClientContext, - }, +export interface IState { + room?: Room; + roomId?: string; + roomAlias?: string; + roomLoading: boolean; + peekLoading: boolean; + shouldPeek: boolean; + // used to trigger a rerender in TimelinePanel once the members are loaded, + // so RR are rendered again (now with the members available), ... + membersLoaded: boolean; + // The event to be scrolled to initially + initialEventId?: string; + // The offset in pixels from the event with which to scroll vertically + initialEventPixelOffset?: number; + // Whether to highlight the event scrolled to + isInitialEventHighlighted?: boolean; + forwardingEvent?: MatrixEvent; + numUnreadMessages: number; + draggingFile: boolean; + searching: boolean; + searchTerm?: string; + searchScope?: "All" | "Room"; + searchResults?: XOR<{}, { + count: number; + highlights: string[]; + results: MatrixEvent[]; + next_batch: string; // eslint-disable-line camelcase + }>; + searchHighlights?: string[]; + searchInProgress?: boolean; + callState?: CallState; + guestsCanJoin: boolean; + canPeek: boolean; + showApps: boolean; + isAlone: boolean; + isPeeking: boolean; + showingPinned: boolean; + showReadReceipts: boolean; + showRightPanel: boolean; + // error object, as from the matrix client/server API + // If we failed to load information about the room, + // store the error here. + roomLoadError?: Error; + // Have we sent a request to join the room that we're waiting to complete? + joining: boolean; + // this is true if we are fully scrolled-down, and are looking at + // the end of the live timeline. It has the effect of hiding the + // 'scroll to bottom' knob, among a couple of other things. + atEndOfLiveTimeline: boolean; + // used by componentDidUpdate to avoid unnecessary checks + atEndOfLiveTimelineInit: boolean; + showTopUnreadMessagesBar: boolean; + auxPanelMaxHeight?: number; + statusBarVisible: boolean; + // We load this later by asking the js-sdk to suggest a version for us. + // This object is the result of Room#getRecommendedVersion() + upgradeRecommendation?: { + version: string; + needsUpgrade: boolean; + urgent: boolean; + }; + canReact: boolean; + canReply: boolean; + useIRCLayout: boolean; + matrixClientIsReady: boolean; + showUrlPreview?: boolean; + e2eStatus?: E2EStatus; + rejecting?: boolean; + rejectError?: Error; +} + +export default class RoomView extends React.Component { + private readonly dispatcherRef: string; + private readonly roomStoreToken: EventSubscription; + private readonly rightPanelStoreToken: EventSubscription; + private readonly showReadReceiptsWatchRef: string; + private readonly layoutWatcherRef: string; + + private unmounted = false; + private permalinkCreators: Record = {}; + private searchId: number; + + private roomView = createRef(); + private searchResultsPanel = createRef(); + private messagePanel: TimelinePanel; + + static contextType = MatrixClientContext; + + constructor(props, context) { + super(props, context); - getInitialState: function() { const llMembers = this.context.hasLazyLoadMembersEnabled(); - return { - room: null, + this.state = { roomId: null, roomLoading: true, peekLoading: false, shouldPeek: true, - - // Media limits for uploading. - mediaConfig: undefined, - - // used to trigger a rerender in TimelinePanel once the members are loaded, - // so RR are rendered again (now with the members available), ... membersLoaded: !llMembers, - // The event to be scrolled to initially - initialEventId: null, - // The offset in pixels from the event with which to scroll vertically - initialEventPixelOffset: null, - // Whether to highlight the event scrolled to - isInitialEventHighlighted: null, - - forwardingEvent: null, numUnreadMessages: 0, draggingFile: false, searching: false, @@ -140,42 +223,17 @@ export default createReactClass({ showingPinned: false, showReadReceipts: true, showRightPanel: RightPanelStore.getSharedInstance().isOpenForRoom, - - // error object, as from the matrix client/server API - // If we failed to load information about the room, - // store the error here. - roomLoadError: null, - - // Have we sent a request to join the room that we're waiting to complete? joining: false, - - // this is true if we are fully scrolled-down, and are looking at - // the end of the live timeline. It has the effect of hiding the - // 'scroll to bottom' knob, among a couple of other things. atEndOfLiveTimeline: true, - atEndOfLiveTimelineInit: false, // used by componentDidUpdate to avoid unnecessary checks - + atEndOfLiveTimelineInit: false, showTopUnreadMessagesBar: false, - - auxPanelMaxHeight: undefined, - statusBarVisible: false, - - // We load this later by asking the js-sdk to suggest a version for us. - // This object is the result of Room#getRecommendedVersion() - upgradeRecommendation: null, - canReact: false, canReply: false, - useIRCLayout: SettingsStore.getValue("useIRCLayout"), - matrixClientIsReady: this.context && this.context.isInitialSyncComplete(), }; - }, - // TODO: [REACT-WARNING] Replace component with real class, use constructor for refs - UNSAFE_componentWillMount: function() { this.dispatcherRef = dis.register(this.onAction); this.context.on("Room", this.onRoom); this.context.on("Room.timeline", this.onRoomTimeline); @@ -189,29 +247,31 @@ export default createReactClass({ this.context.on("deviceVerificationChanged", this.onDeviceVerificationChanged); this.context.on("userTrustStatusChanged", this.onUserVerificationChanged); this.context.on("crossSigning.keysChanged", this.onCrossSigningKeysChanged); - this.context.on("Event.decrypted", this.onEventDecrypted); + this.context.on("Event.decrypted", this.onEventDecrypted); + this.context.on("event", this.onEvent); // Start listening for RoomViewStore updates - this._roomStoreToken = RoomViewStore.addListener(this._onRoomViewStoreUpdate); - this._rightPanelStoreToken = RightPanelStore.getSharedInstance().addListener(this._onRightPanelStoreUpdate); - this._onRoomViewStoreUpdate(true); + this.roomStoreToken = RoomViewStore.addListener(this.onRoomViewStoreUpdate); + this.rightPanelStoreToken = RightPanelStore.getSharedInstance().addListener(this.onRightPanelStoreUpdate); - WidgetEchoStore.on('update', this._onWidgetEchoStoreUpdate); - this._showReadReceiptsWatchRef = SettingsStore.watchSetting("showReadReceipts", null, - this._onReadReceiptsChange); + WidgetEchoStore.on('update', this.onWidgetEchoStoreUpdate); + this.showReadReceiptsWatchRef = SettingsStore.watchSetting("showReadReceipts", null, + this.onReadReceiptsChange); + this.layoutWatcherRef = SettingsStore.watchSetting("useIRCLayout", null, this.onLayoutChange); + } - this._roomView = createRef(); - this._searchResultsPanel = createRef(); + // TODO: [REACT-WARNING] Move into constructor + // eslint-disable-next-line camelcase + UNSAFE_componentWillMount() { + this.onRoomViewStoreUpdate(true); + } - this._layoutWatcherRef = SettingsStore.watchSetting("useIRCLayout", null, this.onLayoutChange); - }, - - _onReadReceiptsChange: function() { + private onReadReceiptsChange = () => { this.setState({ showReadReceipts: SettingsStore.getValue("showReadReceipts", this.state.roomId), }); - }, + }; - _onRoomViewStoreUpdate: function(initial) { + private onRoomViewStoreUpdate = (initial?: boolean) => { if (this.unmounted) { return; } @@ -233,7 +293,7 @@ export default createReactClass({ const roomId = RoomViewStore.getRoomId(); - const newState = { + const newState: Pick = { roomId, roomAlias: RoomViewStore.getRoomAlias(), roomLoading: RoomViewStore.isRoomLoading(), @@ -269,8 +329,8 @@ export default createReactClass({ if (initial) { newState.room = this.context.getRoom(newState.roomId); if (newState.room) { - newState.showApps = this._shouldShowApps(newState.room); - this._onRoomLoaded(newState.room); + newState.showApps = this.shouldShowApps(newState.room); + this.onRoomLoaded(newState.room); } } @@ -303,48 +363,47 @@ export default createReactClass({ // callback because this would prevent the setStates from being batched, // ie. cause it to render RoomView twice rather than the once that is necessary. if (initial) { - this._setupRoom(newState.room, newState.roomId, newState.joining, newState.shouldPeek); + this.setupRoom(newState.room, newState.roomId, newState.joining, newState.shouldPeek); } - }, + }; - _getRoomId() { - // According to `_onRoomViewStoreUpdate`, `state.roomId` can be null + private getRoomId = () => { + // According to `onRoomViewStoreUpdate`, `state.roomId` can be null // if we have a room alias we haven't resolved yet. To work around this, // first we'll try the room object if it's there, and then fallback to // the bare room ID. (We may want to update `state.roomId` after // resolving aliases, so we could always trust it.) return this.state.room ? this.state.room.roomId : this.state.roomId; - }, + }; - _getPermalinkCreatorForRoom: function(room) { - if (!this._permalinkCreators) this._permalinkCreators = {}; - if (this._permalinkCreators[room.roomId]) return this._permalinkCreators[room.roomId]; + private getPermalinkCreatorForRoom(room: Room) { + if (this.permalinkCreators[room.roomId]) return this.permalinkCreators[room.roomId]; - this._permalinkCreators[room.roomId] = new RoomPermalinkCreator(room); + this.permalinkCreators[room.roomId] = new RoomPermalinkCreator(room); if (this.state.room && room.roomId === this.state.room.roomId) { // We want to watch for changes in the creator for the primary room in the view, but // don't need to do so for search results. - this._permalinkCreators[room.roomId].start(); + this.permalinkCreators[room.roomId].start(); } else { - this._permalinkCreators[room.roomId].load(); + this.permalinkCreators[room.roomId].load(); } - return this._permalinkCreators[room.roomId]; - }, + return this.permalinkCreators[room.roomId]; + } - _stopAllPermalinkCreators: function() { - if (!this._permalinkCreators) return; - for (const roomId of Object.keys(this._permalinkCreators)) { - this._permalinkCreators[roomId].stop(); + private stopAllPermalinkCreators() { + if (!this.permalinkCreators) return; + for (const roomId of Object.keys(this.permalinkCreators)) { + this.permalinkCreators[roomId].stop(); } - }, + } - _onWidgetEchoStoreUpdate: function() { + private onWidgetEchoStoreUpdate = () => { this.setState({ - showApps: this._shouldShowApps(this.state.room), + showApps: this.shouldShowApps(this.state.room), }); - }, + }; - _setupRoom: function(room, roomId, joining, shouldPeek) { + private setupRoom(room: Room, roomId: string, joining: boolean, shouldPeek: boolean) { // if this is an unknown room then we're in one of three states: // - This is a room we can peek into (search engine) (we can /peek) // - This is a room we can publicly join or were invited to. (we can /join) @@ -377,7 +436,7 @@ export default createReactClass({ room: room, peekLoading: false, }); - this._onRoomLoaded(room); + this.onRoomLoaded(room); }).catch((err) => { if (this.unmounted) { return; @@ -406,9 +465,9 @@ export default createReactClass({ this.setState({isPeeking: false}); } } - }, + } - _shouldShowApps: function(room) { + private shouldShowApps(room: Room) { if (!BROWSER_SUPPORTS_SANDBOX) return false; // Check if user has previously chosen to hide the app drawer for this @@ -419,17 +478,15 @@ export default createReactClass({ // This is confusing, but it means to say that we default to the tray being // hidden unless the user clicked to open it. return hideWidgetDrawer === "false"; - }, + } - componentDidMount: function() { - const call = this._getCallForRoom(); - const callState = call ? call.call_state : "ended"; + componentDidMount() { + const call = this.getCallForRoom(); + const callState = call ? call.state : null; this.setState({ callState: callState, }); - this._updateConfCallNotification(); - window.addEventListener('beforeunload', this.onPageUnload); if (this.props.resizeNotifier) { this.props.resizeNotifier.on("middlePanelResized", this.onResize); @@ -437,16 +494,16 @@ export default createReactClass({ this.onResize(); document.addEventListener("keydown", this.onNativeKeyDown); - }, + } - shouldComponentUpdate: function(nextProps, nextState) { + shouldComponentUpdate(nextProps, nextState) { return (!ObjectUtils.shallowEqual(this.props, nextProps) || !ObjectUtils.shallowEqual(this.state, nextState)); - }, + } - componentDidUpdate: function() { - if (this._roomView.current) { - const roomView = this._roomView.current; + componentDidUpdate() { + if (this.roomView.current) { + const roomView = this.roomView.current; if (!roomView.ondrop) { roomView.addEventListener('drop', this.onDrop); roomView.addEventListener('dragover', this.onDragOver); @@ -460,15 +517,15 @@ export default createReactClass({ // in render() prevents the ref from being set on first mount, so we try and // catch the messagePanel when it does mount. Because we only want the ref once, // we use a boolean flag to avoid duplicate work. - if (this._messagePanel && !this.state.atEndOfLiveTimelineInit) { + if (this.messagePanel && !this.state.atEndOfLiveTimelineInit) { this.setState({ atEndOfLiveTimelineInit: true, - atEndOfLiveTimeline: this._messagePanel.isAtEndOfLiveTimeline(), + atEndOfLiveTimeline: this.messagePanel.isAtEndOfLiveTimeline(), }); } - }, + } - componentWillUnmount: function() { + componentWillUnmount() { // set a boolean to say we've been unmounted, which any pending // promises can use to throw away their results. // @@ -477,7 +534,7 @@ export default createReactClass({ // update the scroll map before we get unmounted if (this.state.roomId) { - RoomScrollStateStore.setScrollState(this.state.roomId, this._getScrollState()); + RoomScrollStateStore.setScrollState(this.state.roomId, this.getScrollState()); } if (this.state.shouldPeek) { @@ -485,14 +542,14 @@ export default createReactClass({ } // stop tracking room changes to format permalinks - this._stopAllPermalinkCreators(); + this.stopAllPermalinkCreators(); - if (this._roomView.current) { + if (this.roomView.current) { // disconnect the D&D event listeners from the room view. This // is really just for hygiene - we're going to be // deleted anyway, so it doesn't matter if the event listeners // don't get cleaned up. - const roomView = this._roomView.current; + const roomView = this.roomView.current; roomView.removeEventListener('drop', this.onDrop); roomView.removeEventListener('dragover', this.onDragOver); roomView.removeEventListener('dragleave', this.onDragLeaveOrEnd); @@ -513,6 +570,7 @@ export default createReactClass({ this.context.removeListener("userTrustStatusChanged", this.onUserVerificationChanged); this.context.removeListener("crossSigning.keysChanged", this.onCrossSigningKeysChanged); this.context.removeListener("Event.decrypted", this.onEventDecrypted); + this.context.removeListener("event", this.onEvent); } window.removeEventListener('beforeunload', this.onPageUnload); @@ -523,55 +581,54 @@ export default createReactClass({ document.removeEventListener("keydown", this.onNativeKeyDown); // Remove RoomStore listener - if (this._roomStoreToken) { - this._roomStoreToken.remove(); + if (this.roomStoreToken) { + this.roomStoreToken.remove(); } // Remove RightPanelStore listener - if (this._rightPanelStoreToken) { - this._rightPanelStoreToken.remove(); + if (this.rightPanelStoreToken) { + this.rightPanelStoreToken.remove(); } - WidgetEchoStore.removeListener('update', this._onWidgetEchoStoreUpdate); + WidgetEchoStore.removeListener('update', this.onWidgetEchoStoreUpdate); - if (this._showReadReceiptsWatchRef) { - SettingsStore.unwatchSetting(this._showReadReceiptsWatchRef); - this._showReadReceiptsWatchRef = null; + if (this.showReadReceiptsWatchRef) { + SettingsStore.unwatchSetting(this.showReadReceiptsWatchRef); } // cancel any pending calls to the rate_limited_funcs - this._updateRoomMembers.cancelPendingCall(); + this.updateRoomMembers.cancelPendingCall(); // no need to do this as Dir & Settings are now overlays. It just burnt CPU. // console.log("Tinter.tint from RoomView.unmount"); // Tinter.tint(); // reset colourscheme - SettingsStore.unwatchSetting(this._layoutWatcherRef); - }, + SettingsStore.unwatchSetting(this.layoutWatcherRef); + } - onLayoutChange: function() { + private onLayoutChange = () => { this.setState({ useIRCLayout: SettingsStore.getValue("useIRCLayout"), }); - }, + }; - _onRightPanelStoreUpdate: function() { + private onRightPanelStoreUpdate = () => { this.setState({ showRightPanel: RightPanelStore.getSharedInstance().isOpenForRoom, }); - }, + }; - onPageUnload(event) { + private onPageUnload = event => { if (ContentMessages.sharedInstance().getCurrentUploads().length > 0) { return event.returnValue = _t("You seem to be uploading files, are you sure you want to quit?"); - } else if (this._getCallForRoom() && this.state.callState !== 'ended') { + } else if (this.getCallForRoom() && this.state.callState !== 'ended') { return event.returnValue = _t("You seem to be in a call, are you sure you want to quit?"); } - }, + }; // we register global shortcuts here, they *must not conflict* with local shortcuts elsewhere or both will fire - onNativeKeyDown: function(ev) { + private onNativeKeyDown = ev => { let handled = false; const ctrlCmdOnly = isOnlyCtrlOrCmdKeyEvent(ev); @@ -595,15 +652,15 @@ export default createReactClass({ ev.stopPropagation(); ev.preventDefault(); } - }, + }; - onReactKeyDown: function(ev) { + 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.messagePanel.forgetReadMarker(); this.jumpToLiveTimeline(); handled = true; } @@ -614,9 +671,10 @@ export default createReactClass({ handled = true; } break; + case Key.U: // Mac returns lowercase case Key.U.toUpperCase(): if (isOnlyCtrlOrCmdIgnoreShiftKeyEvent(ev) && ev.shiftKey) { - dis.dispatch({ action: "upload_file" }) + dis.dispatch({ action: "upload_file" }, true); handled = true; } break; @@ -626,25 +684,26 @@ export default createReactClass({ ev.stopPropagation(); ev.preventDefault(); } + }; - }, - onAction: function(payload) { + private onAction = payload => { switch (payload.action) { case 'message_send_failed': case 'message_sent': - this._checkIfAlone(this.state.room); + this.checkIfAlone(this.state.room); break; case 'confetti': animateConfetti(this._roomView.current.offsetWidth); break; case 'post_sticker_message': - this.injectSticker( - payload.data.content.url, - payload.data.content.info, - payload.data.description || payload.data.name); - break; + this.injectSticker( + payload.data.content.url, + payload.data.content.info, + payload.data.description || payload.data.name); + break; case 'picture_snapshot': - ContentMessages.sharedInstance().sendContentListToRoom([payload.file], this.state.room.roomId, this.context); + ContentMessages.sharedInstance().sendContentListToRoom( + [payload.file], this.state.room.roomId, this.context); break; case 'notifier_enabled': case 'upload_started': @@ -652,7 +711,7 @@ export default createReactClass({ case 'upload_canceled': this.forceUpdate(); break; - case 'call_state': + case 'call_state': { // don't filter out payloads for room IDs other than props.room because // we may be interested in the conf 1:1 room @@ -660,24 +719,13 @@ export default createReactClass({ return; } - var call = this._getCallForRoom(); - var callState; - - if (call) { - callState = call.call_state; - } else { - callState = "ended"; - } - - // possibly remove the conf call notification if we're now in - // the conf - this._updateConfCallNotification(); + const call = this.getCallForRoom(); this.setState({ - callState: callState, + callState: call ? call.state : null, }); - break; + } case 'appsDrawer': this.setState({ showApps: payload.show, @@ -710,14 +758,14 @@ export default createReactClass({ matrixClientIsReady: this.context && this.context.isInitialSyncComplete(), }, () => { // send another "initial" RVS update to trigger peeking if needed - this._onRoomViewStoreUpdate(true); + this.onRoomViewStoreUpdate(true); }); } break; } - }, + }; - onRoomTimeline: function(ev, room, toStartOfTimeline, removed, data) { + private onRoomTimeline = (ev: MatrixEvent, room: Room, toStartOfTimeline: boolean, removed, data) => { if (this.unmounted) return; // ignore events for other rooms @@ -728,11 +776,11 @@ export default createReactClass({ if (data.timeline.getTimelineSet() !== room.getUnfilteredTimelineSet()) return; if (ev.getType() === "org.matrix.room.preview_urls") { - this._updatePreviewUrlVisibility(room); + this.updatePreviewUrlVisibility(room); } if (ev.getType() === "m.room.encryption") { - this._updateE2EStatus(room); + this.updateE2EStatus(room); } // ignore anything but real-time updates at the end of the room: @@ -753,67 +801,68 @@ export default createReactClass({ }); } } - }, - onEventDecrypted: (ev) => { + }; + + private onEventDecrypted = (ev) => { if (!SettingsStore.getValue('dontShowChatEffects')) { if (ev.isBeingDecrypted() || ev.isDecryptionFailure() || this.state.room.getUnreadNotificationCount() === 0) return; this.handleConfetti(ev); } - }, - handleConfetti: (ev) => { + }; + + private onEvent = (ev) => { + if (ev.isBeingDecrypted() || ev.isDecryptionFailure()) return; + this.handleConfetti(ev); + }; + + private handleConfetti = (ev) => { if (this.state.matrixClientIsReady) { const messageBody = _t('sends confetti'); if (isConfettiEmoji(ev.getContent()) || ev.getContent().body === messageBody) { dis.dispatch({action: 'confetti'}); } } - }, + }; - onRoomName: function(room) { + private onRoomName = (room: Room) => { if (this.state.room && room.roomId == this.state.room.roomId) { this.forceUpdate(); } - }, + }; - onRoomRecoveryReminderDontAskAgain: function() { - // Called when the option to not ask again is set: - // force an update to hide the recovery reminder - this.forceUpdate(); - }, - - onKeyBackupStatus() { + private onKeyBackupStatus = () => { // Key backup status changes affect whether the in-room recovery // reminder is displayed. this.forceUpdate(); - }, + }; - canResetTimeline: function() { - if (!this._messagePanel) { + public canResetTimeline = () => { + if (!this.messagePanel) { return true; } - return this._messagePanel.canResetTimeline(); - }, + return this.messagePanel.canResetTimeline(); + }; // called when state.room is first initialised (either at initial load, // after a successful peek, or after we join the room). - _onRoomLoaded: function(room) { - this._calculatePeekRules(room); - this._updatePreviewUrlVisibility(room); - this._loadMembersIfJoined(room); - this._calculateRecommendedVersion(room); - this._updateE2EStatus(room); - this._updatePermissions(room); + private onRoomLoaded = (room: Room) => { + this.calculatePeekRules(room); + this.updatePreviewUrlVisibility(room); + this.loadMembersIfJoined(room); + this.calculateRecommendedVersion(room); + this.updateE2EStatus(room); + this.updatePermissions(room); forceStopConfetti(); - }, + }; - _calculateRecommendedVersion: async function(room) { + private async calculateRecommendedVersion(room: Room) { this.setState({ upgradeRecommendation: await room.getRecommendedVersion(), }); - }, + } - _loadMembersIfJoined: async function(room) { + private async loadMembersIfJoined(room: Room) { // lazy load members if enabled if (this.context.hasLazyLoadMembersEnabled()) { if (room && room.getMyMembership() === 'join') { @@ -830,9 +879,9 @@ export default createReactClass({ } } } - }, + } - _calculatePeekRules: function(room) { + private calculatePeekRules(room: Room) { const guestAccessEvent = room.currentState.getStateEvents("m.room.guest_access", ""); if (guestAccessEvent && guestAccessEvent.getContent().guest_access === "can_join") { this.setState({ @@ -846,51 +895,51 @@ export default createReactClass({ canPeek: true, }); } - }, + } - _updatePreviewUrlVisibility: function({roomId}) { + private updatePreviewUrlVisibility({roomId}: Room) { // URL Previews in E2EE rooms can be a privacy leak so use a different setting which is per-room explicit const key = this.context.isRoomEncrypted(roomId) ? 'urlPreviewsEnabled_e2ee' : 'urlPreviewsEnabled'; this.setState({ showUrlPreview: SettingsStore.getValue(key, roomId), }); - }, + } - onRoom: function(room) { + private onRoom = (room: Room) => { if (!room || room.roomId !== this.state.roomId) { return; } this.setState({ room: room, }, () => { - this._onRoomLoaded(room); + this.onRoomLoaded(room); }); - }, + }; - onDeviceVerificationChanged: function(userId, device) { + private onDeviceVerificationChanged = (userId: string, device: object) => { const room = this.state.room; if (!room.currentState.getMember(userId)) { return; } - this._updateE2EStatus(room); - }, + this.updateE2EStatus(room); + }; - onUserVerificationChanged: function(userId, _trustStatus) { + private onUserVerificationChanged = (userId: string, trustStatus: object) => { const room = this.state.room; if (!room || !room.currentState.getMember(userId)) { return; } - this._updateE2EStatus(room); - }, + this.updateE2EStatus(room); + }; - onCrossSigningKeysChanged: function() { + private onCrossSigningKeysChanged = () => { const room = this.state.room; if (room) { - this._updateE2EStatus(room); + this.updateE2EStatus(room); } - }, + }; - _updateE2EStatus: async function(room) { + private async updateE2EStatus(room: Room) { if (!this.context.isRoomEncrypted(room.roomId)) { return; } @@ -899,7 +948,7 @@ export default createReactClass({ // so we don't know what the answer is. Let's error on the safe side and show // a warning for this case. this.setState({ - e2eStatus: "warning", + e2eStatus: E2EStatus.Warning, }); return; } @@ -908,26 +957,26 @@ export default createReactClass({ this.setState({ e2eStatus: await shieldStatusForRoom(this.context, room), }); - }, + } - updateTint: function() { + private updateTint() { const room = this.state.room; if (!room) return; console.log("Tinter.tint from updateTint"); const colorScheme = SettingsStore.getValue("roomColor", room.roomId); Tinter.tint(colorScheme.primary_color, colorScheme.secondary_color); - }, + } - onAccountData: function(event) { + private onAccountData = (event: MatrixEvent) => { const type = event.getType(); if ((type === "org.matrix.preview_urls" || type === "im.vector.web.settings") && this.state.room) { // non-e2ee url previews are stored in legacy event type `org.matrix.room.preview_urls` - this._updatePreviewUrlVisibility(this.state.room); + this.updatePreviewUrlVisibility(this.state.room); } - }, + }; - onRoomAccountData: function(event, room) { + private onRoomAccountData = (event: MatrixEvent, room: Room) => { if (room.roomId == this.state.roomId) { const type = event.getType(); if (type === "org.matrix.room.color_scheme") { @@ -937,21 +986,21 @@ export default createReactClass({ Tinter.tint(colorScheme.primary_color, colorScheme.secondary_color); } else if (type === "org.matrix.room.preview_urls" || type === "im.vector.web.settings") { // non-e2ee url previews are stored in legacy event type `org.matrix.room.preview_urls` - this._updatePreviewUrlVisibility(room); + this.updatePreviewUrlVisibility(room); } } - }, + }; - onRoomStateEvents: function(ev, state) { + private onRoomStateEvents = (ev: MatrixEvent, state) => { // ignore if we don't have a room yet if (!this.state.room || this.state.room.roomId !== state.roomId) { return; } - this._updatePermissions(this.state.room); - }, + this.updatePermissions(this.state.room); + }; - onRoomStateMember: function(ev, state, member) { + private onRoomStateMember = (ev: MatrixEvent, state, member) => { // ignore if we don't have a room yet if (!this.state.room) { return; @@ -962,18 +1011,18 @@ export default createReactClass({ return; } - this._updateRoomMembers(member); - }, + this.updateRoomMembers(member); + }; - onMyMembership: function(room, membership, oldMembership) { + private onMyMembership = (room: Room, membership: string, oldMembership: string) => { if (room.roomId === this.state.roomId) { this.forceUpdate(); - this._loadMembersIfJoined(room); - this._updatePermissions(room); + this.loadMembersIfJoined(room); + this.updatePermissions(room); } - }, + }; - _updatePermissions: function(room) { + private updatePermissions(room: Room) { if (room) { const me = this.context.getUserId(); const canReact = room.getMyMembership() === "join" && room.currentState.maySendEvent("m.reaction", me); @@ -981,15 +1030,11 @@ export default createReactClass({ this.setState({canReact, canReply}); } - }, + } - // rate limited because a power level change will emit an event for every - // member in the room. - _updateRoomMembers: rate_limited_func(function(dueToMember) { - // a member state changed in this room - // refresh the conf call notification state - this._updateConfCallNotification(); - this._updateDMState(); + // rate limited because a power level change will emit an event for every member in the room. + private updateRoomMembers = rateLimitedFunc((dueToMember) => { + this.updateDMState(); let memberCountInfluence = 0; if (dueToMember && dueToMember.membership === "invite" && this.state.room.getInvitedMemberCount() === 0) { @@ -997,15 +1042,15 @@ export default createReactClass({ // count by 1 to counteract this. memberCountInfluence = 1; } - this._checkIfAlone(this.state.room, memberCountInfluence); + this.checkIfAlone(this.state.room, memberCountInfluence); - this._updateE2EStatus(this.state.room); - }, 500), + this.updateE2EStatus(this.state.room); + }, 500); - _checkIfAlone: function(room, countInfluence) { + private checkIfAlone(room: Room, countInfluence?: number) { let warnedAboutLonelyRoom = false; if (localStorage) { - warnedAboutLonelyRoom = localStorage.getItem('mx_user_alone_warned_' + this.state.room.roomId); + warnedAboutLonelyRoom = Boolean(localStorage.getItem('mx_user_alone_warned_' + this.state.room.roomId)); } if (warnedAboutLonelyRoom) { if (this.state.isAlone) this.setState({isAlone: false}); @@ -1015,33 +1060,9 @@ export default createReactClass({ let joinedOrInvitedMemberCount = room.getJoinedMemberCount() + room.getInvitedMemberCount(); if (countInfluence) joinedOrInvitedMemberCount += countInfluence; this.setState({isAlone: joinedOrInvitedMemberCount === 1}); - }, + } - _updateConfCallNotification: function() { - const room = this.state.room; - if (!room || !this.props.ConferenceHandler) { - return; - } - const confMember = room.getMember( - this.props.ConferenceHandler.getConferenceUserIdForRoom(room.roomId), - ); - - if (!confMember) { - return; - } - const confCall = this.props.ConferenceHandler.getConferenceCallForRoom(confMember.roomId); - - // A conf call notification should be displayed if there is an ongoing - // conf call but this cilent isn't a part of it. - this.setState({ - displayConfCallNotification: ( - (!confCall || confCall.call_state === "ended") && - confMember.membership === "join" - ), - }); - }, - - _updateDMState() { + private updateDMState() { const room = this.state.room; if (room.getMyMembership() != "join") { return; @@ -1050,9 +1071,9 @@ export default createReactClass({ if (dmInviter) { Rooms.setDMRoom(room.roomId, dmInviter); } - }, + } - onSearchResultsFillRequest: function(backwards) { + private onSearchResultsFillRequest = (backwards: boolean) => { if (!backwards) { return Promise.resolve(false); } @@ -1060,30 +1081,30 @@ export default createReactClass({ if (this.state.searchResults.next_batch) { debuglog("requesting more search results"); const searchPromise = searchPagination(this.state.searchResults); - return this._handleSearchResult(searchPromise); + return this.handleSearchResult(searchPromise); } else { debuglog("no more search results"); return Promise.resolve(false); } - }, + }; - onInviteButtonClick: function() { + private onInviteButtonClick = () => { // call AddressPickerDialog dis.dispatch({ action: 'view_invite', roomId: this.state.room.roomId, }); this.setState({isAlone: false}); // there's a good chance they'll invite someone - }, + }; - onStopAloneWarningClick: function() { + private onStopAloneWarningClick = () => { if (localStorage) { - localStorage.setItem('mx_user_alone_warned_' + this.state.room.roomId, true); + localStorage.setItem('mx_user_alone_warned_' + this.state.room.roomId, String(true)); } this.setState({isAlone: false}); - }, + }; - onJoinButtonClicked: function(ev) { + private onJoinButtonClicked = () => { // If the user is a ROU, allow them to transition to a PWLU if (this.context && this.context.isGuest()) { // Join this room once the user has registered and logged in @@ -1092,49 +1113,13 @@ export default createReactClass({ action: 'do_after_sync_prepared', deferred_action: { action: 'view_room', - room_id: this._getRoomId(), + room_id: this.getRoomId(), }, }); - - // Don't peek whilst registering otherwise getPendingEventList complains - // Do this by indicating our intention to join - - // XXX: ILAG is disabled for now, - // see https://github.com/vector-im/element-web/issues/8222 dis.dispatch({action: 'require_registration'}); - // dis.dispatch({ - // action: 'will_join', - // }); - - // const SetMxIdDialog = sdk.getComponent('views.dialogs.SetMxIdDialog'); - // const close = Modal.createTrackedDialog('Set MXID', '', SetMxIdDialog, { - // homeserverUrl: cli.getHomeserverUrl(), - // onFinished: (submitted, credentials) => { - // if (submitted) { - // this.props.onRegistered(credentials); - // } else { - // dis.dispatch({ - // action: 'cancel_after_sync_prepared', - // }); - // dis.dispatch({ - // action: 'cancel_join', - // }); - // } - // }, - // onDifferentServerClicked: (ev) => { - // dis.dispatch({action: 'start_registration'}); - // close(); - // }, - // onLoginClick: (ev) => { - // dis.dispatch({action: 'start_login'}); - // close(); - // }, - // }).close; - // return; } else { Promise.resolve().then(() => { - const signUrl = this.props.thirdPartyInvite ? - this.props.thirdPartyInvite.inviteSignUrl : undefined; + const signUrl = this.props.threepidInvite?.signUrl; dis.dispatch({ action: 'join_room', opts: { inviteSignUrl: signUrl, viaServers: this.props.viaServers }, @@ -1142,11 +1127,10 @@ export default createReactClass({ return Promise.resolve(); }); } + }; - }, - - onMessageListScroll: function(ev) { - if (this._messagePanel.isAtEndOfLiveTimeline()) { + private onMessageListScroll = ev => { + if (this.messagePanel.isAtEndOfLiveTimeline()) { this.setState({ numUnreadMessages: 0, atEndOfLiveTimeline: true, @@ -1156,10 +1140,10 @@ export default createReactClass({ atEndOfLiveTimeline: false, }); } - this._updateTopUnreadMessagesBar(); - }, + this.updateTopUnreadMessagesBar(); + }; - onDragOver: function(ev) { + private onDragOver = ev => { ev.stopPropagation(); ev.preventDefault(); @@ -1176,9 +1160,9 @@ export default createReactClass({ ev.dataTransfer.dropEffect = 'copy'; } } - }, + }; - onDrop: function(ev) { + private onDrop = ev => { ev.stopPropagation(); ev.preventDefault(); ContentMessages.sharedInstance().sendContentListToRoom( @@ -1186,15 +1170,15 @@ export default createReactClass({ ); this.setState({ draggingFile: false }); dis.fire(Action.FocusComposer); - }, + }; - onDragLeaveOrEnd: function(ev) { + private onDragLeaveOrEnd = ev => { ev.stopPropagation(); ev.preventDefault(); this.setState({ draggingFile: false }); - }, + }; - injectSticker: function(url, info, text) { + private injectSticker(url, info, text) { if (this.context.isGuest()) { dis.dispatch({action: 'require_registration'}); return; @@ -1207,9 +1191,9 @@ export default createReactClass({ return; } }); - }, + } - onSearch: function(term, scope) { + private onSearch = (term: string, scope) => { this.setState({ searchTerm: term, searchScope: scope, @@ -1219,8 +1203,8 @@ export default createReactClass({ // if we already have a search panel, we need to tell it to forget // about its scroll state. - if (this._searchResultsPanel.current) { - this._searchResultsPanel.current.resetScrollState(); + if (this.searchResultsPanel.current) { + this.searchResultsPanel.current.resetScrollState(); } // make sure that we don't end up showing results from @@ -1234,12 +1218,10 @@ export default createReactClass({ debuglog("sending search request"); const searchPromise = eventSearch(term, roomId); - this._handleSearchResult(searchPromise); - }, - - _handleSearchResult: function(searchPromise) { - const self = this; + this.handleSearchResult(searchPromise); + }; + private handleSearchResult(searchPromise: Promise) { // keep a record of the current search id, so that if the search terms // change before we get a response, we can ignore the results. const localSearchId = this.searchId; @@ -1248,9 +1230,9 @@ export default createReactClass({ searchInProgress: true, }); - return searchPromise.then(function(results) { + return searchPromise.then((results) => { debuglog("search complete"); - if (self.unmounted || !self.state.searching || self.searchId != localSearchId) { + if (this.unmounted || !this.state.searching || this.searchId != localSearchId) { console.error("Discarding stale search results"); return; } @@ -1262,8 +1244,8 @@ export default createReactClass({ // whether it was used by the search engine or not. let highlights = results.highlights; - if (highlights.indexOf(self.state.searchTerm) < 0) { - highlights = highlights.concat(self.state.searchTerm); + if (highlights.indexOf(this.state.searchTerm) < 0) { + highlights = highlights.concat(this.state.searchTerm); } // For overlapping highlights, @@ -1272,25 +1254,26 @@ export default createReactClass({ return b.length - a.length; }); - self.setState({ + this.setState({ searchHighlights: highlights, searchResults: results, }); - }, function(error) { + }, (error) => { const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); console.error("Search failed", error); Modal.createTrackedDialog('Search failed', '', ErrorDialog, { title: _t("Search failed"), - description: ((error && error.message) ? error.message : _t("Server may be unavailable, overloaded, or search timed out :(")), + description: ((error && error.message) ? error.message : + _t("Server may be unavailable, overloaded, or search timed out :(")), }); - }).finally(function() { - self.setState({ + }).finally(() => { + this.setState({ searchInProgress: false, }); }); - }, + } - getSearchResultTiles: function() { + private getSearchResultTiles() { const SearchResultTile = sdk.getComponent('rooms.SearchResultTile'); const Spinner = sdk.getComponent("elements.Spinner"); @@ -1301,20 +1284,20 @@ export default createReactClass({ if (this.state.searchInProgress) { ret.push(
  • - -
  • ); + + ); } if (!this.state.searchResults.next_batch) { if (this.state.searchResults.results.length == 0) { ret.push(
  • -

    { _t("No results") }

    -
  • , +

    { _t("No results") }

    + , ); } else { ret.push(
  • -

    { _t("No more results") }

    -
  • , +

    { _t("No more results") }

    + , ); } } @@ -1322,7 +1305,7 @@ export default createReactClass({ // once dynamic content in the search results load, make the scrollPanel check // the scroll offsets. const onHeightChanged = () => { - const scrollPanel = this._searchResultsPanel.current; + const scrollPanel = this.searchResultsPanel.current; if (scrollPanel) { scrollPanel.checkScroll(); } @@ -1354,36 +1337,41 @@ export default createReactClass({ if (this.state.searchScope === 'All') { if (roomId !== lastRoomId) { ret.push(
  • -

    { _t("Room") }: { room.name }

    -
  • ); +

    { _t("Room") }: { room.name }

    + ); lastRoomId = roomId; } } const resultLink = "#/room/"+roomId+"/"+mxEv.getId(); - ret.push(); + ret.push(); } return ret; - }, + } - onPinnedClick: function() { + private onPinnedClick = () => { const nowShowingPinned = !this.state.showingPinned; const roomId = this.state.room.roomId; this.setState({showingPinned: nowShowingPinned, searching: false}); SettingsStore.setValue("PinnedEvents.isOpen", roomId, SettingLevel.ROOM_DEVICE, nowShowingPinned); - }, + }; - onSettingsClick: function() { - dis.dispatch({ action: 'open_room_settings' }); - }, + private onSettingsClick = () => { + dis.dispatch({ + action: Action.SetRightPanelPhase, + phase: RightPanelPhases.RoomSummary, + }); + }; - onCancelClick: function() { + private onCancelClick = () => { console.log("updateTint from onCancelClick"); this.updateTint(); if (this.state.forwardingEvent) { @@ -1393,33 +1381,32 @@ export default createReactClass({ }); } dis.fire(Action.FocusComposer); - }, + }; - onLeaveClick: function() { + private onLeaveClick = () => { dis.dispatch({ action: 'leave_room', room_id: this.state.room.roomId, }); - }, + }; - onForgetClick: function() { + private onForgetClick = () => { dis.dispatch({ action: 'forget_room', room_id: this.state.room.roomId, }); - }, + }; - onRejectButtonClicked: function(ev) { - const self = this; + private onRejectButtonClicked = ev => { this.setState({ rejecting: true, }); - this.context.leave(this.state.roomId).then(function() { + this.context.leave(this.state.roomId).then(() => { dis.dispatch({ action: 'view_next_room' }); - self.setState({ + this.setState({ rejecting: false, }); - }, function(error) { + }, (error) => { console.error("Failed to reject invite: %s", error); const msg = error.message ? error.message : JSON.stringify(error); @@ -1429,14 +1416,14 @@ export default createReactClass({ description: msg, }); - self.setState({ + this.setState({ rejecting: false, rejectError: error, }); }); - }, + }; - onRejectAndIgnoreClick: async function() { + private onRejectAndIgnoreClick = async () => { this.setState({ rejecting: true, }); @@ -1463,69 +1450,69 @@ export default createReactClass({ description: msg, }); - self.setState({ + this.setState({ rejecting: false, rejectError: error, }); } - }, + }; - onRejectThreepidInviteButtonClicked: function(ev) { + private onRejectThreepidInviteButtonClicked = ev => { // We can reject 3pid invites in the same way that we accept them, // using /leave rather than /join. In the short term though, we // just ignore them. // https://github.com/vector-im/vector-web/issues/1134 dis.fire(Action.ViewRoomDirectory); - }, + }; - onSearchClick: function() { + private onSearchClick = () => { this.setState({ searching: !this.state.searching, showingPinned: false, }); - }, + }; - onCancelSearchClick: function() { + private onCancelSearchClick = () => { this.setState({ searching: false, searchResults: null, }); - }, + }; // jump down to the bottom of this room, where new events are arriving - jumpToLiveTimeline: function() { - this._messagePanel.jumpToLiveTimeline(); + private jumpToLiveTimeline = () => { + this.messagePanel.jumpToLiveTimeline(); dis.fire(Action.FocusComposer); - }, + }; // jump up to wherever our read marker is - jumpToReadMarker: function() { - this._messagePanel.jumpToReadMarker(); - }, + private jumpToReadMarker = () => { + this.messagePanel.jumpToReadMarker(); + }; // update the read marker to match the read-receipt - forgetReadMarker: function(ev) { + private forgetReadMarker = ev => { ev.stopPropagation(); - this._messagePanel.forgetReadMarker(); - }, + this.messagePanel.forgetReadMarker(); + }; // decide whether or not the top 'unread messages' bar should be shown - _updateTopUnreadMessagesBar: function() { - if (!this._messagePanel) { + private updateTopUnreadMessagesBar = () => { + if (!this.messagePanel) { return; } - const showBar = this._messagePanel.canJumpToReadMarker(); + const showBar = this.messagePanel.canJumpToReadMarker(); if (this.state.showTopUnreadMessagesBar != showBar) { this.setState({showTopUnreadMessagesBar: showBar}); } - }, + }; // get the current scroll position of the room, so that it can be // restored when we switch back to it. // - _getScrollState: function() { - const messagePanel = this._messagePanel; + private getScrollState() { + const messagePanel = this.messagePanel; if (!messagePanel) return null; // if we're following the live timeline, we want to return null; that @@ -1559,9 +1546,9 @@ export default createReactClass({ focussedEvent: scrollState.trackedScrollToken, pixelOffset: scrollState.pixelOffset, }; - }, + } - onResize: function() { + private onResize = () => { // It seems flexbox doesn't give us a way to constrain the auxPanel height to have // a minimum of the height of the video element, whilst also capping it from pushing out the page // so we have to do it via JS instead. In this implementation we cap the height by putting @@ -1569,9 +1556,9 @@ export default createReactClass({ // header + footer + status + give us at least 120px of scrollback at all times. let auxPanelMaxHeight = window.innerHeight - - (83 + // height of RoomHeader + (54 + // height of RoomHeader 36 + // height of the status area - 72 + // minimum height of the message compmoser + 51 + // minimum height of the message compmoser 120); // amount of desired scrollback // XXX: this is a bit of a hack and might possibly cause the video to push out the page anyway @@ -1579,121 +1566,108 @@ export default createReactClass({ if (auxPanelMaxHeight < 50) auxPanelMaxHeight = 50; this.setState({auxPanelMaxHeight: auxPanelMaxHeight}); - }, + }; - onFullscreenClick: function() { + private onFullscreenClick = () => { dis.dispatch({ action: 'video_fullscreen', fullscreen: true, }, true); - }, + }; - onMuteAudioClick: function() { - const call = this._getCallForRoom(); + private onMuteAudioClick = () => { + const call = this.getCallForRoom(); if (!call) { return; } const newState = !call.isMicrophoneMuted(); call.setMicrophoneMuted(newState); this.forceUpdate(); // TODO: just update the voip buttons - }, + }; - onMuteVideoClick: function() { - const call = this._getCallForRoom(); + private onMuteVideoClick = () => { + const call = this.getCallForRoom(); if (!call) { return; } const newState = !call.isLocalVideoMuted(); call.setLocalVideoMuted(newState); this.forceUpdate(); // TODO: just update the voip buttons - }, + }; - onStatusBarVisible: function() { + private onStatusBarVisible = () => { if (this.unmounted) return; this.setState({ statusBarVisible: true, }); - }, + }; - onStatusBarHidden: function() { + private onStatusBarHidden = () => { // This is currently not desired as it is annoying if it keeps expanding and collapsing if (this.unmounted) return; this.setState({ statusBarVisible: false, }); - }, + }; /** * called by the parent component when PageUp/Down/etc is pressed. * * We pass it down to the scroll panel. */ - handleScrollKey: function(ev) { + private handleScrollKey = ev => { let panel; - if (this._searchResultsPanel.current) { - panel = this._searchResultsPanel.current; - } else if (this._messagePanel) { - panel = this._messagePanel; + if (this.searchResultsPanel.current) { + panel = this.searchResultsPanel.current; + } else if (this.messagePanel) { + panel = this.messagePanel; } if (panel) { panel.handleScrollKey(ev); } - }, + }; /** * get any current call for this room */ - _getCallForRoom: function() { + private getCallForRoom(): MatrixCall { if (!this.state.room) { return null; } - return CallHandler.getCallForRoom(this.state.room.roomId); - }, + return CallHandler.sharedInstance().getCallForRoom(this.state.room.roomId); + } // this has to be a proper method rather than an unnamed function, // otherwise react calls it with null on each update. - _gatherTimelinePanelRef: function(r) { - this._messagePanel = r; + private gatherTimelinePanelRef = r => { + this.messagePanel = r; if (r) { - console.log("updateTint from RoomView._gatherTimelinePanelRef"); + console.log("updateTint from RoomView.gatherTimelinePanelRef"); this.updateTint(); } - }, + }; - _getOldRoom: function() { + private getOldRoom() { const createEvent = this.state.room.currentState.getStateEvents("m.room.create", ""); if (!createEvent || !createEvent.getContent()['predecessor']) return null; return this.context.getRoom(createEvent.getContent()['predecessor']['room_id']); - }, + } - _getHiddenHighlightCount: function() { - const oldRoom = this._getOldRoom(); + getHiddenHighlightCount() { + const oldRoom = this.getOldRoom(); if (!oldRoom) return 0; return oldRoom.getUnreadNotificationCount('highlight'); - }, + } - _onHiddenHighlightsClick: function() { - const oldRoom = this._getOldRoom(); + onHiddenHighlightsClick = () => { + const oldRoom = this.getOldRoom(); if (!oldRoom) return; dis.dispatch({action: "view_room", room_id: oldRoom.roomId}); - }, - - render: function() { - const RoomHeader = sdk.getComponent('rooms.RoomHeader'); - const ForwardMessage = sdk.getComponent("rooms.ForwardMessage"); - const AuxPanel = sdk.getComponent("rooms.AuxPanel"); - const SearchBar = sdk.getComponent("rooms.SearchBar"); - const PinnedEventsPanel = sdk.getComponent("rooms.PinnedEventsPanel"); - const ScrollPanel = sdk.getComponent("structures.ScrollPanel"); - const TintableSvg = sdk.getComponent("elements.TintableSvg"); - const RoomPreviewBar = sdk.getComponent("rooms.RoomPreviewBar"); - const TimelinePanel = sdk.getComponent("structures.TimelinePanel"); - const RoomUpgradeWarningBar = sdk.getComponent("rooms.RoomUpgradeWarningBar"); - const RoomRecoveryReminder = sdk.getComponent("rooms.RoomRecoveryReminder"); - const ErrorBoundary = sdk.getComponent("elements.ErrorBoundary"); + }; + render() { if (!this.state.room) { const loading = !this.state.matrixClientIsReady || this.state.roomLoading || this.state.peekLoading; if (loading) { @@ -1714,14 +1688,11 @@ export default createReactClass({ ); } else { - var inviterName = undefined; + let inviterName = undefined; if (this.props.oobData) { inviterName = this.props.oobData.inviterName; } - var invitedEmail = undefined; - if (this.props.thirdPartyInvite) { - invitedEmail = this.props.thirdPartyInvite.invitedEmail; - } + const invitedEmail = this.props.threepidInvite?.toEmail; // We have no room object for this room, only the ID. // We've got to this room by following a link, possibly a third party invite. @@ -1739,7 +1710,7 @@ export default createReactClass({ inviterName={inviterName} invitedEmail={invitedEmail} oobData={this.props.oobData} - signUrl={this.props.thirdPartyInvite ? this.props.thirdPartyInvite.inviteSignUrl : null} + signUrl={this.props.threepidInvite?.signUrl} room={this.state.room} /> @@ -1797,13 +1768,16 @@ export default createReactClass({ // We have successfully loaded this room, and are not previewing. // Display the "normal" room view. - const call = this._getCallForRoom(); - let inCall = false; - if (call && (this.state.callState !== 'ended' && this.state.callState !== 'ringing')) { - inCall = true; + let activeCall = null; + { + // New block because this variable doesn't need to hang around for the rest of the function + const call = this.getCallForRoom(); + if (call && (this.state.callState !== 'ended' && this.state.callState !== 'ringing')) { + activeCall = call; + } } - const scrollheader_classes = classNames({ + const scrollheaderClasses = classNames({ mx_RoomView_scrollheader: true, }); @@ -1819,7 +1793,8 @@ export default createReactClass({ statusBar = ; } else if (this.state.searching) { hideCancel = true; // has own cancel - aux = ; + aux = ; } else if (showRoomUpgradeBar) { aux = ; hideCancel = true; - } else if (showRoomRecoveryReminder) { - aux = ; - hideCancel = true; } else if (this.state.showingPinned) { hideCancel = true; // has own cancel aux = ; } else if (myMembership !== "join") { // We do have a room object for this room, but we're not currently in it. // We may have a 3rd party invite to it. - var inviterName = undefined; + let inviterName = undefined; if (this.props.oobData) { inviterName = this.props.oobData.inviterName; } - var invitedEmail = undefined; - if (this.props.thirdPartyInvite) { - invitedEmail = this.props.thirdPartyInvite.invitedEmail; - } + const invitedEmail = this.props.threepidInvite?.toEmail; hideCancel = true; previewBar = ( - ); if (!this.state.canPeek) { @@ -1892,13 +1859,14 @@ export default createReactClass({ { previewBar } ); - } else { - forceHideRightPanel = true; } } else if (hiddenHighlightCount > 0) { aux = ( - + {_t( "You have %(count)s unread notifications in a prior version of this room.", {count: hiddenHighlightCount}, @@ -1908,15 +1876,16 @@ export default createReactClass({ } const auxPanel = ( - + { aux } ); @@ -1932,11 +1901,10 @@ export default createReactClass({ ; } @@ -1950,32 +1918,44 @@ export default createReactClass({ }; } - if (inCall) { + if (activeCall) { let zoomButton; let videoMuteButton; - if (call.type === "video") { + if (activeCall.type === CallType.Video) { zoomButton = (
    - +
    ); videoMuteButton =
    - +
    ; } const voiceMuteButton =
    - +
    ; // wrap the existing status bar into a 'callStatusBar' which adds more knobs. @@ -1996,16 +1976,18 @@ export default createReactClass({ if (this.state.searchResults) { // show searching spinner if (this.state.searchResults.results === undefined) { - searchResultsPanel = (
    ); + searchResultsPanel = ( +
    + ); } else { searchResultsPanel = ( -
  • +
  • { this.getSearchResultTiles() } ); @@ -2031,7 +2013,7 @@ export default createReactClass({ // console.info("ShowUrlPreview for %s is %s", this.state.room.roomId, this.state.showUrlPreview); const messagePanel = ( ); + topUnreadMessagesBar = ( + + ); } let jumpToBottom; // Do not show JumpToBottomButton if we have search results showing, it makes no sense @@ -2070,23 +2051,14 @@ export default createReactClass({ onScrollToBottomClick={this.jumpToLiveTimeline} />); } - const statusBarAreaClass = classNames( - "mx_RoomView_statusArea", - { - "mx_RoomView_statusArea_expanded": isStatusAreaExpanded, - }, - ); - const fadableSectionClasses = classNames( - "mx_RoomView_body", "mx_fadable", - { - "mx_fadable_faded": this.props.disabled, - }, - ); + const statusBarAreaClass = classNames("mx_RoomView_statusArea", { + "mx_RoomView_statusArea_expanded": isStatusAreaExpanded, + }); - const showRightPanel = !forceHideRightPanel && this.state.room && this.state.showRightPanel; + const showRightPanel = this.state.room && this.state.showRightPanel; const rightPanel = showRightPanel - ? + ? : null; const timelineClasses = classNames("mx_RoomView_timeline", { @@ -2094,12 +2066,12 @@ export default createReactClass({ }); const mainClasses = classNames("mx_RoomView", { - mx_RoomView_inCall: inCall, + mx_RoomView_inCall: Boolean(activeCall), }); return ( -
    +
    - -
    + +
    {auxPanel}
    {topUnreadMessagesBar} @@ -2140,5 +2109,5 @@ export default createReactClass({
    ); - }, -}); + } +} diff --git a/src/components/structures/ScrollPanel.js b/src/components/structures/ScrollPanel.js index 51113f4f56..99a3da2565 100644 --- a/src/components/structures/ScrollPanel.js +++ b/src/components/structures/ScrollPanel.js @@ -15,7 +15,6 @@ limitations under the License. */ import React, {createRef} from "react"; -import createReactClass from 'create-react-class'; import PropTypes from 'prop-types'; import { Key } from '../../Keyboard'; import Timer from '../../utils/Timer'; @@ -84,10 +83,8 @@ if (DEBUG_SCROLL) { * offset as normal. */ -export default createReactClass({ - displayName: 'ScrollPanel', - - propTypes: { +export default class ScrollPanel extends React.Component { + static propTypes = { /* stickyBottom: if set to true, then once the user hits the bottom of * the list, any new children added to the list will cause the list to * scroll down to show the new element, rather than preserving the @@ -97,7 +94,7 @@ export default createReactClass({ /* startAtBottom: if set to true, the view is assumed to start * scrolled to the bottom. - * XXX: It's likley this is unecessary and can be derived from + * XXX: It's likely this is unnecessary and can be derived from * stickyBottom, but I'm adding an extra parameter to ensure * behaviour stays the same for other uses of ScrollPanel. * If so, let's remove this parameter down the line. @@ -141,6 +138,7 @@ export default createReactClass({ /* style: styles to add to the top-level div */ style: PropTypes.object, + /* resizeNotifier: ResizeNotifier to know when middle column has changed size */ resizeNotifier: PropTypes.object, @@ -149,36 +147,35 @@ export default createReactClass({ * of the wrapper */ fixedChildren: PropTypes.node, - }, + }; - getDefaultProps: function() { - return { - stickyBottom: true, - startAtBottom: true, - onFillRequest: function(backwards) { return Promise.resolve(false); }, - onUnfillRequest: function(backwards, scrollToken) {}, - onScroll: function() {}, - }; - }, + static defaultProps = { + stickyBottom: true, + startAtBottom: true, + onFillRequest: function(backwards) { return Promise.resolve(false); }, + onUnfillRequest: function(backwards, scrollToken) {}, + onScroll: function() {}, + }; + + constructor(props) { + super(props); - // TODO: [REACT-WARNING] Replace component with real class, use constructor for refs - UNSAFE_componentWillMount: function() { this._pendingFillRequests = {b: null, f: null}; if (this.props.resizeNotifier) { - this.props.resizeNotifier.on("middlePanelResized", this.onResize); + this.props.resizeNotifier.on("middlePanelResizedNoisy", this.onResize); } this.resetScrollState(); this._itemlist = createRef(); - }, + } - componentDidMount: function() { + componentDidMount() { this.checkScroll(); - }, + } - componentDidUpdate: function() { + componentDidUpdate() { // after adding event tiles, we may need to tweak the scroll (either to // keep at the bottom of the timeline, or to maintain the view after // adding events to the top). @@ -186,9 +183,9 @@ export default createReactClass({ // This will also re-check the fill state, in case the paginate was inadequate this.checkScroll(); this.updatePreventShrinking(); - }, + } - componentWillUnmount: function() { + componentWillUnmount() { // set a boolean to say we've been unmounted, which any pending // promises can use to throw away their results. // @@ -196,51 +193,53 @@ export default createReactClass({ this.unmounted = true; if (this.props.resizeNotifier) { - this.props.resizeNotifier.removeListener("middlePanelResized", this.onResize); + this.props.resizeNotifier.removeListener("middlePanelResizedNoisy", this.onResize); } - }, + } - onScroll: function(ev) { + onScroll = ev => { + // skip scroll events caused by resizing + if (this.props.resizeNotifier && this.props.resizeNotifier.isResizing) return; debuglog("onScroll", this._getScrollNode().scrollTop); this._scrollTimeout.restart(); this._saveScrollState(); this.updatePreventShrinking(); this.props.onScroll(ev); this.checkFillState(); - }, + }; - onResize: function() { + onResize = () => { + debuglog("onResize"); this.checkScroll(); // update preventShrinkingState if present if (this.preventShrinkingState) { this.preventShrinking(); } - }, + }; // after an update to the contents of the panel, check that the scroll is // where it ought to be, and set off pagination requests if necessary. - checkScroll: function() { + checkScroll = () => { if (this.unmounted) { return; } this._restoreSavedScrollState(); this.checkFillState(); - }, + }; // return true if the content is fully scrolled down right now; else false. // // note that this is independent of the 'stuckAtBottom' state - it is simply // about whether the content is scrolled down right now, irrespective of // whether it will stay that way when the children update. - isAtBottom: function() { + isAtBottom = () => { const sn = this._getScrollNode(); // fractional values (both too big and too small) // for scrollTop happen on certain browsers/platforms // when scrolled all the way down. E.g. Chrome 72 on debian. // so check difference <= 1; return Math.abs(sn.scrollHeight - (sn.scrollTop + sn.clientHeight)) <= 1; - - }, + }; // returns the vertical height in the given direction that can be removed from // the content box (which has a height of scrollHeight, see checkFillState) without @@ -273,7 +272,7 @@ export default createReactClass({ // |#########| - | // |#########| | // `---------' - - _getExcessHeight: function(backwards) { + _getExcessHeight(backwards) { const sn = this._getScrollNode(); const contentHeight = this._getMessagesHeight(); const listHeight = this._getListHeight(); @@ -285,10 +284,10 @@ export default createReactClass({ } else { return contentHeight - (unclippedScrollTop + 2*sn.clientHeight) - UNPAGINATION_PADDING; } - }, + } // check the scroll state and send out backfill requests if necessary. - checkFillState: async function(depth=0) { + checkFillState = async (depth=0) => { if (this.unmounted) { return; } @@ -368,10 +367,10 @@ export default createReactClass({ this._fillRequestWhileRunning = false; this.checkFillState(); } - }, + }; // check if unfilling is possible and send an unfill request if necessary - _checkUnfillState: function(backwards) { + _checkUnfillState(backwards) { let excessHeight = this._getExcessHeight(backwards); if (excessHeight <= 0) { return; @@ -417,10 +416,10 @@ export default createReactClass({ this.props.onUnfillRequest(backwards, markerScrollToken); }, UNFILL_REQUEST_DEBOUNCE_MS); } - }, + } // check if there is already a pending fill request. If not, set one off. - _maybeFill: function(depth, backwards) { + _maybeFill(depth, backwards) { const dir = backwards ? 'b' : 'f'; if (this._pendingFillRequests[dir]) { debuglog("Already a "+dir+" fill in progress - not starting another"); @@ -456,7 +455,7 @@ export default createReactClass({ return this.checkFillState(depth + 1); } }); - }, + } /* get the current scroll state. This returns an object with the following * properties: @@ -472,9 +471,7 @@ export default createReactClass({ * the number of pixels the bottom of the tracked child is above the * bottom of the scroll panel. */ - getScrollState: function() { - return this.scrollState; - }, + getScrollState = () => this.scrollState; /* reset the saved scroll state. * @@ -488,7 +485,7 @@ export default createReactClass({ * no use if no children exist yet, or if you are about to replace the * child list.) */ - resetScrollState: function() { + resetScrollState = () => { this.scrollState = { stuckAtBottom: this.props.startAtBottom, }; @@ -496,20 +493,20 @@ export default createReactClass({ this._pages = 0; this._scrollTimeout = new Timer(100); this._heightUpdateInProgress = false; - }, + }; /** * jump to the top of the content. */ - scrollToTop: function() { + scrollToTop = () => { this._getScrollNode().scrollTop = 0; this._saveScrollState(); - }, + }; /** * jump to the bottom of the content. */ - scrollToBottom: function() { + scrollToBottom = () => { // the easiest way to make sure that the scroll state is correctly // saved is to do the scroll, then save the updated state. (Calculating // it ourselves is hard, and we can't rely on an onScroll callback @@ -517,25 +514,25 @@ export default createReactClass({ const sn = this._getScrollNode(); sn.scrollTop = sn.scrollHeight; this._saveScrollState(); - }, + }; /** * Page up/down. * * @param {number} mult: -1 to page up, +1 to page down */ - scrollRelative: function(mult) { + scrollRelative = mult => { const scrollNode = this._getScrollNode(); const delta = mult * scrollNode.clientHeight * 0.5; scrollNode.scrollBy(0, delta); this._saveScrollState(); - }, + }; /** * Scroll up/down in response to a scroll key * @param {object} ev the keyboard event */ - handleScrollKey: function(ev) { + handleScrollKey = ev => { switch (ev.key) { case Key.PAGE_UP: if (!ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey) { @@ -561,7 +558,7 @@ export default createReactClass({ } break; } - }, + }; /* Scroll the panel to bring the DOM node with the scroll token * `scrollToken` into view. @@ -574,7 +571,7 @@ export default createReactClass({ * node (specifically, the bottom of it) will be positioned. If omitted, it * defaults to 0. */ - scrollToToken: function(scrollToken, pixelOffset, offsetBase) { + scrollToToken = (scrollToken, pixelOffset, offsetBase) => { pixelOffset = pixelOffset || 0; offsetBase = offsetBase || 0; @@ -596,9 +593,9 @@ export default createReactClass({ scrollNode.scrollTop = (trackedNode.offsetTop - (scrollNode.clientHeight * offsetBase)) + pixelOffset; this._saveScrollState(); } - }, + }; - _saveScrollState: function() { + _saveScrollState() { if (this.props.stickyBottom && this.isAtBottom()) { this.scrollState = { stuckAtBottom: true }; debuglog("saved stuckAtBottom state"); @@ -641,9 +638,9 @@ export default createReactClass({ bottomOffset: bottomOffset, pixelOffset: bottomOffset - viewportBottom, //needed for restoring the scroll position when coming back to the room }; - }, + } - _restoreSavedScrollState: async function() { + async _restoreSavedScrollState() { const scrollState = this.scrollState; if (scrollState.stuckAtBottom) { @@ -676,7 +673,8 @@ export default createReactClass({ } else { debuglog("not updating height because request already in progress"); } - }, + } + // need a better name that also indicates this will change scrollTop? Rebalance height? Reveal content? async _updateHeight() { // wait until user has stopped scrolling @@ -731,7 +729,7 @@ export default createReactClass({ debuglog("updateHeight to", {newHeight, topDiff}); } } - }, + } _getTrackedNode() { const scrollState = this.scrollState; @@ -764,11 +762,11 @@ export default createReactClass({ } return scrollState.trackedNode; - }, + } _getListHeight() { return this._bottomGrowth + (this._pages * PAGE_SIZE); - }, + } _getMessagesHeight() { const itemlist = this._itemlist.current; @@ -777,17 +775,17 @@ export default createReactClass({ const firstNodeTop = itemlist.firstElementChild ? itemlist.firstElementChild.offsetTop : 0; // 18 is itemlist padding return lastNodeBottom - firstNodeTop + (18 * 2); - }, + } _topFromBottom(node) { // current capped height - distance from top = distance from bottom of container to top of tracked element return this._itemlist.current.clientHeight - node.offsetTop; - }, + } /* get the DOM node which has the scrollTop property we care about for our * message panel. */ - _getScrollNode: function() { + _getScrollNode() { if (this.unmounted) { // this shouldn't happen, but when it does, turn the NPE into // something more meaningful. @@ -801,18 +799,18 @@ export default createReactClass({ } return this._divScroll; - }, + } - _collectScroll: function(divScroll) { + _collectScroll = divScroll => { this._divScroll = divScroll; - }, + }; /** Mark the bottom offset of the last tile so we can balance it out when anything below it changes, by calling updatePreventShrinking, to keep the same minimum bottom offset, effectively preventing the timeline to shrink. */ - preventShrinking: function() { + preventShrinking = () => { const messageList = this._itemlist.current; const tiles = messageList && messageList.children; if (!messageList) { @@ -836,16 +834,16 @@ export default createReactClass({ offsetNode: lastTileNode, }; debuglog("prevent shrinking, last tile ", offsetFromBottom, "px from bottom"); - }, + }; /** Clear shrinking prevention. Used internally, and when the timeline is reloaded. */ - clearPreventShrinking: function() { + clearPreventShrinking = () => { const messageList = this._itemlist.current; const balanceElement = messageList && messageList.parentElement; if (balanceElement) balanceElement.style.paddingBottom = null; this.preventShrinkingState = null; debuglog("prevent shrinking cleared"); - }, + }; /** update the container padding to balance @@ -855,7 +853,7 @@ export default createReactClass({ from the bottom of the marked tile grows larger than what it was when marking. */ - updatePreventShrinking: function() { + updatePreventShrinking = () => { if (this.preventShrinkingState) { const sn = this._getScrollNode(); const scrollState = this.scrollState; @@ -885,9 +883,9 @@ export default createReactClass({ this.clearPreventShrinking(); } } - }, + }; - render: function() { + render() { // TODO: the classnames on the div and ol could do with being updated to // reflect the fact that we don't necessarily contain a list of messages. // it's not obvious why we have a separate div and ol anyway. @@ -905,5 +903,5 @@ export default createReactClass({
  • ); - }, -}); + } +} diff --git a/src/components/structures/SearchBox.js b/src/components/structures/SearchBox.js index 7e9d290bce..c1e3ad0cf2 100644 --- a/src/components/structures/SearchBox.js +++ b/src/components/structures/SearchBox.js @@ -16,18 +16,15 @@ limitations under the License. */ import React, {createRef} from 'react'; -import createReactClass from 'create-react-class'; import PropTypes from 'prop-types'; import { Key } from '../../Keyboard'; import dis from '../../dispatcher/dispatcher'; -import { throttle } from 'lodash'; +import {throttle} from 'lodash'; import AccessibleButton from '../../components/views/elements/AccessibleButton'; import classNames from 'classnames'; -export default createReactClass({ - displayName: 'SearchBox', - - propTypes: { +export default class SearchBox extends React.Component { + static propTypes = { onSearch: PropTypes.func, onCleared: PropTypes.func, onKeyDown: PropTypes.func, @@ -38,35 +35,32 @@ export default createReactClass({ // on room search focus action (it would be nicer to take // this functionality out, but not obvious how that would work) enableRoomSearchFocus: PropTypes.bool, - }, + }; - getDefaultProps: function() { - return { - enableRoomSearchFocus: false, - }; - }, + static defaultProps = { + enableRoomSearchFocus: false, + }; - getInitialState: function() { - return { + constructor(props) { + super(props); + + this._search = createRef(); + + this.state = { searchTerm: "", blurred: true, }; - }, + } - // TODO: [REACT-WARNING] Replace component with real class, use constructor for refs - UNSAFE_componentWillMount: function() { - this._search = createRef(); - }, - - componentDidMount: function() { + componentDidMount() { this.dispatcherRef = dis.register(this.onAction); - }, + } - componentWillUnmount: function() { + componentWillUnmount() { dis.unregister(this.dispatcherRef); - }, + } - onAction: function(payload) { + onAction = payload => { if (!this.props.enableRoomSearchFocus) return; switch (payload.action) { @@ -81,51 +75,51 @@ export default createReactClass({ } break; } - }, + }; - onChange: function() { + onChange = () => { if (!this._search.current) return; this.setState({ searchTerm: this._search.current.value }); this.onSearch(); - }, + }; - onSearch: throttle(function() { + onSearch = throttle(() => { this.props.onSearch(this._search.current.value); - }, 200, {trailing: true, leading: true}), + }, 200, {trailing: true, leading: true}); - _onKeyDown: function(ev) { + _onKeyDown = ev => { switch (ev.key) { case Key.ESCAPE: this._clearSearch("keyboard"); break; } if (this.props.onKeyDown) this.props.onKeyDown(ev); - }, + }; - _onFocus: function(ev) { + _onFocus = ev => { this.setState({blurred: false}); ev.target.select(); if (this.props.onFocus) { this.props.onFocus(ev); } - }, + }; - _onBlur: function(ev) { + _onBlur = ev => { this.setState({blurred: true}); if (this.props.onBlur) { this.props.onBlur(ev); } - }, + }; - _clearSearch: function(source) { + _clearSearch(source) { this._search.current.value = ""; this.onChange(); if (this.props.onCleared) { this.props.onCleared(source); } - }, + } - render: function() { + render() { // check for collapsed here and // not at parent so we keep // searchTerm in our state @@ -166,5 +160,5 @@ export default createReactClass({ { clearButton }
    ); - }, -}); + } +} diff --git a/src/components/structures/TabbedView.tsx b/src/components/structures/TabbedView.tsx index 704dbf8832..6bc35eb2a4 100644 --- a/src/components/structures/TabbedView.tsx +++ b/src/components/structures/TabbedView.tsx @@ -18,7 +18,6 @@ limitations under the License. import * as React from "react"; import {_t} from '../../languageHandler'; -import * as PropTypes from "prop-types"; import * as sdk from "../../index"; import AutoHideScrollbar from './AutoHideScrollbar'; import { ReactNode } from "react"; diff --git a/src/components/structures/TimelinePanel.js b/src/components/structures/TimelinePanel.js index 2313b60ab1..8bbc66bf40 100644 --- a/src/components/structures/TimelinePanel.js +++ b/src/components/structures/TimelinePanel.js @@ -19,7 +19,6 @@ limitations under the License. import SettingsStore from "../../settings/SettingsStore"; import React, {createRef} from 'react'; -import createReactClass from 'create-react-class'; import ReactDOM from "react-dom"; import PropTypes from 'prop-types'; import {EventTimeline} from "matrix-js-sdk"; @@ -36,6 +35,7 @@ import Timer from '../../utils/Timer'; import shouldHideEvent from '../../shouldHideEvent'; import EditorStateTransfer from '../../utils/EditorStateTransfer'; import {haveTileForEvent} from "../views/rooms/EventTile"; +import {UIFeature} from "../../settings/UIFeature"; const PAGINATE_SIZE = 20; const INITIAL_SIZE = 20; @@ -54,10 +54,8 @@ if (DEBUG) { * * Also responsible for handling and sending read receipts. */ -const TimelinePanel = createReactClass({ - displayName: 'TimelinePanel', - - propTypes: { +class TimelinePanel extends React.Component { + static propTypes = { // The js-sdk EventTimelineSet object for the timeline sequence we are // representing. This may or may not have a room, depending on what it's // a timeline representing. If it has a room, we maintain RRs etc for @@ -107,31 +105,36 @@ const TimelinePanel = createReactClass({ // shape property to be passed to EventTiles tileShape: PropTypes.string, - // placeholder text to use if the timeline is empty - empty: PropTypes.string, + // placeholder to use if the timeline is empty + empty: PropTypes.node, // whether to show reactions for an event showReactions: PropTypes.bool, // whether to use the irc layout useIRCLayout: PropTypes.bool, - }, + } - statics: { - // a map from room id to read marker event timestamp - roomReadMarkerTsMap: {}, - }, + // a map from room id to read marker event timestamp + static roomReadMarkerTsMap = {}; - getDefaultProps: function() { - return { - // By default, disable the timelineCap in favour of unpaginating based on - // event tile heights. (See _unpaginateEvents) - timelineCap: Number.MAX_VALUE, - className: 'mx_RoomView_messagePanel', - }; - }, + static defaultProps = { + // By default, disable the timelineCap in favour of unpaginating based on + // event tile heights. (See _unpaginateEvents) + timelineCap: Number.MAX_VALUE, + className: 'mx_RoomView_messagePanel', + }; + + constructor(props) { + super(props); + + debuglog("TimelinePanel: mounting"); + + this.lastRRSentEventId = undefined; + this.lastRMSentEventId = undefined; + + this._messagePanel = createRef(); - getInitialState: function() { // XXX: we could track RM per TimelineSet rather than per Room. // but for now we just do it per room for simplicity. let initialReadMarker = null; @@ -144,7 +147,7 @@ const TimelinePanel = createReactClass({ } } - return { + this.state = { events: [], liveEvents: [], timelineLoading: true, // track whether our room timeline is loading @@ -203,24 +206,6 @@ const TimelinePanel = createReactClass({ // how long to show the RM for when it's scrolled off-screen readMarkerOutOfViewThresholdMs: SettingsStore.getValue("readMarkerOutOfViewThresholdMs"), }; - }, - - // TODO: [REACT-WARNING] Replace component with real class, use constructor for refs - UNSAFE_componentWillMount: function() { - debuglog("TimelinePanel: mounting"); - - this.lastRRSentEventId = undefined; - this.lastRMSentEventId = undefined; - - this._messagePanel = createRef(); - - if (this.props.manageReadReceipts) { - this.updateReadReceiptOnUserActivity(); - } - if (this.props.manageReadMarkers) { - this.updateReadMarkerOnUserActivity(); - } - this.dispatcherRef = dis.register(this.onAction); MatrixClientPeg.get().on("Room.timeline", this.onRoomTimeline); @@ -234,12 +219,24 @@ const TimelinePanel = createReactClass({ MatrixClientPeg.get().on("Event.decrypted", this.onEventDecrypted); MatrixClientPeg.get().on("Event.replaced", this.onEventReplaced); MatrixClientPeg.get().on("sync", this.onSync); + } + + // TODO: [REACT-WARNING] Move into constructor + // eslint-disable-next-line camelcase + UNSAFE_componentWillMount() { + if (this.props.manageReadReceipts) { + this.updateReadReceiptOnUserActivity(); + } + if (this.props.manageReadMarkers) { + this.updateReadMarkerOnUserActivity(); + } this._initTimeline(this.props); - }, + } // TODO: [REACT-WARNING] Replace with appropriate lifecycle event - UNSAFE_componentWillReceiveProps: function(newProps) { + // eslint-disable-next-line camelcase + UNSAFE_componentWillReceiveProps(newProps) { if (newProps.timelineSet !== this.props.timelineSet) { // throw new Error("changing timelineSet on a TimelinePanel is not supported"); @@ -260,9 +257,9 @@ const TimelinePanel = createReactClass({ " (was " + this.props.eventId + ")"); return this._initTimeline(newProps); } - }, + } - shouldComponentUpdate: function(nextProps, nextState) { + shouldComponentUpdate(nextProps, nextState) { if (!ObjectUtils.shallowEqual(this.props, nextProps)) { if (DEBUG) { console.group("Timeline.shouldComponentUpdate: props change"); @@ -284,9 +281,9 @@ const TimelinePanel = createReactClass({ } return false; - }, + } - componentWillUnmount: function() { + componentWillUnmount() { // set a boolean to say we've been unmounted, which any pending // promises can use to throw away their results. // @@ -316,9 +313,9 @@ const TimelinePanel = createReactClass({ client.removeListener("Event.replaced", this.onEventReplaced); client.removeListener("sync", this.onSync); } - }, + } - onMessageListUnfillRequest: function(backwards, scrollToken) { + onMessageListUnfillRequest = (backwards, scrollToken) => { // If backwards, unpaginate from the back (i.e. the start of the timeline) const dir = backwards ? EventTimeline.BACKWARDS : EventTimeline.FORWARDS; debuglog("TimelinePanel: unpaginating events in direction", dir); @@ -349,18 +346,18 @@ const TimelinePanel = createReactClass({ firstVisibleEventIndex, }); } - }, + }; - onPaginationRequest(timelineWindow, direction, size) { + onPaginationRequest = (timelineWindow, direction, size) => { if (this.props.onPaginationRequest) { return this.props.onPaginationRequest(timelineWindow, direction, size); } else { return timelineWindow.paginate(direction, size); } - }, + }; // set off a pagination request. - onMessageListFillRequest: function(backwards) { + onMessageListFillRequest = backwards => { if (!this._shouldPaginate()) return Promise.resolve(false); const dir = backwards ? EventTimeline.BACKWARDS : EventTimeline.FORWARDS; @@ -425,9 +422,9 @@ const TimelinePanel = createReactClass({ }); }); }); - }, + }; - onMessageListScroll: function(e) { + onMessageListScroll = e => { if (this.props.onScroll) { this.props.onScroll(e); } @@ -447,9 +444,9 @@ const TimelinePanel = createReactClass({ // NO-OP when timeout already has set to the given value this._readMarkerActivityTimer.changeTimeout(timeout); } - }, + }; - onAction: function(payload) { + onAction = payload => { if (payload.action === 'ignore_state_changed') { this.forceUpdate(); } @@ -463,9 +460,9 @@ const TimelinePanel = createReactClass({ } }); } - }, + }; - onRoomTimeline: function(ev, room, toStartOfTimeline, removed, data) { + onRoomTimeline = (ev, room, toStartOfTimeline, removed, data) => { // ignore events for other timeline sets if (data.timeline.getTimelineSet() !== this.props.timelineSet) return; @@ -537,21 +534,19 @@ const TimelinePanel = createReactClass({ } }); }); - }, + }; - onRoomTimelineReset: function(room, timelineSet) { + onRoomTimelineReset = (room, timelineSet) => { if (timelineSet !== this.props.timelineSet) return; if (this._messagePanel.current && this._messagePanel.current.isAtBottom()) { this._loadTimeline(); } - }, + }; - canResetTimeline: function() { - return this._messagePanel.current && this._messagePanel.current.isAtBottom(); - }, + canResetTimeline = () => this._messagePanel.current && this._messagePanel.current.isAtBottom(); - onRoomRedaction: function(ev, room) { + onRoomRedaction = (ev, room) => { if (this.unmounted) return; // ignore events for other rooms @@ -560,9 +555,9 @@ const TimelinePanel = createReactClass({ // we could skip an update if the event isn't in our timeline, // but that's probably an early optimisation. this.forceUpdate(); - }, + }; - onEventReplaced: function(replacedEvent, room) { + onEventReplaced = (replacedEvent, room) => { if (this.unmounted) return; // ignore events for other rooms @@ -571,27 +566,27 @@ const TimelinePanel = createReactClass({ // we could skip an update if the event isn't in our timeline, // but that's probably an early optimisation. this.forceUpdate(); - }, + }; - onRoomReceipt: function(ev, room) { + onRoomReceipt = (ev, room) => { if (this.unmounted) return; // ignore events for other rooms if (room !== this.props.timelineSet.room) return; this.forceUpdate(); - }, + }; - onLocalEchoUpdated: function(ev, room, oldEventId) { + onLocalEchoUpdated = (ev, room, oldEventId) => { if (this.unmounted) return; // ignore events for other rooms if (room !== this.props.timelineSet.room) return; this._reloadEvents(); - }, + }; - onAccountData: function(ev, room) { + onAccountData = (ev, room) => { if (this.unmounted) return; // ignore events for other rooms @@ -605,9 +600,9 @@ const TimelinePanel = createReactClass({ this.setState({ readMarkerEventId: ev.getContent().event_id, }, this.props.onReadMarkerUpdated); - }, + }; - onEventDecrypted: function(ev) { + onEventDecrypted = ev => { // Can be null for the notification timeline, etc. if (!this.props.timelineSet.room) return; @@ -620,19 +615,19 @@ const TimelinePanel = createReactClass({ if (ev.getRoomId() === this.props.timelineSet.room.roomId) { this.forceUpdate(); } - }, + }; - onSync: function(state, prevState, data) { + onSync = (state, prevState, data) => { this.setState({clientSyncState: state}); - }, + }; _readMarkerTimeout(readMarkerPosition) { return readMarkerPosition === 0 ? this.state.readMarkerInViewThresholdMs : this.state.readMarkerOutOfViewThresholdMs; - }, + } - updateReadMarkerOnUserActivity: async function() { + async updateReadMarkerOnUserActivity() { const initialTimeout = this._readMarkerTimeout(this.getReadMarkerPosition()); this._readMarkerActivityTimer = new Timer(initialTimeout); @@ -644,9 +639,9 @@ const TimelinePanel = createReactClass({ // outside of try/catch to not swallow errors this.updateReadMarker(); } - }, + } - updateReadReceiptOnUserActivity: async function() { + async updateReadReceiptOnUserActivity() { this._readReceiptActivityTimer = new Timer(READ_RECEIPT_INTERVAL_MS); while (this._readReceiptActivityTimer) { //unset on unmount UserActivity.sharedInstance().timeWhileActiveNow(this._readReceiptActivityTimer); @@ -656,9 +651,9 @@ const TimelinePanel = createReactClass({ // outside of try/catch to not swallow errors this.sendReadReceipt(); } - }, + } - sendReadReceipt: function() { + sendReadReceipt = () => { if (SettingsStore.getValue("lowBandwidth")) return; if (!this._messagePanel.current) return; @@ -766,11 +761,11 @@ const TimelinePanel = createReactClass({ }); } } - }, + }; // if the read marker is on the screen, we can now assume we've caught up to the end // of the screen, so move the marker down to the bottom of the screen. - updateReadMarker: function() { + updateReadMarker = () => { if (!this.props.manageReadMarkers) return; if (this.getReadMarkerPosition() === 1) { // the read marker is at an event below the viewport, @@ -801,11 +796,11 @@ const TimelinePanel = createReactClass({ // Send the updated read marker (along with read receipt) to the server this.sendReadReceipt(); - }, + }; // advance the read marker past any events we sent ourselves. - _advanceReadMarkerPastMyEvents: function() { + _advanceReadMarkerPastMyEvents() { if (!this.props.manageReadMarkers) return; // we call `_timelineWindow.getEvents()` rather than using @@ -837,11 +832,11 @@ const TimelinePanel = createReactClass({ const ev = events[i]; this._setReadMarker(ev.getId(), ev.getTs()); - }, + } /* jump down to the bottom of this room, where new events are arriving */ - jumpToLiveTimeline: function() { + jumpToLiveTimeline = () => { // if we can't forward-paginate the existing timeline, then there // is no point reloading it - just jump straight to the bottom. // @@ -854,12 +849,12 @@ const TimelinePanel = createReactClass({ this._messagePanel.current.scrollToBottom(); } } - }, + }; /* scroll to show the read-up-to marker. We put it 1/3 of the way down * the container. */ - jumpToReadMarker: function() { + jumpToReadMarker = () => { if (!this.props.manageReadMarkers) return; if (!this._messagePanel.current) return; if (!this.state.readMarkerEventId) return; @@ -883,11 +878,11 @@ const TimelinePanel = createReactClass({ // As with jumpToLiveTimeline, we want to reload the timeline around the // read-marker. this._loadTimeline(this.state.readMarkerEventId, 0, 1/3); - }, + }; /* update the read-up-to marker to match the read receipt */ - forgetReadMarker: function() { + forgetReadMarker = () => { if (!this.props.manageReadMarkers) return; const rmId = this._getCurrentReadReceipt(); @@ -903,17 +898,17 @@ const TimelinePanel = createReactClass({ } this._setReadMarker(rmId, rmTs); - }, + }; /* return true if the content is fully scrolled down and we are * at the end of the live timeline. */ - isAtEndOfLiveTimeline: function() { + isAtEndOfLiveTimeline = () => { return this._messagePanel.current && this._messagePanel.current.isAtBottom() && this._timelineWindow && !this._timelineWindow.canPaginate(EventTimeline.FORWARDS); - }, + } /* get the current scroll state. See ScrollPanel.getScrollState for @@ -921,10 +916,10 @@ const TimelinePanel = createReactClass({ * * returns null if we are not mounted. */ - getScrollState: function() { + getScrollState = () => { if (!this._messagePanel.current) { return null; } return this._messagePanel.current.getScrollState(); - }, + }; // returns one of: // @@ -932,7 +927,7 @@ const TimelinePanel = createReactClass({ // -1: read marker is above the window // 0: read marker is visible // +1: read marker is below the window - getReadMarkerPosition: function() { + getReadMarkerPosition = () => { if (!this.props.manageReadMarkers) return null; if (!this._messagePanel.current) return null; @@ -953,9 +948,9 @@ const TimelinePanel = createReactClass({ } return null; - }, + }; - canJumpToReadMarker: function() { + canJumpToReadMarker = () => { // 1. Do not show jump bar if neither the RM nor the RR are set. // 3. We want to show the bar if the read-marker is off the top of the screen. // 4. Also, if pos === null, the event might not be paginated - show the unread bar @@ -963,14 +958,14 @@ const TimelinePanel = createReactClass({ const ret = this.state.readMarkerEventId !== null && // 1. (pos < 0 || pos === null); // 3., 4. return ret; - }, + }; /* * called by the parent component when PageUp/Down/etc is pressed. * * We pass it down to the scroll panel. */ - handleScrollKey: function(ev) { + handleScrollKey = ev => { if (!this._messagePanel.current) { return; } // jump to the live timeline on ctrl-end, rather than the end of the @@ -980,9 +975,9 @@ const TimelinePanel = createReactClass({ } else { this._messagePanel.current.handleScrollKey(ev); } - }, + }; - _initTimeline: function(props) { + _initTimeline(props) { const initialEvent = props.eventId; const pixelOffset = props.eventPixelOffset; @@ -994,7 +989,7 @@ const TimelinePanel = createReactClass({ } return this._loadTimeline(initialEvent, pixelOffset, offsetBase); - }, + } /** * (re)-load the event timeline, and initialise the scroll state, centered @@ -1012,7 +1007,7 @@ const TimelinePanel = createReactClass({ * * returns a promise which will resolve when the load completes. */ - _loadTimeline: function(eventId, pixelOffset, offsetBase) { + _loadTimeline(eventId, pixelOffset, offsetBase) { this._timelineWindow = new Matrix.TimelineWindow( MatrixClientPeg.get(), this.props.timelineSet, {windowLimit: this.props.timelineCap}); @@ -1122,21 +1117,21 @@ const TimelinePanel = createReactClass({ }); prom.then(onLoaded, onError); } - }, + } // handle the completion of a timeline load or localEchoUpdate, by // reloading the events from the timelinewindow and pending event list into // the state. - _reloadEvents: function() { + _reloadEvents() { // we might have switched rooms since the load started - just bin // the results if so. if (this.unmounted) return; this.setState(this._getEvents()); - }, + } // get the list of events from the timeline window and the pending event list - _getEvents: function() { + _getEvents() { const events = this._timelineWindow.getEvents(); const firstVisibleEventIndex = this._checkForPreJoinUISI(events); @@ -1154,7 +1149,7 @@ const TimelinePanel = createReactClass({ liveEvents, firstVisibleEventIndex, }; - }, + } /** * Check for undecryptable messages that were sent while the user was not in @@ -1166,7 +1161,7 @@ const TimelinePanel = createReactClass({ * undecryptable event that was sent while the user was not in the room. If no * such events were found, then it returns 0. */ - _checkForPreJoinUISI: function(events) { + _checkForPreJoinUISI(events) { const room = this.props.timelineSet.room; if (events.length === 0 || !room || @@ -1228,18 +1223,18 @@ const TimelinePanel = createReactClass({ } } return 0; - }, + } - _indexForEventId: function(evId) { + _indexForEventId(evId) { for (let i = 0; i < this.state.events.length; ++i) { if (evId == this.state.events[i].getId()) { return i; } } return null; - }, + } - _getLastDisplayedEventIndex: function(opts) { + _getLastDisplayedEventIndex(opts) { opts = opts || {}; const ignoreOwn = opts.ignoreOwn || false; const allowPartial = opts.allowPartial || false; @@ -1313,7 +1308,7 @@ const TimelinePanel = createReactClass({ } return null; - }, + } /** * Get the id of the event corresponding to our user's latest read-receipt. @@ -1324,7 +1319,7 @@ const TimelinePanel = createReactClass({ * SDK. * @return {String} the event ID */ - _getCurrentReadReceipt: function(ignoreSynthesized) { + _getCurrentReadReceipt(ignoreSynthesized) { const client = MatrixClientPeg.get(); // the client can be null on logout if (client == null) { @@ -1333,9 +1328,9 @@ const TimelinePanel = createReactClass({ const myUserId = client.credentials.userId; return this.props.timelineSet.room.getEventReadUpTo(myUserId, ignoreSynthesized); - }, + } - _setReadMarker: function(eventId, eventTs, inhibitSetState) { + _setReadMarker(eventId, eventTs, inhibitSetState) { const roomId = this.props.timelineSet.room.roomId; // don't update the state (and cause a re-render) if there is @@ -1358,9 +1353,9 @@ const TimelinePanel = createReactClass({ this.setState({ readMarkerEventId: eventId, }, this.props.onReadMarkerUpdated); - }, + } - _shouldPaginate: function() { + _shouldPaginate() { // don't try to paginate while events in the timeline are // still being decrypted. We don't render events while they're // being decrypted, so they don't take up space in the timeline. @@ -1369,13 +1364,11 @@ const TimelinePanel = createReactClass({ return !this.state.events.some((e) => { return e.isBeingDecrypted(); }); - }, + } - getRelationsForEvent(...args) { - return this.props.timelineSet.getRelationsForEvent(...args); - }, + getRelationsForEvent = (...args) => this.props.timelineSet.getRelationsForEvent(...args); - render: function() { + render() { const MessagePanel = sdk.getComponent("structures.MessagePanel"); const Loader = sdk.getComponent("elements.Spinner"); @@ -1454,9 +1447,10 @@ const TimelinePanel = createReactClass({ editState={this.state.editState} showReactions={this.props.showReactions} useIRCLayout={this.props.useIRCLayout} + enableFlair={SettingsStore.getValue(UIFeature.Flair)} /> ); - }, -}); + } +} export default TimelinePanel; diff --git a/src/components/structures/UploadBar.js b/src/components/structures/UploadBar.js index 421d1d79a7..0865764c5a 100644 --- a/src/components/structures/UploadBar.js +++ b/src/components/structures/UploadBar.js @@ -16,30 +16,28 @@ limitations under the License. */ import React from 'react'; -import createReactClass from 'create-react-class'; import PropTypes from 'prop-types'; import ContentMessages from '../../ContentMessages'; import dis from "../../dispatcher/dispatcher"; import filesize from "filesize"; import { _t } from '../../languageHandler'; -export default createReactClass({ - displayName: 'UploadBar', - propTypes: { +export default class UploadBar extends React.Component { + static propTypes = { room: PropTypes.object, - }, + }; - componentDidMount: function() { + componentDidMount() { this.dispatcherRef = dis.register(this.onAction); this.mounted = true; - }, + } - componentWillUnmount: function() { + componentWillUnmount() { this.mounted = false; dis.unregister(this.dispatcherRef); - }, + } - onAction: function(payload) { + onAction = payload => { switch (payload.action) { case 'upload_progress': case 'upload_finished': @@ -48,9 +46,9 @@ export default createReactClass({ if (this.mounted) this.forceUpdate(); break; } - }, + }; - render: function() { + render() { const uploads = ContentMessages.sharedInstance().getCurrentUploads(); // for testing UI... - also fix up the ContentMessages.getCurrentUploads().length @@ -105,5 +103,5 @@ export default createReactClass({
    { uploadText }
    ); - }, -}); + } +} diff --git a/src/components/structures/UserMenu.tsx b/src/components/structures/UserMenu.tsx index e782618872..81d25e6a0c 100644 --- a/src/components/structures/UserMenu.tsx +++ b/src/components/structures/UserMenu.tsx @@ -40,8 +40,17 @@ import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton"; import { SettingLevel } from "../../settings/SettingLevel"; import IconizedContextMenu, { IconizedContextMenuOption, - IconizedContextMenuOptionList + IconizedContextMenuOptionList, } from "../views/context_menus/IconizedContextMenu"; +import { CommunityPrototypeStore } from "../../stores/CommunityPrototypeStore"; +import * as fbEmitter from "fbemitter"; +import GroupFilterOrderStore from "../../stores/GroupFilterOrderStore"; +import { showCommunityInviteDialog } from "../../RoomInvite"; +import dis from "../../dispatcher/dispatcher"; +import { RightPanelPhases } from "../../stores/RightPanelStorePhases"; +import ErrorDialog from "../views/dialogs/ErrorDialog"; +import EditCommunityPrototypeDialog from "../views/dialogs/EditCommunityPrototypeDialog"; +import {UIFeature} from "../../settings/UIFeature"; interface IProps { isMinimized: boolean; @@ -58,6 +67,7 @@ export default class UserMenu extends React.Component { private dispatcherRef: string; private themeWatcherRef: string; private buttonRef: React.RefObject = createRef(); + private tagStoreRef: fbEmitter.EventSubscription; constructor(props: IProps) { super(props); @@ -77,14 +87,20 @@ export default class UserMenu extends React.Component { public componentDidMount() { this.dispatcherRef = defaultDispatcher.register(this.onAction); this.themeWatcherRef = SettingsStore.watchSetting("theme", null, this.onThemeChanged); + this.tagStoreRef = GroupFilterOrderStore.addListener(this.onTagStoreUpdate); } public componentWillUnmount() { if (this.themeWatcherRef) SettingsStore.unwatchSetting(this.themeWatcherRef); if (this.dispatcherRef) defaultDispatcher.unregister(this.dispatcherRef); OwnProfileStore.instance.off(UPDATE_EVENT, this.onProfileUpdate); + this.tagStoreRef.remove(); } + private onTagStoreUpdate = () => { + this.forceUpdate(); // we don't have anything useful in state to update + }; + private isUserOnDarkTheme(): boolean { const theme = SettingsStore.getValue("theme"); if (theme.startsWith("custom-")) { @@ -189,9 +205,54 @@ export default class UserMenu extends React.Component { defaultDispatcher.dispatch({action: 'view_home_page'}); }; + private onCommunitySettingsClick = (ev: ButtonEvent) => { + ev.preventDefault(); + ev.stopPropagation(); + + Modal.createTrackedDialog('Edit Community', '', EditCommunityPrototypeDialog, { + communityId: CommunityPrototypeStore.instance.getSelectedCommunityId(), + }); + this.setState({contextMenuPosition: null}); // also close the menu + }; + + private onCommunityMembersClick = (ev: ButtonEvent) => { + ev.preventDefault(); + ev.stopPropagation(); + + // We'd ideally just pop open a right panel with the member list, but the current + // way the right panel is structured makes this exceedingly difficult. Instead, we'll + // switch to the general room and open the member list there as it should be in sync + // anyways. + const chat = CommunityPrototypeStore.instance.getSelectedCommunityGeneralChat(); + if (chat) { + dis.dispatch({ + action: 'view_room', + room_id: chat.roomId, + }, true); + dis.dispatch({action: Action.SetRightPanelPhase, phase: RightPanelPhases.RoomMemberList}); + } else { + // "This should never happen" clauses go here for the prototype. + Modal.createTrackedDialog('Failed to find general chat', '', ErrorDialog, { + title: _t('Failed to find the general chat for this community'), + description: _t("Failed to find the general chat for this community"), + }); + } + this.setState({contextMenuPosition: null}); // also close the menu + }; + + private onCommunityInviteClick = (ev: ButtonEvent) => { + ev.preventDefault(); + ev.stopPropagation(); + + showCommunityInviteDialog(CommunityPrototypeStore.instance.getSelectedCommunityId()); + this.setState({contextMenuPosition: null}); // also close the menu + }; + private renderContextMenu = (): React.ReactNode => { if (!this.state.contextMenuPosition) return null; + const prototypeCommunityName = CommunityPrototypeStore.instance.getSelectedCommunityName(); + let hostingLink; const signupLink = getHostingLink("user-context-menu"); if (signupLink) { @@ -225,22 +286,151 @@ export default class UserMenu extends React.Component { ); } + let feedbackButton; + if (SettingsStore.getValue(UIFeature.Feedback)) { + feedbackButton = ; + } + + let primaryHeader = ( +
    + + {OwnProfileStore.instance.displayName} + + + {MatrixClientPeg.get().getUserId()} + +
    + ); + let primaryOptionList = ( + + + {homeButton} + this.onSettingsOpen(e, USER_NOTIFICATIONS_TAB)} + /> + this.onSettingsOpen(e, USER_SECURITY_TAB)} + /> + this.onSettingsOpen(e, null)} + /> + {/* */} + { feedbackButton } + + + + + + ); + let secondarySection = null; + + if (prototypeCommunityName) { + const communityId = CommunityPrototypeStore.instance.getSelectedCommunityId(); + primaryHeader = ( +
    + + {prototypeCommunityName} + +
    + ); + let settingsOption; + let inviteOption; + if (CommunityPrototypeStore.instance.canInviteTo(communityId)) { + inviteOption = ( + + ); + } + if (CommunityPrototypeStore.instance.isAdminOf(communityId)) { + settingsOption = ( + + ); + } + primaryOptionList = ( + + {settingsOption} + + {inviteOption} + + ); + secondarySection = ( + +
    +
    +
    + + {OwnProfileStore.instance.displayName} + + + {MatrixClientPeg.get().getUserId()} + +
    +
    + + this.onSettingsOpen(e, null)} + /> + { feedbackButton } + + + + +
    + ) + } + + const classes = classNames({ + "mx_UserMenu_contextMenu": true, + "mx_UserMenu_contextMenu_prototype": !!prototypeCommunityName, + }); + return
    -
    - - {OwnProfileStore.instance.displayName} - - - {MatrixClientPeg.get().getUserId()} - -
    + {primaryHeader} {
    {hostingLink} - - {homeButton} - this.onSettingsOpen(e, USER_NOTIFICATIONS_TAB)} - /> - this.onSettingsOpen(e, USER_SECURITY_TAB)} - /> - this.onSettingsOpen(e, null)} - /> - {/* */} - - - - - + {primaryOptionList} + {secondarySection}
    ; }; public render() { const avatarSize = 32; // should match border-radius of the avatar - let name = {OwnProfileStore.instance.displayName}; + const displayName = OwnProfileStore.instance.displayName || MatrixClientPeg.get().getUserId(); + const avatarUrl = OwnProfileStore.instance.getHttpAvatarUrl(avatarSize); + + const prototypeCommunityName = CommunityPrototypeStore.instance.getSelectedCommunityName(); + + let isPrototype = false; + let menuName = _t("User menu"); + let name = {displayName}; let buttons = ( {/* masked image in CSS */} ); + if (prototypeCommunityName) { + name = ( +
    + {prototypeCommunityName} + {displayName} +
    + ); + menuName = _t("Community and user menu"); + isPrototype = true; + } else if (SettingsStore.getValue("feature_communities_v2_prototypes")) { + name = ( +
    + {_t("Home")} + {displayName} +
    + ); + isPrototype = true; + } if (this.props.isMinimized) { name = null; buttons = null; @@ -309,6 +491,7 @@ export default class UserMenu extends React.Component { const classes = classNames({ 'mx_UserMenu': true, 'mx_UserMenu_minimized': this.props.isMinimized, + 'mx_UserMenu_prototype': isPrototype, }); return ( @@ -317,16 +500,16 @@ export default class UserMenu extends React.Component { className={classes} onClick={this.onOpenMenuClick} inputRef={this.buttonRef} - label={_t("User menu")} + label={menuName} isExpanded={!!this.state.contextMenuPosition} onContextMenu={this.onContextMenu} >
    ; - return (); + return ( + + ); } else { return (
    ); } diff --git a/src/components/structures/ViewSource.js b/src/components/structures/ViewSource.js index 326ba2c22f..0b969784e5 100644 --- a/src/components/structures/ViewSource.js +++ b/src/components/structures/ViewSource.js @@ -17,24 +17,21 @@ limitations under the License. */ import React from 'react'; -import createReactClass from 'create-react-class'; import PropTypes from 'prop-types'; import SyntaxHighlight from '../views/elements/SyntaxHighlight'; import {_t} from "../../languageHandler"; import * as sdk from "../../index"; -export default createReactClass({ - displayName: 'ViewSource', - - propTypes: { +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, - }, + }; - render: function() { + render() { const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); return ( @@ -49,5 +46,5 @@ export default createReactClass({
    ); - }, -}); + } +} diff --git a/src/components/structures/auth/E2eSetup.js b/src/components/structures/auth/E2eSetup.js index 9b390d24cc..6df8158002 100644 --- a/src/components/structures/auth/E2eSetup.js +++ b/src/components/structures/auth/E2eSetup.js @@ -16,8 +16,9 @@ limitations under the License. import React from 'react'; import PropTypes from 'prop-types'; -import AsyncWrapper from '../../../AsyncWrapper'; -import * as sdk from '../../../index'; +import AuthPage from '../../views/auth/AuthPage'; +import CompleteSecurityBody from '../../views/auth/CompleteSecurityBody'; +import CreateCrossSigningDialog from '../../views/dialogs/security/CreateCrossSigningDialog'; export default class E2eSetup extends React.Component { static propTypes = { @@ -25,21 +26,11 @@ export default class E2eSetup extends React.Component { accountPassword: PropTypes.string, }; - constructor() { - super(); - // awkwardly indented because https://github.com/eslint/eslint/issues/11310 - this._createStorageDialogPromise = - import("../../../async-components/views/dialogs/secretstorage/CreateSecretStorageDialog"); - } - render() { - const AuthPage = sdk.getComponent("auth.AuthPage"); - const CompleteSecurityBody = sdk.getComponent("auth.CompleteSecurityBody"); return ( - diff --git a/src/components/structures/auth/ForgotPassword.js b/src/components/structures/auth/ForgotPassword.js index 9877c53106..3fa2713a35 100644 --- a/src/components/structures/auth/ForgotPassword.js +++ b/src/components/structures/auth/ForgotPassword.js @@ -17,7 +17,6 @@ limitations under the License. */ import React from 'react'; -import createReactClass from 'create-react-class'; import PropTypes from 'prop-types'; import { _t } from '../../../languageHandler'; import * as sdk from '../../../index'; @@ -40,50 +39,47 @@ const PHASE_EMAIL_SENT = 3; // User has clicked the link in email and completed reset const PHASE_DONE = 4; -export default createReactClass({ - displayName: 'ForgotPassword', - - propTypes: { +export default class ForgotPassword extends React.Component { + static propTypes = { serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired, onServerConfigChange: PropTypes.func.isRequired, onLoginClick: PropTypes.func, onComplete: PropTypes.func.isRequired, - }, + }; - getInitialState: function() { - return { - phase: PHASE_FORGOT, - email: "", - password: "", - password2: "", - errorText: null, + state = { + phase: PHASE_FORGOT, + email: "", + password: "", + password2: "", + errorText: null, - // We perform liveliness checks later, but for now suppress the errors. - // We also track the server dead errors independently of the regular errors so - // that we can render it differently, and override any other error the user may - // be seeing. - serverIsAlive: true, - serverErrorIsFatal: false, - serverDeadError: "", - serverRequiresIdServer: null, - }; - }, + // We perform liveliness checks later, but for now suppress the errors. + // We also track the server dead errors independently of the regular errors so + // that we can render it differently, and override any other error the user may + // be seeing. + serverIsAlive: true, + serverErrorIsFatal: false, + serverDeadError: "", + serverRequiresIdServer: null, + }; - componentDidMount: function() { + componentDidMount() { this.reset = null; this._checkServerLiveliness(this.props.serverConfig); - }, + } // TODO: [REACT-WARNING] Replace with appropriate lifecycle event - UNSAFE_componentWillReceiveProps: function(newProps) { + // eslint-disable-next-line camelcase + UNSAFE_componentWillReceiveProps(newProps) { if (newProps.serverConfig.hsUrl === this.props.serverConfig.hsUrl && newProps.serverConfig.isUrl === this.props.serverConfig.isUrl) return; // Do a liveliness check on the new URLs this._checkServerLiveliness(newProps.serverConfig); - }, + } - _checkServerLiveliness: async function(serverConfig) { + async _checkServerLiveliness(serverConfig) { try { await AutoDiscoveryUtils.validateServerConfigWithStaticUrls( serverConfig.hsUrl, @@ -100,9 +96,9 @@ export default createReactClass({ } catch (e) { this.setState(AutoDiscoveryUtils.authComponentStateForError(e, "forgot_password")); } - }, + } - submitPasswordReset: function(email, password) { + submitPasswordReset(email, password) { this.setState({ phase: PHASE_SENDING_EMAIL, }); @@ -117,9 +113,9 @@ export default createReactClass({ phase: PHASE_FORGOT, }); }); - }, + } - onVerify: async function(ev) { + onVerify = async ev => { ev.preventDefault(); if (!this.reset) { console.error("onVerify called before submitPasswordReset!"); @@ -131,9 +127,9 @@ export default createReactClass({ } catch (err) { this.showErrorDialog(err.message); } - }, + }; - onSubmitForm: async function(ev) { + onSubmitForm = async ev => { ev.preventDefault(); // refresh the server errors, just in case the server came back online @@ -166,41 +162,41 @@ export default createReactClass({ }, }); } - }, + }; - onInputChanged: function(stateKey, ev) { + onInputChanged = (stateKey, ev) => { this.setState({ [stateKey]: ev.target.value, }); - }, + }; - async onServerDetailsNextPhaseClick() { + onServerDetailsNextPhaseClick = async () => { this.setState({ phase: PHASE_FORGOT, }); - }, + }; - onEditServerDetailsClick(ev) { + onEditServerDetailsClick = ev => { ev.preventDefault(); ev.stopPropagation(); this.setState({ phase: PHASE_SERVER_DETAILS, }); - }, + }; - onLoginClick: function(ev) { + onLoginClick = ev => { ev.preventDefault(); ev.stopPropagation(); this.props.onLoginClick(); - }, + }; - showErrorDialog: function(body, title) { + showErrorDialog(body, title) { const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createTrackedDialog('Forgot Password Error', '', ErrorDialog, { title: title, description: body, }); - }, + } renderServerDetails() { const ServerConfig = sdk.getComponent("auth.ServerConfig"); @@ -218,7 +214,7 @@ export default createReactClass({ submitText={_t("Next")} submitClass="mx_Login_submit" />; - }, + } renderForgot() { const Field = sdk.getComponent('elements.Field'); @@ -335,12 +331,12 @@ export default createReactClass({ {_t('Sign in instead')}
    ; - }, + } renderSendingEmail() { const Spinner = sdk.getComponent("elements.Spinner"); return ; - }, + } renderEmailSent() { return
    @@ -350,7 +346,7 @@ export default createReactClass({
    ; - }, + } renderDone() { return
    @@ -363,9 +359,9 @@ export default createReactClass({
    ; - }, + } - render: function() { + render() { const AuthHeader = sdk.getComponent("auth.AuthHeader"); const AuthBody = sdk.getComponent("auth.AuthBody"); @@ -397,5 +393,5 @@ export default createReactClass({ ); - }, -}); + } +} diff --git a/src/components/structures/auth/Login.js b/src/components/structures/auth/Login.js index 3bc363f863..118eed59e3 100644 --- a/src/components/structures/auth/Login.js +++ b/src/components/structures/auth/Login.js @@ -17,7 +17,6 @@ limitations under the License. */ import React from 'react'; -import createReactClass from 'create-react-class'; import PropTypes from 'prop-types'; import {_t, _td} from '../../../languageHandler'; import * as sdk from '../../../index'; @@ -29,6 +28,8 @@ import classNames from "classnames"; import AuthPage from "../../views/auth/AuthPage"; import SSOButton from "../../views/elements/SSOButton"; import PlatformPeg from '../../../PlatformPeg'; +import SettingsStore from "../../../settings/SettingsStore"; +import {UIFeature} from "../../../settings/UIFeature"; // For validating phone numbers without country codes const PHONE_NUMBER_REGEX = /^[0-9()\-\s]*$/; @@ -53,13 +54,11 @@ _td("Invalid base_url for m.identity_server"); _td("Identity server URL does not appear to be a valid identity server"); _td("General failure"); -/** +/* * A wire component which glues together login UI components and Login logic */ -export default createReactClass({ - displayName: 'Login', - - propTypes: { +export default class LoginComponent extends React.Component { + static propTypes = { // Called when the user has logged in. Params: // - The object returned by the login API // - The user's password, if applicable, (may be cached in memory for a @@ -85,10 +84,14 @@ export default createReactClass({ serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired, isSyncing: PropTypes.bool, - }, + }; - getInitialState: function() { - return { + constructor(props) { + super(props); + + this._unmounted = false; + + this.state = { busy: false, busyLoggingIn: null, errorText: null, @@ -113,11 +116,6 @@ export default createReactClass({ serverErrorIsFatal: false, serverDeadError: "", }; - }, - - // TODO: [REACT-WARNING] Move this to constructor - UNSAFE_componentWillMount: function() { - this._unmounted = false; // map from login step type to a function which will render a control // letting you do that login type @@ -128,35 +126,38 @@ export default createReactClass({ 'm.login.cas': () => this._renderSsoStep("cas"), 'm.login.sso': () => this._renderSsoStep("sso"), }; - - this._initLoginLogic(); - }, - - componentWillUnmount: function() { - this._unmounted = true; - }, + } // TODO: [REACT-WARNING] Replace with appropriate lifecycle event + // eslint-disable-next-line camelcase + UNSAFE_componentWillMount() { + this._initLoginLogic(); + } + + componentWillUnmount() { + this._unmounted = true; + } + + // TODO: [REACT-WARNING] Replace with appropriate lifecycle event + // eslint-disable-next-line camelcase UNSAFE_componentWillReceiveProps(newProps) { if (newProps.serverConfig.hsUrl === this.props.serverConfig.hsUrl && newProps.serverConfig.isUrl === this.props.serverConfig.isUrl) return; // Ensure that we end up actually logging in to the right place this._initLoginLogic(newProps.serverConfig.hsUrl, newProps.serverConfig.isUrl); - }, + } - onPasswordLoginError: function(errorText) { + onPasswordLoginError = errorText => { this.setState({ errorText, loginIncorrect: Boolean(errorText), }); - }, + }; - isBusy: function() { - return this.state.busy || this.props.busy; - }, + isBusy = () => this.state.busy || this.props.busy; - onPasswordLogin: async function(username, phoneCountry, phoneNumber, password) { + onPasswordLogin = async (username, phoneCountry, phoneNumber, password) => { if (!this.state.serverIsAlive) { this.setState({busy: true}); // Do a quick liveliness check on the URLs @@ -263,13 +264,13 @@ export default createReactClass({ loginIncorrect: error.httpStatus === 401 || error.httpStatus === 403, }); }); - }, + }; - onUsernameChanged: function(username) { + onUsernameChanged = username => { this.setState({ username: username }); - }, + }; - onUsernameBlur: async function(username) { + onUsernameBlur = async username => { const doWellknownLookup = username[0] === "@"; this.setState({ username: username, @@ -314,19 +315,19 @@ export default createReactClass({ }); } } - }, + }; - onPhoneCountryChanged: function(phoneCountry) { + onPhoneCountryChanged = phoneCountry => { this.setState({ phoneCountry: phoneCountry }); - }, + }; - onPhoneNumberChanged: function(phoneNumber) { + onPhoneNumberChanged = phoneNumber => { this.setState({ phoneNumber: phoneNumber, }); - }, + }; - onPhoneNumberBlur: function(phoneNumber) { + onPhoneNumberBlur = phoneNumber => { // Validate the phone number entered if (!PHONE_NUMBER_REGEX.test(phoneNumber)) { this.setState({ @@ -339,15 +340,15 @@ export default createReactClass({ canTryLogin: true, }); } - }, + }; - onRegisterClick: function(ev) { + onRegisterClick = ev => { ev.preventDefault(); ev.stopPropagation(); this.props.onRegisterClick(); - }, + }; - onTryRegisterClick: function(ev) { + onTryRegisterClick = ev => { const step = this._getCurrentFlowStep(); if (step === 'm.login.sso' || step === 'm.login.cas') { // If we're showing SSO it means that registration is also probably disabled, @@ -361,23 +362,23 @@ export default createReactClass({ // Don't intercept - just go through to the register page this.onRegisterClick(ev); } - }, + }; - async onServerDetailsNextPhaseClick() { + onServerDetailsNextPhaseClick = () => { this.setState({ phase: PHASE_LOGIN, }); - }, + }; - onEditServerDetailsClick(ev) { + onEditServerDetailsClick = ev => { ev.preventDefault(); ev.stopPropagation(); this.setState({ phase: PHASE_SERVER_DETAILS, }); - }, + }; - _initLoginLogic: async function(hsUrl, isUrl) { + async _initLoginLogic(hsUrl, isUrl) { hsUrl = hsUrl || this.props.serverConfig.hsUrl; isUrl = isUrl || this.props.serverConfig.isUrl; @@ -465,9 +466,9 @@ export default createReactClass({ busy: false, }); }); - }, + } - _isSupportedFlow: function(flow) { + _isSupportedFlow(flow) { // technically the flow can have multiple steps, but no one does this // for login and loginLogic doesn't support it so we can ignore it. if (!this._stepRendererMap[flow.type]) { @@ -475,11 +476,11 @@ export default createReactClass({ return false; } return true; - }, + } - _getCurrentFlowStep: function() { + _getCurrentFlowStep() { return this._loginLogic ? this._loginLogic.getCurrentFlowStep() : null; - }, + } _errorTextFromError(err) { let errCode = err.errcode; @@ -526,7 +527,7 @@ export default createReactClass({ } return errorText; - }, + } renderServerComponent() { const ServerConfig = sdk.getComponent("auth.ServerConfig"); @@ -552,7 +553,7 @@ export default createReactClass({ delayTimeMs={250} {...serverDetailsProps} />; - }, + } renderLoginComponentForStep() { if (PHASES_ENABLED && this.state.phase !== PHASE_LOGIN) { @@ -572,9 +573,9 @@ export default createReactClass({ } return null; - }, + } - _renderPasswordStep: function() { + _renderPasswordStep = () => { const PasswordLogin = sdk.getComponent('auth.PasswordLogin'); let onEditServerDetailsClick = null; @@ -603,9 +604,9 @@ export default createReactClass({ busy={this.props.isSyncing || this.state.busyLoggingIn} /> ); - }, + }; - _renderSsoStep: function(loginType) { + _renderSsoStep = loginType => { const SignInToText = sdk.getComponent('views.auth.SignInToText'); let onEditServerDetailsClick = null; @@ -634,9 +635,9 @@ export default createReactClass({ /> ); - }, + }; - render: function() { + render() { const Loader = sdk.getComponent("elements.Spinner"); const InlineSpinner = sdk.getComponent("elements.InlineSpinner"); const AuthHeader = sdk.getComponent("auth.AuthHeader"); @@ -680,7 +681,7 @@ export default createReactClass({ {_t("If you've joined lots of rooms, this might take a while")} } ; - } else { + } else if (SettingsStore.getValue(UIFeature.Registration)) { footer = ( { _t('Create account') } @@ -704,5 +705,5 @@ export default createReactClass({ ); - }, -}); + } +} diff --git a/src/components/structures/auth/PostRegistration.js b/src/components/structures/auth/PostRegistration.js index 687ab9a195..aa36de6596 100644 --- a/src/components/structures/auth/PostRegistration.js +++ b/src/components/structures/auth/PostRegistration.js @@ -15,29 +15,24 @@ limitations under the License. */ import React from 'react'; -import createReactClass from 'create-react-class'; import PropTypes from 'prop-types'; import * as sdk from '../../../index'; import {MatrixClientPeg} from '../../../MatrixClientPeg'; import { _t } from '../../../languageHandler'; import AuthPage from "../../views/auth/AuthPage"; -export default createReactClass({ - displayName: 'PostRegistration', - - propTypes: { +export default class PostRegistration extends React.Component { + static propTypes = { onComplete: PropTypes.func.isRequired, - }, + }; - getInitialState: function() { - return { - avatarUrl: null, - errorString: null, - busy: false, - }; - }, + state = { + avatarUrl: null, + errorString: null, + busy: false, + }; - componentDidMount: function() { + componentDidMount() { // There is some assymetry between ChangeDisplayName and ChangeAvatar, // as ChangeDisplayName will auto-get the name but ChangeAvatar expects // the URL to be passed to you (because it's also used for room avatars). @@ -55,9 +50,9 @@ export default createReactClass({ busy: false, }); }); - }, + } - render: function() { + render() { const ChangeDisplayName = sdk.getComponent('settings.ChangeDisplayName'); const ChangeAvatar = sdk.getComponent('settings.ChangeAvatar'); const AuthHeader = sdk.getComponent('auth.AuthHeader'); @@ -78,5 +73,5 @@ export default createReactClass({ ); - }, -}); + } +} diff --git a/src/components/structures/auth/Registration.js b/src/components/structures/auth/Registration.js index 13e48f6287..630e04da9c 100644 --- a/src/components/structures/auth/Registration.js +++ b/src/components/structures/auth/Registration.js @@ -19,7 +19,6 @@ limitations under the License. import Matrix from 'matrix-js-sdk'; import React from 'react'; -import createReactClass from 'create-react-class'; import PropTypes from 'prop-types'; import * as sdk from '../../../index'; import { _t, _td } from '../../../languageHandler'; @@ -43,10 +42,8 @@ const PHASE_REGISTRATION = 1; // Enable phases for registration const PHASES_ENABLED = true; -export default createReactClass({ - displayName: 'Registration', - - propTypes: { +export default class Registration extends React.Component { + static propTypes = { // Called when the user has logged in. Params: // - object with userId, deviceId, homeserverUrl, identityServerUrl, accessToken // - The user's password, if available and applicable (may be cached in memory @@ -65,12 +62,13 @@ export default createReactClass({ onLoginClick: PropTypes.func.isRequired, onServerConfigChange: PropTypes.func.isRequired, defaultDeviceDisplayName: PropTypes.string, - }, + }; + + constructor(props) { + super(props); - getInitialState: function() { const serverType = ServerType.getTypeFromServerConfig(this.props.serverConfig); - - return { + this.state = { busy: false, errorText: null, // We remember the values entered by the user because @@ -118,14 +116,15 @@ export default createReactClass({ // this is the user ID that's logged in. differentLoggedInUserId: null, }; - }, + } - componentDidMount: function() { + componentDidMount() { this._unmounted = false; this._replaceClient(); - }, + } // TODO: [REACT-WARNING] Replace with appropriate lifecycle event + // eslint-disable-next-line camelcase UNSAFE_componentWillReceiveProps(newProps) { if (newProps.serverConfig.hsUrl === this.props.serverConfig.hsUrl && newProps.serverConfig.isUrl === this.props.serverConfig.isUrl) return; @@ -142,7 +141,7 @@ export default createReactClass({ phase: this.getDefaultPhaseForServerType(serverType), }); } - }, + } getDefaultPhaseForServerType(type) { switch (type) { @@ -155,9 +154,9 @@ export default createReactClass({ case ServerType.ADVANCED: return PHASE_SERVER_DETAILS; } - }, + } - onServerTypeChange(type) { + onServerTypeChange = type => { this.setState({ serverType: type, }); @@ -184,9 +183,9 @@ export default createReactClass({ this.setState({ phase: this.getDefaultPhaseForServerType(type), }); - }, + }; - _replaceClient: async function(serverConfig) { + async _replaceClient(serverConfig) { this.setState({ errorText: null, serverDeadError: null, @@ -286,18 +285,18 @@ export default createReactClass({ showGenericError(e); } } - }, + } - onFormSubmit: function(formVals) { + onFormSubmit = formVals => { this.setState({ errorText: "", busy: true, formVals: formVals, doingUIAuth: true, }); - }, + }; - _requestEmailToken: function(emailAddress, clientSecret, sendAttempt, sessionId) { + _requestEmailToken = (emailAddress, clientSecret, sendAttempt, sessionId) => { return this.state.matrixClient.requestRegisterEmailToken( emailAddress, clientSecret, @@ -309,9 +308,9 @@ export default createReactClass({ session_id: sessionId, }), ); - }, + } - _onUIAuthFinished: async function(success, response, extra) { + _onUIAuthFinished = async (success, response, extra) => { if (!success) { let msg = response.message || response.toString(); // can we give a better error message? @@ -395,9 +394,9 @@ export default createReactClass({ } this.setState(newState); - }, + }; - _setupPushers: function() { + _setupPushers() { if (!this.props.brand) { return Promise.resolve(); } @@ -418,15 +417,15 @@ export default createReactClass({ }, (error) => { console.error("Couldn't get pushers: " + error); }); - }, + } - onLoginClick: function(ev) { + onLoginClick = ev => { ev.preventDefault(); ev.stopPropagation(); this.props.onLoginClick(); - }, + }; - onGoToFormClicked(ev) { + onGoToFormClicked = ev => { ev.preventDefault(); ev.stopPropagation(); this._replaceClient(); @@ -435,23 +434,23 @@ export default createReactClass({ doingUIAuth: false, phase: PHASE_REGISTRATION, }); - }, + }; - async onServerDetailsNextPhaseClick() { + onServerDetailsNextPhaseClick = async () => { this.setState({ phase: PHASE_REGISTRATION, }); - }, + }; - onEditServerDetailsClick(ev) { + onEditServerDetailsClick = ev => { ev.preventDefault(); ev.stopPropagation(); this.setState({ phase: PHASE_SERVER_DETAILS, }); - }, + }; - _makeRegisterRequest: function(auth) { + _makeRegisterRequest = auth => { // We inhibit login if we're trying to register with an email address: this // avoids a lot of complex race conditions that can occur if we try to log // the user in one one or both of the tabs they might end up with after @@ -471,20 +470,20 @@ export default createReactClass({ if (auth) registerParams.auth = auth; if (inhibitLogin !== undefined && inhibitLogin !== null) registerParams.inhibit_login = inhibitLogin; return this.state.matrixClient.registerRequest(registerParams); - }, + }; - _getUIAuthInputs: function() { + _getUIAuthInputs() { return { emailAddress: this.state.formVals.email, phoneCountry: this.state.formVals.phoneCountry, phoneNumber: this.state.formVals.phoneNumber, }; - }, + } // Links to the login page shown after registration is completed are routed through this // which checks the user hasn't already logged in somewhere else (perhaps we should do // this more generally?) - _onLoginClickWithCheck: async function(ev) { + _onLoginClickWithCheck = async ev => { ev.preventDefault(); const sessionLoaded = await Lifecycle.loadSession({ignoreGuest: true}); @@ -492,7 +491,7 @@ export default createReactClass({ // ok fine, there's still no session: really go to the login page this.props.onLoginClick(); } - }, + }; renderServerComponent() { const ServerTypeSelector = sdk.getComponent("auth.ServerTypeSelector"); @@ -553,7 +552,7 @@ export default createReactClass({ /> {serverDetails} ; - }, + } renderRegisterComponent() { if (PHASES_ENABLED && this.state.phase !== PHASE_REGISTRATION) { @@ -608,9 +607,9 @@ export default createReactClass({ serverRequiresIdServer={this.state.serverRequiresIdServer} />; } - }, + } - render: function() { + render() { const AuthHeader = sdk.getComponent('auth.AuthHeader'); const AuthBody = sdk.getComponent("auth.AuthBody"); const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); @@ -706,5 +705,5 @@ export default createReactClass({ ); - }, -}); + } +} diff --git a/src/components/views/auth/AuthFooter.js b/src/components/views/auth/AuthFooter.js index 1309800772..3de5a19350 100644 --- a/src/components/views/auth/AuthFooter.js +++ b/src/components/views/auth/AuthFooter.js @@ -18,16 +18,13 @@ limitations under the License. import { _t } from '../../../languageHandler'; import React from 'react'; -import createReactClass from 'create-react-class'; -export default createReactClass({ - displayName: 'AuthFooter', - - render: function() { +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 6e787ba77c..57499e397c 100644 --- a/src/components/views/auth/AuthHeader.js +++ b/src/components/views/auth/AuthHeader.js @@ -17,17 +17,14 @@ limitations under the License. import React from 'react'; import PropTypes from 'prop-types'; -import createReactClass from 'create-react-class'; import * as sdk from '../../../index'; -export default createReactClass({ - displayName: 'AuthHeader', - - propTypes: { +export default class AuthHeader extends React.Component { + static propTypes = { disableLanguageSelector: PropTypes.bool, - }, + }; - render: function() { + render() { const AuthHeaderLogo = sdk.getComponent('auth.AuthHeaderLogo'); const LanguageSelector = sdk.getComponent('views.auth.LanguageSelector'); @@ -37,5 +34,5 @@ export default createReactClass({ ); - }, -}); + } +} diff --git a/src/components/views/auth/CaptchaForm.js b/src/components/views/auth/CaptchaForm.js index e162603b01..783d519621 100644 --- a/src/components/views/auth/CaptchaForm.js +++ b/src/components/views/auth/CaptchaForm.js @@ -15,7 +15,6 @@ limitations under the License. */ import React, {createRef} from 'react'; -import createReactClass from 'create-react-class'; import PropTypes from 'prop-types'; import { _t } from '../../../languageHandler'; @@ -24,36 +23,31 @@ const DIV_ID = 'mx_recaptcha'; /** * A pure UI component which displays a captcha form. */ -export default createReactClass({ - displayName: 'CaptchaForm', - - propTypes: { +export default class CaptchaForm extends React.Component { + static propTypes = { sitePublicKey: PropTypes.string, // called with the captcha response onCaptchaResponse: PropTypes.func, - }, + }; - getDefaultProps: function() { - return { - onCaptchaResponse: () => {}, - }; - }, + static defaultProps = { + onCaptchaResponse: () => {}, + }; - getInitialState: function() { - return { + constructor(props) { + super(props); + + this.state = { errorText: null, }; - }, - // TODO: [REACT-WARNING] Replace component with real class, use constructor for refs - UNSAFE_componentWillMount: function() { this._captchaWidgetId = null; this._recaptchaContainer = createRef(); - }, + } - componentDidMount: function() { + componentDidMount() { // Just putting a script tag into the returned jsx doesn't work, annoyingly, // so we do this instead. if (global.grecaptcha) { @@ -68,13 +62,13 @@ export default createReactClass({ ); this._recaptchaContainer.current.appendChild(scriptTag); } - }, + } - componentWillUnmount: function() { + componentWillUnmount() { this._resetRecaptcha(); - }, + } - _renderRecaptcha: function(divId) { + _renderRecaptcha(divId) { if (!global.grecaptcha) { console.error("grecaptcha not loaded!"); throw new Error("Recaptcha did not load successfully"); @@ -93,15 +87,15 @@ export default createReactClass({ sitekey: publicKey, callback: this.props.onCaptchaResponse, }); - }, + } - _resetRecaptcha: function() { + _resetRecaptcha() { if (this._captchaWidgetId !== null) { global.grecaptcha.reset(this._captchaWidgetId); } - }, + } - _onCaptchaLoaded: function() { + _onCaptchaLoaded() { console.log("Loaded recaptcha script."); try { this._renderRecaptcha(DIV_ID); @@ -110,9 +104,9 @@ export default createReactClass({ errorText: e.toString(), }); } - }, + } - render: function() { + render() { let error = null; if (this.state.errorText) { error = ( @@ -131,5 +125,5 @@ export default createReactClass({ { error } ); - }, -}); + } +} diff --git a/src/components/views/auth/CustomServerDialog.js b/src/components/views/auth/CustomServerDialog.js index 7b2c8f88aa..138f8c4689 100644 --- a/src/components/views/auth/CustomServerDialog.js +++ b/src/components/views/auth/CustomServerDialog.js @@ -16,14 +16,11 @@ limitations under the License. */ import React from 'react'; -import createReactClass from 'create-react-class'; import { _t } from '../../../languageHandler'; import SdkConfig from '../../../SdkConfig'; -export default createReactClass({ - displayName: 'CustomServerDialog', - - render: function() { +export default class CustomServerDialog extends React.Component { + render() { const brand = SdkConfig.get().brand; return (
    @@ -46,5 +43,5 @@ export default createReactClass({
    ); - }, -}); + } +} diff --git a/src/components/views/auth/InteractiveAuthEntryComponents.js b/src/components/views/auth/InteractiveAuthEntryComponents.js index 7080eb3602..47263c1e21 100644 --- a/src/components/views/auth/InteractiveAuthEntryComponents.js +++ b/src/components/views/auth/InteractiveAuthEntryComponents.js @@ -17,7 +17,6 @@ limitations under the License. */ import React, {createRef} from 'react'; -import createReactClass from 'create-react-class'; import PropTypes from 'prop-types'; import url from 'url'; import classnames from 'classnames'; @@ -26,6 +25,7 @@ import * as sdk from '../../../index'; import { _t } from '../../../languageHandler'; import SettingsStore from "../../../settings/SettingsStore"; import AccessibleButton from "../elements/AccessibleButton"; +import Spinner from "../elements/Spinner"; /* This file contains a collection of components which are used by the * InteractiveAuth to prompt the user to enter the information needed @@ -75,14 +75,10 @@ import AccessibleButton from "../elements/AccessibleButton"; export const DEFAULT_PHASE = 0; -export const PasswordAuthEntry = createReactClass({ - displayName: 'PasswordAuthEntry', +export class PasswordAuthEntry extends React.Component { + static LOGIN_TYPE = "m.login.password"; - statics: { - LOGIN_TYPE: "m.login.password", - }, - - propTypes: { + static propTypes = { matrixClient: PropTypes.object.isRequired, submitAuthDict: PropTypes.func.isRequired, errorText: PropTypes.string, @@ -90,19 +86,17 @@ export const PasswordAuthEntry = createReactClass({ // happen? busy: PropTypes.bool, onPhaseChange: PropTypes.func.isRequired, - }, + }; - componentDidMount: function() { + componentDidMount() { this.props.onPhaseChange(DEFAULT_PHASE); - }, + } - getInitialState: function() { - return { - password: "", - }; - }, + state = { + password: "", + }; - _onSubmit: function(e) { + _onSubmit = e => { e.preventDefault(); if (this.props.busy) return; @@ -117,16 +111,16 @@ export const PasswordAuthEntry = createReactClass({ }, password: this.state.password, }); - }, + }; - _onPasswordFieldChange: function(ev) { + _onPasswordFieldChange = ev => { // enable the submit button iff the password is non-empty this.setState({ password: ev.target.value, }); - }, + }; - render: function() { + render() { const passwordBoxClass = classnames({ "error": this.props.errorText, }); @@ -176,36 +170,32 @@ export const PasswordAuthEntry = createReactClass({ { errorSection } ); - }, -}); + } +} -export const RecaptchaAuthEntry = createReactClass({ - displayName: 'RecaptchaAuthEntry', +export class RecaptchaAuthEntry extends React.Component { + static LOGIN_TYPE = "m.login.recaptcha"; - statics: { - LOGIN_TYPE: "m.login.recaptcha", - }, - - propTypes: { + static propTypes = { submitAuthDict: PropTypes.func.isRequired, stageParams: PropTypes.object.isRequired, errorText: PropTypes.string, busy: PropTypes.bool, onPhaseChange: PropTypes.func.isRequired, - }, + }; - componentDidMount: function() { + componentDidMount() { this.props.onPhaseChange(DEFAULT_PHASE); - }, + } - _onCaptchaResponse: function(response) { + _onCaptchaResponse = response => { this.props.submitAuthDict({ type: RecaptchaAuthEntry.LOGIN_TYPE, response: response, }); - }, + }; - render: function() { + render() { if (this.props.busy) { const Loader = sdk.getComponent("elements.Spinner"); return ; @@ -241,31 +231,24 @@ export const RecaptchaAuthEntry = createReactClass({ { errorSection } ); - }, -}); + } +} -export const TermsAuthEntry = createReactClass({ - displayName: 'TermsAuthEntry', +export class TermsAuthEntry extends React.Component { + static LOGIN_TYPE = "m.login.terms"; - statics: { - LOGIN_TYPE: "m.login.terms", - }, - - propTypes: { + static propTypes = { submitAuthDict: PropTypes.func.isRequired, stageParams: PropTypes.object.isRequired, errorText: PropTypes.string, busy: PropTypes.bool, showContinue: PropTypes.bool, onPhaseChange: PropTypes.func.isRequired, - }, + }; - componentDidMount: function() { - this.props.onPhaseChange(DEFAULT_PHASE); - }, + constructor(props) { + super(props); - // TODO: [REACT-WARNING] Move this to constructor - componentWillMount: function() { // example stageParams: // // { @@ -310,17 +293,22 @@ export const TermsAuthEntry = createReactClass({ pickedPolicies.push(langPolicy); } - this.setState({ - "toggledPolicies": initToggles, - "policies": pickedPolicies, - }); - }, + this.state = { + toggledPolicies: initToggles, + policies: pickedPolicies, + }; + } - tryContinue: function() { + + componentDidMount() { + this.props.onPhaseChange(DEFAULT_PHASE); + } + + tryContinue = () => { this._trySubmit(); - }, + }; - _togglePolicy: function(policyId) { + _togglePolicy(policyId) { const newToggles = {}; for (const policy of this.state.policies) { let checked = this.state.toggledPolicies[policy.id]; @@ -329,9 +317,9 @@ export const TermsAuthEntry = createReactClass({ newToggles[policy.id] = checked; } this.setState({"toggledPolicies": newToggles}); - }, + } - _trySubmit: function() { + _trySubmit = () => { let allChecked = true; for (const policy of this.state.policies) { const checked = this.state.toggledPolicies[policy.id]; @@ -340,9 +328,9 @@ export const TermsAuthEntry = createReactClass({ if (allChecked) this.props.submitAuthDict({type: TermsAuthEntry.LOGIN_TYPE}); else this.setState({errorText: _t("Please review and accept all of the homeserver's policies")}); - }, + }; - render: function() { + render() { if (this.props.busy) { const Loader = sdk.getComponent("elements.Spinner"); return ; @@ -387,17 +375,13 @@ export const TermsAuthEntry = createReactClass({ { submitButton } ); - }, -}); + } +} -export const EmailIdentityAuthEntry = createReactClass({ - displayName: 'EmailIdentityAuthEntry', +export class EmailIdentityAuthEntry extends React.Component { + static LOGIN_TYPE = "m.login.email.identity"; - statics: { - LOGIN_TYPE: "m.login.email.identity", - }, - - propTypes: { + static propTypes = { matrixClient: PropTypes.object.isRequired, submitAuthDict: PropTypes.func.isRequired, authSessionId: PropTypes.string.isRequired, @@ -407,13 +391,13 @@ export const EmailIdentityAuthEntry = createReactClass({ fail: PropTypes.func.isRequired, setEmailSid: PropTypes.func.isRequired, onPhaseChange: PropTypes.func.isRequired, - }, + }; - componentDidMount: function() { + componentDidMount() { this.props.onPhaseChange(DEFAULT_PHASE); - }, + } - render: function() { + render() { // This component is now only displayed once the token has been requested, // so we know the email has been sent. It can also get loaded after the user // has clicked the validation link if the server takes a while to propagate @@ -421,8 +405,12 @@ export const EmailIdentityAuthEntry = createReactClass({ // the validation link, we won't know the email address, so if we don't have it, // assume that the link has been clicked and the server will realise when we poll. if (this.props.inputs.emailAddress === undefined) { - const Loader = sdk.getComponent("elements.Spinner"); - return ; + return ; + } else if (this.props.stageState?.emailSid) { + // we only have a session ID if the user has clicked the link in their email, + // so show a loading state instead of "an email has been sent to..." because + // that's confusing when you've already read that email. + return ; } else { return (
    @@ -434,17 +422,13 @@ export const EmailIdentityAuthEntry = createReactClass({
    ); } - }, -}); + } +} -export const MsisdnAuthEntry = createReactClass({ - displayName: 'MsisdnAuthEntry', +export class MsisdnAuthEntry extends React.Component { + static LOGIN_TYPE = "m.login.msisdn"; - statics: { - LOGIN_TYPE: "m.login.msisdn", - }, - - propTypes: { + static propTypes = { inputs: PropTypes.shape({ phoneCountry: PropTypes.string, phoneNumber: PropTypes.string, @@ -454,16 +438,14 @@ export const MsisdnAuthEntry = createReactClass({ submitAuthDict: PropTypes.func.isRequired, matrixClient: PropTypes.object, onPhaseChange: PropTypes.func.isRequired, - }, + }; - getInitialState: function() { - return { - token: '', - requestingToken: false, - }; - }, + state = { + token: '', + requestingToken: false, + }; - componentDidMount: function() { + componentDidMount() { this.props.onPhaseChange(DEFAULT_PHASE); this._submitUrl = null; @@ -477,12 +459,12 @@ export const MsisdnAuthEntry = createReactClass({ }).finally(() => { this.setState({requestingToken: false}); }); - }, + } /* * Requests a verification token by SMS. */ - _requestMsisdnToken: function() { + _requestMsisdnToken() { return this.props.matrixClient.requestRegisterMsisdnToken( this.props.inputs.phoneCountry, this.props.inputs.phoneNumber, @@ -493,15 +475,15 @@ export const MsisdnAuthEntry = createReactClass({ this._sid = result.sid; this._msisdn = result.msisdn; }); - }, + } - _onTokenChange: function(e) { + _onTokenChange = e => { this.setState({ token: e.target.value, }); - }, + }; - _onFormSubmit: async function(e) { + _onFormSubmit = async e => { e.preventDefault(); if (this.state.token == '') return; @@ -552,9 +534,9 @@ export const MsisdnAuthEntry = createReactClass({ this.props.fail(e); console.log("Failed to submit msisdn token"); } - }, + }; - render: function() { + render() { if (this.state.requestingToken) { const Loader = sdk.getComponent("elements.Spinner"); return ; @@ -598,8 +580,8 @@ export const MsisdnAuthEntry = createReactClass({ ); } - }, -}); + } +} export class SSOAuthEntry extends React.Component { static propTypes = { @@ -686,46 +668,46 @@ export class SSOAuthEntry extends React.Component { } } -export const FallbackAuthEntry = createReactClass({ - displayName: 'FallbackAuthEntry', - - propTypes: { +export class FallbackAuthEntry extends React.Component { + static propTypes = { matrixClient: PropTypes.object.isRequired, authSessionId: PropTypes.string.isRequired, loginType: PropTypes.string.isRequired, submitAuthDict: PropTypes.func.isRequired, errorText: PropTypes.string, onPhaseChange: PropTypes.func.isRequired, - }, + }; - componentDidMount: function() { - this.props.onPhaseChange(DEFAULT_PHASE); - }, + constructor(props) { + super(props); - // TODO: [REACT-WARNING] Replace component with real class, use constructor for refs - UNSAFE_componentWillMount: function() { // we have to make the user click a button, as browsers will block // the popup if we open it immediately. this._popupWindow = null; window.addEventListener("message", this._onReceiveMessage); this._fallbackButton = createRef(); - }, + } - componentWillUnmount: function() { + + componentDidMount() { + this.props.onPhaseChange(DEFAULT_PHASE); + } + + componentWillUnmount() { window.removeEventListener("message", this._onReceiveMessage); if (this._popupWindow) { this._popupWindow.close(); } - }, + } - focus: function() { + focus = () => { if (this._fallbackButton.current) { this._fallbackButton.current.focus(); } - }, + }; - _onShowFallbackClick: function(e) { + _onShowFallbackClick = e => { e.preventDefault(); e.stopPropagation(); @@ -735,18 +717,18 @@ export const FallbackAuthEntry = createReactClass({ ); this._popupWindow = window.open(url); this._popupWindow.opener = null; - }, + }; - _onReceiveMessage: function(event) { + _onReceiveMessage = event => { if ( event.data === "authDone" && event.origin === this.props.matrixClient.getHomeserverUrl() ) { this.props.submitAuthDict({}); } - }, + }; - render: function() { + render() { let errorSection; if (this.props.errorText) { errorSection = ( @@ -761,8 +743,8 @@ export const FallbackAuthEntry = createReactClass({ {errorSection} ); - }, -}); + } +} const AuthEntryComponents = [ PasswordAuthEntry, diff --git a/src/components/views/auth/PassphraseField.tsx b/src/components/views/auth/PassphraseField.tsx index 2f5064447e..b420ed0872 100644 --- a/src/components/views/auth/PassphraseField.tsx +++ b/src/components/views/auth/PassphraseField.tsx @@ -40,11 +40,7 @@ interface IProps { onValidate(result: IValidationResult); } -interface IState { - complexity: zxcvbn.ZXCVBNResult; -} - -class PassphraseField extends PureComponent { +class PassphraseField extends PureComponent { static defaultProps = { label: _td("Password"), labelEnterPassword: _td("Enter password"), @@ -52,14 +48,16 @@ class PassphraseField extends PureComponent { labelAllowedButUnsafe: _td("Password is allowed, but unsafe"), }; - state = { complexity: null }; - - public readonly validate = withValidation({ - description: function() { - const complexity = this.state.complexity; + public readonly validate = withValidation({ + description: function(complexity) { const score = complexity ? complexity.score : 0; return ; }, + deriveData: async ({ value }) => { + if (!value) return null; + const { scorePassword } = await import('../../../utils/PasswordScorer'); + return scorePassword(value); + }, rules: [ { key: "required", @@ -68,28 +66,24 @@ class PassphraseField extends PureComponent { }, { key: "complexity", - test: async function({ value }) { + test: async function({ value }, complexity) { if (!value) { return false; } - const { scorePassword } = await import('../../../utils/PasswordScorer'); - const complexity = scorePassword(value); - this.setState({ complexity }); const safe = complexity.score >= this.props.minScore; const allowUnsafe = SdkConfig.get()["dangerously_allow_unsafe_and_insecure_passwords"]; return allowUnsafe || safe; }, - valid: function() { + valid: function(complexity) { // Unsafe passwords that are valid are only possible through a // configuration flag. We'll print some helper text to signal // to the user that their password is allowed, but unsafe. - if (this.state.complexity.score >= this.props.minScore) { + if (complexity.score >= this.props.minScore) { return _t(this.props.labelStrongPassword); } return _t(this.props.labelAllowedButUnsafe); }, - invalid: function() { - const complexity = this.state.complexity; + invalid: function(complexity) { if (!complexity) { return null; } diff --git a/src/components/views/auth/RegistrationForm.js b/src/components/views/auth/RegistrationForm.js index 17c65fa94e..c07486d3bd 100644 --- a/src/components/views/auth/RegistrationForm.js +++ b/src/components/views/auth/RegistrationForm.js @@ -18,7 +18,6 @@ limitations under the License. */ import React from 'react'; -import createReactClass from 'create-react-class'; import PropTypes from 'prop-types'; import * as sdk from '../../../index'; import * as Email from '../../../email'; @@ -39,13 +38,11 @@ const FIELD_PASSWORD_CONFIRM = 'field_password_confirm'; const PASSWORD_MIN_SCORE = 3; // safely unguessable: moderate protection from offline slow-hash scenario. -/** +/* * A pure UI component which displays a registration form. */ -export default createReactClass({ - displayName: 'RegistrationForm', - - propTypes: { +export default class RegistrationForm extends React.Component { + static propTypes = { // Values pre-filled in the input boxes when the component loads defaultEmail: PropTypes.string, defaultPhoneCountry: PropTypes.string, @@ -58,17 +55,17 @@ export default createReactClass({ serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired, canSubmit: PropTypes.bool, serverRequiresIdServer: PropTypes.bool, - }, + }; - getDefaultProps: function() { - return { - onValidationChange: console.error, - canSubmit: true, - }; - }, + static defaultProps = { + onValidationChange: console.error, + canSubmit: true, + }; - getInitialState: function() { - return { + constructor(props) { + super(props); + + this.state = { // Field error codes by field ID fieldValid: {}, // The ISO2 country code selected in the phone number entry @@ -80,9 +77,9 @@ export default createReactClass({ passwordConfirm: this.props.defaultPassword || "", passwordComplexity: null, }; - }, + } - onSubmit: async function(ev) { + onSubmit = async ev => { ev.preventDefault(); if (!this.props.canSubmit) return; @@ -118,7 +115,7 @@ export default createReactClass({ title: _t("Warning!"), description: desc, button: _t("Continue"), - onFinished: function(confirmed) { + onFinished(confirmed) { if (confirmed) { self._doSubmit(ev); } @@ -127,9 +124,9 @@ export default createReactClass({ } else { self._doSubmit(ev); } - }, + }; - _doSubmit: function(ev) { + _doSubmit(ev) { const email = this.state.email.trim(); const promise = this.props.onRegisterClick({ username: this.state.username.trim(), @@ -145,7 +142,7 @@ export default createReactClass({ ev.target.disabled = false; }); } - }, + } async verifyFieldsBeforeSubmit() { // Blur the active element if any, so we first run its blur validation, @@ -196,12 +193,12 @@ export default createReactClass({ invalidField.focus(); invalidField.validate({ allowEmpty: false, focused: true }); return false; - }, + } /** * @returns {boolean} true if all fields were valid last time they were validated. */ - allFieldsValid: function() { + allFieldsValid() { const keys = Object.keys(this.state.fieldValid); for (let i = 0; i < keys.length; ++i) { if (!this.state.fieldValid[keys[i]]) { @@ -209,7 +206,7 @@ export default createReactClass({ } } return true; - }, + } findFirstInvalidField(fieldIDs) { for (const fieldID of fieldIDs) { @@ -218,34 +215,34 @@ export default createReactClass({ } } return null; - }, + } - markFieldValid: function(fieldID, valid) { + markFieldValid(fieldID, valid) { const { fieldValid } = this.state; fieldValid[fieldID] = valid; this.setState({ fieldValid, }); - }, + } - onEmailChange(ev) { + onEmailChange = ev => { this.setState({ email: ev.target.value, }); - }, + }; - async onEmailValidate(fieldState) { + onEmailValidate = async fieldState => { const result = await this.validateEmailRules(fieldState); this.markFieldValid(FIELD_EMAIL, result.valid); return result; - }, + }; - validateEmailRules: withValidation({ + validateEmailRules = withValidation({ description: () => _t("Use an email address to recover your account"), rules: [ { key: "required", - test: function({ value, allowEmpty }) { + test({ value, allowEmpty }) { return allowEmpty || !this._authStepIsRequired('m.login.email.identity') || !!value; }, invalid: () => _t("Enter email address (required on this homeserver)"), @@ -256,31 +253,31 @@ export default createReactClass({ invalid: () => _t("Doesn't look like a valid email address"), }, ], - }), + }); - onPasswordChange(ev) { + onPasswordChange = ev => { this.setState({ password: ev.target.value, }); - }, + }; - onPasswordValidate(result) { + onPasswordValidate = result => { this.markFieldValid(FIELD_PASSWORD, result.valid); - }, + }; - onPasswordConfirmChange(ev) { + onPasswordConfirmChange = ev => { this.setState({ passwordConfirm: ev.target.value, }); - }, + }; - async onPasswordConfirmValidate(fieldState) { + onPasswordConfirmValidate = async fieldState => { const result = await this.validatePasswordConfirmRules(fieldState); this.markFieldValid(FIELD_PASSWORD_CONFIRM, result.valid); return result; - }, + }; - validatePasswordConfirmRules: withValidation({ + validatePasswordConfirmRules = withValidation({ rules: [ { key: "required", @@ -289,39 +286,39 @@ export default createReactClass({ }, { key: "match", - test: function({ value }) { + test({ value }) { return !value || value === this.state.password; }, invalid: () => _t("Passwords don't match"), }, ], - }), + }); - onPhoneCountryChange(newVal) { + onPhoneCountryChange = newVal => { this.setState({ phoneCountry: newVal.iso2, phonePrefix: newVal.prefix, }); - }, + }; - onPhoneNumberChange(ev) { + onPhoneNumberChange = ev => { this.setState({ phoneNumber: ev.target.value, }); - }, + }; - async onPhoneNumberValidate(fieldState) { + onPhoneNumberValidate = async fieldState => { const result = await this.validatePhoneNumberRules(fieldState); this.markFieldValid(FIELD_PHONE_NUMBER, result.valid); return result; - }, + }; - validatePhoneNumberRules: withValidation({ + validatePhoneNumberRules = withValidation({ description: () => _t("Other users can invite you to rooms using your contact details"), rules: [ { key: "required", - test: function({ value, allowEmpty }) { + test({ value, allowEmpty }) { return allowEmpty || !this._authStepIsRequired('m.login.msisdn') || !!value; }, invalid: () => _t("Enter phone number (required on this homeserver)"), @@ -332,21 +329,21 @@ export default createReactClass({ invalid: () => _t("Doesn't look like a valid phone number"), }, ], - }), + }); - onUsernameChange(ev) { + onUsernameChange = ev => { this.setState({ username: ev.target.value, }); - }, + }; - async onUsernameValidate(fieldState) { + onUsernameValidate = async fieldState => { const result = await this.validateUsernameRules(fieldState); this.markFieldValid(FIELD_USERNAME, result.valid); return result; - }, + }; - validateUsernameRules: withValidation({ + validateUsernameRules = withValidation({ description: () => _t("Use lowercase letters, numbers, dashes and underscores only"), rules: [ { @@ -360,7 +357,7 @@ export default createReactClass({ invalid: () => _t("Some characters not allowed"), }, ], - }), + }); /** * A step is required if all flows include that step. @@ -372,7 +369,7 @@ export default createReactClass({ return this.props.flows.every((flow) => { return flow.stages.includes(step); }); - }, + } /** * A step is used if any flows include that step. @@ -384,7 +381,7 @@ export default createReactClass({ return this.props.flows.some((flow) => { return flow.stages.includes(step); }); - }, + } _showEmail() { const haveIs = Boolean(this.props.serverConfig.isUrl); @@ -395,7 +392,7 @@ export default createReactClass({ return false; } return true; - }, + } _showPhoneNumber() { const threePidLogin = !SdkConfig.get().disable_3pid_login; @@ -408,7 +405,7 @@ export default createReactClass({ return false; } return true; - }, + } renderEmail() { if (!this._showEmail()) { @@ -426,7 +423,7 @@ export default createReactClass({ onChange={this.onEmailChange} onValidate={this.onEmailValidate} />; - }, + } renderPassword() { return ; - }, + } renderPasswordConfirm() { const Field = sdk.getComponent('elements.Field'); @@ -451,7 +448,7 @@ export default createReactClass({ onChange={this.onPasswordConfirmChange} onValidate={this.onPasswordConfirmValidate} />; - }, + } renderPhoneNumber() { if (!this._showPhoneNumber()) { @@ -477,7 +474,7 @@ export default createReactClass({ onChange={this.onPhoneNumberChange} onValidate={this.onPhoneNumberValidate} />; - }, + } renderUsername() { const Field = sdk.getComponent('elements.Field'); @@ -491,9 +488,9 @@ export default createReactClass({ onChange={this.onUsernameChange} onValidate={this.onUsernameValidate} />; - }, + } - render: function() { + render() { let yourMatrixAccountText = _t('Create your Matrix account on %(serverName)s', { serverName: this.props.serverConfig.hsName, }); @@ -578,5 +575,5 @@ export default createReactClass({ ); - }, -}); + } +} diff --git a/src/components/views/auth/Welcome.js b/src/components/views/auth/Welcome.js index 5a30a02490..21032f4f1a 100644 --- a/src/components/views/auth/Welcome.js +++ b/src/components/views/auth/Welcome.js @@ -15,10 +15,14 @@ limitations under the License. */ import React from 'react'; +import classNames from "classnames"; + import * as sdk from '../../../index'; import SdkConfig from '../../../SdkConfig'; import AuthPage from "./AuthPage"; import {_td} from "../../../languageHandler"; +import SettingsStore from "../../../settings/SettingsStore"; +import {UIFeature} from "../../../settings/UIFeature"; // translatable strings for Welcome pages _td("Sign in with SSO"); @@ -39,7 +43,9 @@ export default class Welcome extends React.PureComponent { return ( -
    +
    { + // work out the full set of urls to try to load. This is formed like so: + // imageUrls: [ props.url, ...props.urls ] + + let _urls = []; + if (!SettingsStore.getValue("lowBandwidth")) { + _urls = urls || []; + + if (url) { + _urls.unshift(url); // put in urls[0] + } + } + + // deduplicate URLs + return Array.from(new Set(_urls)); +}; + const useImageUrl = ({url, urls}): [string, () => void] => { - const [imageUrls, setUrls] = useState([]); - const [urlsIndex, setIndex] = useState(); + const [imageUrls, setUrls] = useState(calculateUrls(url, urls)); + const [urlsIndex, setIndex] = useState(0); const onError = useCallback(() => { setIndex(i => i + 1); // try the next one }, []); - const memoizedUrls = useMemo(() => urls, [JSON.stringify(urls)]); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { - // work out the full set of urls to try to load. This is formed like so: - // imageUrls: [ props.url, ...props.urls ] - - let _urls = []; - if (!SettingsStore.getValue("lowBandwidth")) { - _urls = memoizedUrls || []; - - if (url) { - _urls.unshift(url); // put in urls[0] - } - } - - // deduplicate URLs - _urls = Array.from(new Set(_urls)); - + setUrls(calculateUrls(url, urls)); setIndex(0); - setUrls(_urls); - }, [url, memoizedUrls]); // eslint-disable-line react-hooks/exhaustive-deps + }, [url, JSON.stringify(urls)]); // eslint-disable-line react-hooks/exhaustive-deps const cli = useContext(MatrixClientContext); const onClientSync = useCallback((syncState, prevState) => { @@ -95,7 +96,7 @@ const BaseAvatar = (props: IProps) => { urls, width = 40, height = 40, - resizeMethod = "crop", // eslint-disable-line no-unused-vars + resizeMethod = "crop", // eslint-disable-line @typescript-eslint/no-unused-vars defaultToInitialLetter = true, onClick, inputRef, diff --git a/src/components/views/avatars/DecoratedRoomAvatar.tsx b/src/components/views/avatars/DecoratedRoomAvatar.tsx index e6dadf676c..d7e012467b 100644 --- a/src/components/views/avatars/DecoratedRoomAvatar.tsx +++ b/src/components/views/avatars/DecoratedRoomAvatar.tsx @@ -126,7 +126,7 @@ export default class DecoratedRoomAvatar extends React.PureComponent { if (this.isUnmounted) return; - let newIcon = this.getPresenceIcon(); + const newIcon = this.getPresenceIcon(); if (newIcon !== this.state.icon) this.setState({icon: newIcon}); }; diff --git a/src/components/views/avatars/GroupAvatar.tsx b/src/components/views/avatars/GroupAvatar.tsx index e55e2e6fac..51327605c0 100644 --- a/src/components/views/avatars/GroupAvatar.tsx +++ b/src/components/views/avatars/GroupAvatar.tsx @@ -47,7 +47,7 @@ export default class GroupAvatar extends React.Component { render() { // extract the props we use from props so we can pass any others through // should consider adding this as a global rule in js-sdk? - /*eslint no-unused-vars: ["error", { "ignoreRestSiblings": true }]*/ + /* eslint @typescript-eslint/no-unused-vars: ["error", { "ignoreRestSiblings": true }] */ const {groupId, groupAvatarUrl, groupName, ...otherProps} = this.props; return ( diff --git a/src/components/views/avatars/MemberAvatar.tsx b/src/components/views/avatars/MemberAvatar.tsx index 1d23d85b0f..60b043016b 100644 --- a/src/components/views/avatars/MemberAvatar.tsx +++ b/src/components/views/avatars/MemberAvatar.tsx @@ -16,23 +16,24 @@ limitations under the License. */ import React from 'react'; +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"; -interface IProps { - // TODO: replace with correct type - member: any; - fallbackUserId: string; +interface IProps extends Omit, "name" | "idName" | "url"> { + member: RoomMember; + fallbackUserId?: string; width: number; height: number; - resizeMethod: string; + resizeMethod?: string; // The onClick to give the avatar - onClick: React.MouseEventHandler; + onClick?: React.MouseEventHandler; // Whether the onClick of the avatar should be overriden to dispatch `Action.ViewUser` - viewUserOnClick: boolean; - title: string; + viewUserOnClick?: boolean; + title?: string; } interface IState { diff --git a/src/components/views/avatars/PulsedAvatar.tsx b/src/components/views/avatars/PulsedAvatar.tsx index 94a6c87687..b4e876b9f6 100644 --- a/src/components/views/avatars/PulsedAvatar.tsx +++ b/src/components/views/avatars/PulsedAvatar.tsx @@ -25,4 +25,4 @@ const PulsedAvatar: React.FC = (props) => {
    ; }; -export default PulsedAvatar; \ No newline at end of file +export default PulsedAvatar; diff --git a/src/components/views/avatars/RoomAvatar.tsx b/src/components/views/avatars/RoomAvatar.tsx index e37dff4bfe..cbdae765f7 100644 --- a/src/components/views/avatars/RoomAvatar.tsx +++ b/src/components/views/avatars/RoomAvatar.tsx @@ -22,6 +22,7 @@ import ImageView from '../elements/ImageView'; import {MatrixClientPeg} from '../../../MatrixClientPeg'; import Modal from '../../../Modal'; import * as Avatar from '../../../Avatar'; +import {ResizeMethod} from "../../../Avatar"; interface IProps { // Room may be left unset here, but if it is, @@ -32,7 +33,7 @@ interface IProps { oobData?: any; width?: number; height?: number; - resizeMethod?: string; + resizeMethod?: ResizeMethod; viewAvatarOnClick?: boolean; } diff --git a/src/components/views/context_menus/IconizedContextMenu.tsx b/src/components/views/context_menus/IconizedContextMenu.tsx index b3ca9fde6f..a3fb00a9f4 100644 --- a/src/components/views/context_menus/IconizedContextMenu.tsx +++ b/src/components/views/context_menus/IconizedContextMenu.tsx @@ -37,7 +37,7 @@ interface IOptionListProps { } interface IOptionProps extends React.ComponentProps { - iconClassName: string; + iconClassName?: string; } interface ICheckboxProps extends React.ComponentProps { @@ -92,7 +92,7 @@ export const IconizedContextMenuCheckbox: React.FC = ({ export const IconizedContextMenuOption: React.FC = ({label, iconClassName, ...props}) => { return - + { iconClassName && } {label} ; }; diff --git a/src/components/views/context_menus/MessageContextMenu.js b/src/components/views/context_menus/MessageContextMenu.js index 6fa54058a0..d760c8defa 100644 --- a/src/components/views/context_menus/MessageContextMenu.js +++ b/src/components/views/context_menus/MessageContextMenu.js @@ -19,7 +19,6 @@ limitations under the License. import React from 'react'; import PropTypes from 'prop-types'; -import createReactClass from 'create-react-class'; import {EventStatus} from 'matrix-js-sdk'; import {MatrixClientPeg} from '../../../MatrixClientPeg'; @@ -37,10 +36,8 @@ function canCancel(eventStatus) { return eventStatus === EventStatus.QUEUED || eventStatus === EventStatus.NOT_SENT; } -export default createReactClass({ - displayName: 'MessageContextMenu', - - propTypes: { +export default class MessageContextMenu extends React.Component { + static propTypes = { /* the MatrixEvent associated with the context menu */ mxEvent: PropTypes.object.isRequired, @@ -52,28 +49,26 @@ export default createReactClass({ /* callback called when the menu is dismissed */ onFinished: PropTypes.func, - }, + }; - getInitialState: function() { - return { - canRedact: false, - canPin: false, - }; - }, + state = { + canRedact: false, + canPin: false, + }; - componentDidMount: function() { + componentDidMount() { MatrixClientPeg.get().on('RoomMember.powerLevel', this._checkPermissions); this._checkPermissions(); - }, + } - componentWillUnmount: function() { + componentWillUnmount() { const cli = MatrixClientPeg.get(); if (cli) { cli.removeListener('RoomMember.powerLevel', this._checkPermissions); } - }, + } - _checkPermissions: function() { + _checkPermissions = () => { const cli = MatrixClientPeg.get(); const room = cli.getRoom(this.props.mxEvent.getRoomId()); @@ -84,47 +79,47 @@ export default createReactClass({ if (!SettingsStore.getValue("feature_pinning")) canPin = false; this.setState({canRedact, canPin}); - }, + }; - _isPinned: function() { + _isPinned() { const room = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId()); const pinnedEvent = room.currentState.getStateEvents('m.room.pinned_events', ''); if (!pinnedEvent) return false; const content = pinnedEvent.getContent(); return content.pinned && Array.isArray(content.pinned) && content.pinned.includes(this.props.mxEvent.getId()); - }, + } - onResendClick: function() { + onResendClick = () => { Resend.resend(this.props.mxEvent); this.closeMenu(); - }, + }; - onResendEditClick: function() { + onResendEditClick = () => { Resend.resend(this.props.mxEvent.replacingEvent()); this.closeMenu(); - }, + }; - onResendRedactionClick: function() { + onResendRedactionClick = () => { Resend.resend(this.props.mxEvent.localRedactionEvent()); this.closeMenu(); - }, + }; - onResendReactionsClick: function() { + onResendReactionsClick = () => { for (const reaction of this._getUnsentReactions()) { Resend.resend(reaction); } this.closeMenu(); - }, + }; - onReportEventClick: function() { + onReportEventClick = () => { const ReportEventDialog = sdk.getComponent("dialogs.ReportEventDialog"); Modal.createTrackedDialog('Report Event', '', ReportEventDialog, { mxEvent: this.props.mxEvent, }, 'mx_Dialog_reportEvent'); this.closeMenu(); - }, + }; - onViewSourceClick: function() { + onViewSourceClick = () => { const ev = this.props.mxEvent.replacingEvent() || this.props.mxEvent; const ViewSource = sdk.getComponent('structures.ViewSource'); Modal.createTrackedDialog('View Event Source', '', ViewSource, { @@ -133,9 +128,9 @@ export default createReactClass({ content: ev.event, }, 'mx_Dialog_viewsource'); this.closeMenu(); - }, + }; - onViewClearSourceClick: function() { + onViewClearSourceClick = () => { const ev = this.props.mxEvent.replacingEvent() || this.props.mxEvent; const ViewSource = sdk.getComponent('structures.ViewSource'); Modal.createTrackedDialog('View Clear Event Source', '', ViewSource, { @@ -145,9 +140,9 @@ export default createReactClass({ content: ev._clearEvent, }, 'mx_Dialog_viewsource'); this.closeMenu(); - }, + }; - onRedactClick: function() { + onRedactClick = () => { const ConfirmRedactDialog = sdk.getComponent("dialogs.ConfirmRedactDialog"); Modal.createTrackedDialog('Confirm Redact Dialog', '', ConfirmRedactDialog, { onFinished: async (proceed) => { @@ -176,9 +171,9 @@ export default createReactClass({ }, }, 'mx_Dialog_confirmredact'); this.closeMenu(); - }, + }; - onCancelSendClick: function() { + onCancelSendClick = () => { const mxEvent = this.props.mxEvent; const editEvent = mxEvent.replacingEvent(); const redactEvent = mxEvent.localRedactionEvent(); @@ -199,17 +194,17 @@ export default createReactClass({ Resend.removeFromQueue(this.props.mxEvent); } this.closeMenu(); - }, + }; - onForwardClick: function() { + onForwardClick = () => { dis.dispatch({ action: 'forward_event', event: this.props.mxEvent, }); this.closeMenu(); - }, + }; - onPinClick: function() { + onPinClick = () => { MatrixClientPeg.get().getStateEvent(this.props.mxEvent.getRoomId(), 'm.room.pinned_events', '') .catch((e) => { // Intercept the Event Not Found error and fall through the promise chain with no event. @@ -230,28 +225,28 @@ export default createReactClass({ cli.sendStateEvent(this.props.mxEvent.getRoomId(), 'm.room.pinned_events', {pinned: eventIds}, ''); }); this.closeMenu(); - }, + }; - closeMenu: function() { + closeMenu = () => { if (this.props.onFinished) this.props.onFinished(); - }, + }; - onUnhidePreviewClick: function() { + onUnhidePreviewClick = () => { if (this.props.eventTileOps) { this.props.eventTileOps.unhideWidget(); } this.closeMenu(); - }, + }; - onQuoteClick: function() { + onQuoteClick = () => { dis.dispatch({ action: 'quote', event: this.props.mxEvent, }); this.closeMenu(); - }, + }; - onPermalinkClick: function(e: Event) { + onPermalinkClick = (e: Event) => { e.preventDefault(); const ShareDialog = sdk.getComponent("dialogs.ShareDialog"); Modal.createTrackedDialog('share room message dialog', '', ShareDialog, { @@ -259,12 +254,12 @@ export default createReactClass({ permalinkCreator: this.props.permalinkCreator, }); this.closeMenu(); - }, + }; - onCollapseReplyThreadClick: function() { + onCollapseReplyThreadClick = () => { this.props.collapseReplyThread(); this.closeMenu(); - }, + }; _getReactions(filter) { const cli = MatrixClientPeg.get(); @@ -277,17 +272,17 @@ export default createReactClass({ relation.event_id === eventId && filter(e); }); - }, + } _getPendingReactions() { return this._getReactions(e => canCancel(e.status)); - }, + } _getUnsentReactions() { return this._getReactions(e => e.status === EventStatus.NOT_SENT); - }, + } - render: function() { + render() { const cli = MatrixClientPeg.get(); const me = cli.getUserId(); const mxEvent = this.props.mxEvent; @@ -489,5 +484,5 @@ export default createReactClass({ { reportEventButton }
    ); - }, -}); + } +} diff --git a/src/components/views/context_menus/WidgetContextMenu.js b/src/components/views/context_menus/WidgetContextMenu.js index 1ec74b2e6c..6ed32daa5c 100644 --- a/src/components/views/context_menus/WidgetContextMenu.js +++ b/src/components/views/context_menus/WidgetContextMenu.js @@ -26,6 +26,9 @@ export default class WidgetContextMenu extends React.Component { // Callback for when the revoke button is clicked. Required. onRevokeClicked: PropTypes.func.isRequired, + // Callback for when the unpin button is clicked. If absent, unpin will be hidden. + onUnpinClicked: PropTypes.func, + // Callback for when the snapshot button is clicked. Button not shown // without a callback. onSnapshotClicked: PropTypes.func, @@ -70,6 +73,8 @@ export default class WidgetContextMenu extends React.Component { this.proxyClick(this.props.onRevokeClicked); }; + onUnpinClicked = () => this.proxyClick(this.props.onUnpinClicked); + render() { const options = []; @@ -81,6 +86,14 @@ export default class WidgetContextMenu extends React.Component { ); } + if (this.props.onUnpinClicked) { + options.push( + + {_t("Unpin")} + , + ); + } + if (this.props.onReloadClicked) { options.push( diff --git a/src/components/views/create_room/Presets.js b/src/components/views/create_room/Presets.js deleted file mode 100644 index 0f18d11511..0000000000 --- a/src/components/views/create_room/Presets.js +++ /dev/null @@ -1,57 +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 createReactClass from 'create-react-class'; -import { _t } from '../../../languageHandler'; - -const Presets = { - PrivateChat: "private_chat", - PublicChat: "public_chat", - Custom: "custom", -}; - -export default createReactClass({ - displayName: 'CreateRoomPresets', - propTypes: { - onChange: PropTypes.func, - preset: PropTypes.string, - }, - - Presets: Presets, - - getDefaultProps: function() { - return { - onChange: function() {}, - }; - }, - - onValueChanged: function(ev) { - this.props.onChange(ev.target.value); - }, - - render: function() { - return ( - - ); - }, -}); diff --git a/src/components/views/create_room/RoomAlias.js b/src/components/views/create_room/RoomAlias.js deleted file mode 100644 index 5bdfdde08d..0000000000 --- a/src/components/views/create_room/RoomAlias.js +++ /dev/null @@ -1,106 +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 createReactClass from 'create-react-class'; -import { _t } from '../../../languageHandler'; - -export default createReactClass({ - displayName: 'RoomAlias', - propTypes: { - // Specifying a homeserver will make magical things happen when you, - // e.g. start typing in the room alias box. - homeserver: PropTypes.string, - alias: PropTypes.string, - onChange: PropTypes.func, - }, - - getDefaultProps: function() { - return { - onChange: function() {}, - alias: '', - }; - }, - - getAliasLocalpart: function() { - let room_alias = this.props.alias; - - if (room_alias && this.props.homeserver) { - const suffix = ":" + this.props.homeserver; - if (room_alias.startsWith("#") && room_alias.endsWith(suffix)) { - room_alias = room_alias.slice(1, -suffix.length); - } - } - - return room_alias; - }, - - onValueChanged: function(ev) { - this.props.onChange(ev.target.value); - }, - - onFocus: function(ev) { - const target = ev.target; - const curr_val = ev.target.value; - - if (this.props.homeserver) { - if (curr_val == "") { - const self = this; - setTimeout(function() { - target.value = "#:" + self.props.homeserver; - target.setSelectionRange(1, 1); - }, 0); - } else { - const suffix = ":" + this.props.homeserver; - setTimeout(function() { - target.setSelectionRange( - curr_val.startsWith("#") ? 1 : 0, - curr_val.endsWith(suffix) ? (target.value.length - suffix.length) : target.value.length, - ); - }, 0); - } - } - }, - - onBlur: function(ev) { - const curr_val = ev.target.value; - - if (this.props.homeserver) { - if (curr_val == "#:" + this.props.homeserver) { - ev.target.value = ""; - return; - } - - if (curr_val != "") { - let new_val = ev.target.value; - const suffix = ":" + this.props.homeserver; - if (!curr_val.startsWith("#")) new_val = "#" + new_val; - if (!curr_val.endsWith(suffix)) new_val = new_val + suffix; - ev.target.value = new_val; - } - } - }, - - render: function() { - return ( - - ); - }, -}); diff --git a/src/components/views/dialogs/AddressPickerDialog.js b/src/components/views/dialogs/AddressPickerDialog.js index 8ddd89dc65..2cd09874b2 100644 --- a/src/components/views/dialogs/AddressPickerDialog.js +++ b/src/components/views/dialogs/AddressPickerDialog.js @@ -19,7 +19,6 @@ limitations under the License. import React, {createRef} from 'react'; import PropTypes from 'prop-types'; -import createReactClass from 'create-react-class'; import { _t, _td } from '../../../languageHandler'; import * as sdk from '../../../index'; @@ -45,10 +44,8 @@ const addressTypeName = { }; -export default createReactClass({ - displayName: "AddressPickerDialog", - - propTypes: { +export default class AddressPickerDialog extends React.Component { + static propTypes = { title: PropTypes.string.isRequired, description: PropTypes.node, // Extra node inserted after picker input, dropdown and errors @@ -66,26 +63,28 @@ export default createReactClass({ // Whether the current user should be included in the addresses returned. Only // applicable when pickerType is `user`. Default: false. includeSelf: PropTypes.bool, - }, + }; - getDefaultProps: function() { - return { - value: "", - focus: true, - validAddressTypes: addressTypes, - pickerType: 'user', - includeSelf: false, - }; - }, + static defaultProps = { + value: "", + focus: true, + validAddressTypes: addressTypes, + pickerType: 'user', + includeSelf: false, + }; + + constructor(props) { + super(props); + + this._textinput = createRef(); - getInitialState: function() { let validAddressTypes = this.props.validAddressTypes; // Remove email from validAddressTypes if no IS is configured. It may be added at a later stage by the user if (!MatrixClientPeg.get().getIdentityServerUrl() && validAddressTypes.includes("email")) { validAddressTypes = validAddressTypes.filter(type => type !== "email"); } - return { + this.state = { // Whether to show an error message because of an invalid address invalidAddressError: false, // List of UserAddressType objects representing @@ -106,19 +105,14 @@ export default createReactClass({ // dialog is open and represents the supported list of address types at this time. validAddressTypes, }; - }, + } - // TODO: [REACT-WARNING] Replace component with real class, use constructor for refs - UNSAFE_componentWillMount: function() { - this._textinput = createRef(); - }, - - componentDidMount: function() { + componentDidMount() { if (this.props.focus) { // Set the cursor at the end of the text input this._textinput.current.value = this.props.value; } - }, + } getPlaceholder() { const { placeholder } = this.props; @@ -127,9 +121,9 @@ export default createReactClass({ } // Otherwise it's a function, as checked by prop types. return placeholder(this.state.validAddressTypes); - }, + } - onButtonClick: function() { + onButtonClick = () => { let selectedList = this.state.selectedList.slice(); // Check the text input field to see if user has an unconverted address // If there is and it's valid add it to the local selectedList @@ -138,13 +132,13 @@ export default createReactClass({ if (selectedList === null) return; } this.props.onFinished(true, selectedList); - }, + }; - onCancel: function() { + onCancel = () => { this.props.onFinished(false); - }, + }; - onKeyDown: function(e) { + onKeyDown = e => { const textInput = this._textinput.current ? this._textinput.current.value : undefined; if (e.key === Key.ESCAPE) { @@ -181,9 +175,9 @@ export default createReactClass({ e.preventDefault(); this._addAddressesToList([textInput]); } - }, + }; - onQueryChanged: function(ev) { + onQueryChanged = ev => { const query = ev.target.value; if (this.queryChangedDebouncer) { clearTimeout(this.queryChangedDebouncer); @@ -216,28 +210,24 @@ export default createReactClass({ searchError: null, }); } - }, + }; - onDismissed: function(index) { - return () => { - const selectedList = this.state.selectedList.slice(); - selectedList.splice(index, 1); - this.setState({ - selectedList, - suggestedList: [], - query: "", - }); - if (this._cancelThreepidLookup) this._cancelThreepidLookup(); - }; - }, + onDismissed = index => () => { + const selectedList = this.state.selectedList.slice(); + selectedList.splice(index, 1); + this.setState({ + selectedList, + suggestedList: [], + query: "", + }); + if (this._cancelThreepidLookup) this._cancelThreepidLookup(); + }; - onClick: function(index) { - return () => { - this.onSelected(index); - }; - }, + onClick = index => () => { + this.onSelected(index); + }; - onSelected: function(index) { + onSelected = index => { const selectedList = this.state.selectedList.slice(); selectedList.push(this._getFilteredSuggestions()[index]); this.setState({ @@ -246,9 +236,9 @@ export default createReactClass({ query: "", }); if (this._cancelThreepidLookup) this._cancelThreepidLookup(); - }, + }; - _doNaiveGroupSearch: function(query) { + _doNaiveGroupSearch(query) { const lowerCaseQuery = query.toLowerCase(); this.setState({ busy: true, @@ -280,9 +270,9 @@ export default createReactClass({ busy: false, }); }); - }, + } - _doNaiveGroupRoomSearch: function(query) { + _doNaiveGroupRoomSearch(query) { const lowerCaseQuery = query.toLowerCase(); const results = []; GroupStore.getGroupRooms(this.props.groupId).forEach((r) => { @@ -302,9 +292,9 @@ export default createReactClass({ this.setState({ busy: false, }); - }, + } - _doRoomSearch: function(query) { + _doRoomSearch(query) { const lowerCaseQuery = query.toLowerCase(); const rooms = MatrixClientPeg.get().getRooms(); const results = []; @@ -359,9 +349,9 @@ export default createReactClass({ this.setState({ busy: false, }); - }, + } - _doUserDirectorySearch: function(query) { + _doUserDirectorySearch(query) { this.setState({ busy: true, query, @@ -393,9 +383,9 @@ export default createReactClass({ busy: false, }); }); - }, + } - _doLocalSearch: function(query) { + _doLocalSearch(query) { this.setState({ query, searchError: null, @@ -417,9 +407,9 @@ export default createReactClass({ }); }); this._processResults(results, query); - }, + } - _processResults: function(results, query) { + _processResults(results, query) { const suggestedList = []; results.forEach((result) => { if (result.room_id) { @@ -485,9 +475,9 @@ export default createReactClass({ }, () => { if (this.addressSelector) this.addressSelector.moveSelectionTop(); }); - }, + } - _addAddressesToList: function(addressTexts) { + _addAddressesToList(addressTexts) { const selectedList = this.state.selectedList.slice(); let hasError = false; @@ -529,9 +519,9 @@ export default createReactClass({ }); if (this._cancelThreepidLookup) this._cancelThreepidLookup(); return hasError ? null : selectedList; - }, + } - _lookupThreepid: async function(medium, address) { + async _lookupThreepid(medium, address) { let cancelled = false; // Note that we can't safely remove this after we're done // because we don't know that it's the same one, so we just @@ -577,9 +567,9 @@ export default createReactClass({ searchError: _t('Something went wrong!'), }); } - }, + } - _getFilteredSuggestions: function() { + _getFilteredSuggestions() { // map addressType => set of addresses to avoid O(n*m) operation const selectedAddresses = {}; this.state.selectedList.forEach(({address, addressType}) => { @@ -591,17 +581,17 @@ export default createReactClass({ return this.state.suggestedList.filter(({address, addressType}) => { return !(selectedAddresses[addressType] && selectedAddresses[addressType].has(address)); }); - }, + } - _onPaste: function(e) { + _onPaste = e => { // Prevent the text being pasted into the textarea e.preventDefault(); const text = e.clipboardData.getData("text"); // Process it as a list of addresses to add instead this._addAddressesToList(text.split(/[\s,]+/)); - }, + }; - onUseDefaultIdentityServerClick(e) { + onUseDefaultIdentityServerClick = e => { e.preventDefault(); // Update the IS in account data. Actually using it may trigger terms. @@ -612,15 +602,15 @@ export default createReactClass({ const { validAddressTypes } = this.state; validAddressTypes.push('email'); this.setState({ validAddressTypes }); - }, + }; - onManageSettingsClick(e) { + onManageSettingsClick = e => { e.preventDefault(); dis.fire(Action.ViewUserSettings); this.onCancel(); - }, + }; - render: function() { + render() { const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); const AddressSelector = sdk.getComponent("elements.AddressSelector"); @@ -738,5 +728,5 @@ export default createReactClass({ onCancel={this.onCancel} /> ); - }, -}); + } +} diff --git a/src/components/views/dialogs/AskInviteAnywayDialog.js b/src/components/views/dialogs/AskInviteAnywayDialog.js index 7a12d2bd20..c69400977a 100644 --- a/src/components/views/dialogs/AskInviteAnywayDialog.js +++ b/src/components/views/dialogs/AskInviteAnywayDialog.js @@ -16,37 +16,36 @@ limitations under the License. import React from 'react'; import PropTypes from 'prop-types'; -import createReactClass from 'create-react-class'; import * as sdk from '../../../index'; import { _t } from '../../../languageHandler'; import SettingsStore from "../../../settings/SettingsStore"; import {SettingLevel} from "../../../settings/SettingLevel"; -export default createReactClass({ - propTypes: { +export default class AskInviteAnywayDialog extends React.Component { + static propTypes = { unknownProfileUsers: PropTypes.array.isRequired, // [ {userId, errorText}... ] onInviteAnyways: PropTypes.func.isRequired, onGiveUp: PropTypes.func.isRequired, onFinished: PropTypes.func.isRequired, - }, + }; - _onInviteClicked: function() { + _onInviteClicked = () => { this.props.onInviteAnyways(); this.props.onFinished(true); - }, + }; - _onInviteNeverWarnClicked: function() { + _onInviteNeverWarnClicked = () => { SettingsStore.setValue("promptBeforeInviteUnknownUsers", null, SettingLevel.ACCOUNT, false); this.props.onInviteAnyways(); this.props.onFinished(true); - }, + }; - _onGiveUpClicked: function() { + _onGiveUpClicked = () => { this.props.onGiveUp(); this.props.onFinished(false); - }, + }; - render: function() { + render() { const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); const errorList = this.props.unknownProfileUsers @@ -78,5 +77,5 @@ export default createReactClass({ ); - }, -}); + } +} diff --git a/src/components/views/dialogs/BaseDialog.js b/src/components/views/dialogs/BaseDialog.js index 353298032c..9ba5368ee5 100644 --- a/src/components/views/dialogs/BaseDialog.js +++ b/src/components/views/dialogs/BaseDialog.js @@ -17,7 +17,6 @@ limitations under the License. */ import React from 'react'; -import createReactClass from 'create-react-class'; import FocusLock from 'react-focus-lock'; import PropTypes from 'prop-types'; import classNames from 'classnames'; @@ -28,16 +27,14 @@ import {MatrixClientPeg} from '../../../MatrixClientPeg'; import { _t } from "../../../languageHandler"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; -/** +/* * Basic container for modal dialogs. * * Includes a div for the title, and a keypress handler which cancels the * dialog on escape. */ -export default createReactClass({ - displayName: 'BaseDialog', - - propTypes: { +export default class BaseDialog extends React.Component { + static propTypes = { // onFinished callback to call when Escape is pressed // Take a boolean which is true if the dialog was dismissed // with a positive / confirm action or false if it was @@ -81,21 +78,20 @@ export default createReactClass({ PropTypes.object, PropTypes.arrayOf(PropTypes.string), ]), - }, + }; - getDefaultProps: function() { - return { - hasCancel: true, - fixedWidth: true, - }; - }, + static defaultProps = { + hasCancel: true, + fixedWidth: true, + }; + + constructor(props) { + super(props); - // TODO: [REACT-WARNING] Move this to constructor - UNSAFE_componentWillMount() { this._matrixClient = MatrixClientPeg.get(); - }, + } - _onKeyDown: function(e) { + _onKeyDown = (e) => { if (this.props.onKeyDown) { this.props.onKeyDown(e); } @@ -104,13 +100,13 @@ export default createReactClass({ e.preventDefault(); this.props.onFinished(false); } - }, + }; - _onCancelClick: function(e) { + _onCancelClick = (e) => { this.props.onFinished(false); - }, + }; - render: function() { + render() { let cancelButton; if (this.props.hasCancel) { cancelButton = ( @@ -161,5 +157,5 @@ export default createReactClass({ ); - }, -}); + } +} diff --git a/src/components/views/dialogs/BugReportDialog.js b/src/components/views/dialogs/BugReportDialog.js index d001d3993d..c4dd0a1430 100644 --- a/src/components/views/dialogs/BugReportDialog.js +++ b/src/components/views/dialogs/BugReportDialog.js @@ -34,7 +34,7 @@ export default class BugReportDialog extends React.Component { busy: false, err: null, issueUrl: "", - text: "", + text: props.initialText || "", progress: null, downloadBusy: false, downloadProgress: null, @@ -255,4 +255,5 @@ export default class BugReportDialog extends React.Component { BugReportDialog.propTypes = { onFinished: PropTypes.func.isRequired, + initialText: PropTypes.string, }; diff --git a/src/components/views/dialogs/CommunityPrototypeInviteDialog.tsx b/src/components/views/dialogs/CommunityPrototypeInviteDialog.tsx new file mode 100644 index 0000000000..1c8a4ad6f6 --- /dev/null +++ b/src/components/views/dialogs/CommunityPrototypeInviteDialog.tsx @@ -0,0 +1,248 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React, { ChangeEvent, FormEvent } from 'react'; +import BaseDialog from "./BaseDialog"; +import { _t } from "../../../languageHandler"; +import { IDialogProps } from "./IDialogProps"; +import Field from "../elements/Field"; +import AccessibleButton from "../elements/AccessibleButton"; +import { MatrixClientPeg } from "../../../MatrixClientPeg"; +import { arrayFastClone } from "../../../utils/arrays"; +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"; + +interface IProps extends IDialogProps { + roomId: string; + communityName: string; +} + +interface IPerson { + userId: string; + user: RoomMember; + lastActive: number; +} + +interface IState { + emailTargets: string[]; + userTargets: string[]; + showPeople: boolean; + people: IPerson[]; + numPeople: number; + busy: boolean; +} + +export default class CommunityPrototypeInviteDialog extends React.PureComponent { + constructor(props: IProps) { + super(props); + + this.state = { + emailTargets: [], + userTargets: [], + showPeople: false, + people: this.buildSuggestions(), + numPeople: 5, // arbitrary default + busy: false, + }; + } + + private buildSuggestions(): IPerson[] { + const alreadyInvited = new Set([MatrixClientPeg.get().getUserId(), SdkConfig.get()['welcomeUserId']]); + if (this.props.roomId) { + const room = MatrixClientPeg.get().getRoom(this.props.roomId); + if (!room) throw new Error("Room ID given to InviteDialog does not look like a room"); + room.getMembersWithMembership('invite').forEach(m => alreadyInvited.add(m.userId)); + room.getMembersWithMembership('join').forEach(m => alreadyInvited.add(m.userId)); + // add banned users, so we don't try to invite them + room.getMembersWithMembership('ban').forEach(m => alreadyInvited.add(m.userId)); + } + + return InviteDialog.buildRecents(alreadyInvited); + } + + private onSubmit = async (ev: FormEvent) => { + ev.preventDefault(); + ev.stopPropagation(); + + this.setState({busy: true}); + try { + const targets = [...this.state.emailTargets, ...this.state.userTargets]; + const result = await inviteMultipleToRoom(this.props.roomId, targets); + const room = MatrixClientPeg.get().getRoom(this.props.roomId); + const success = showAnyInviteErrors(result.states, room, result.inviter); + if (success) { + this.props.onFinished(true); + } else { + this.setState({busy: false}); + } + } catch (e) { + this.setState({busy: false}); + console.error(e); + Modal.createTrackedDialog('Failed to invite', '', ErrorDialog, { + title: _t("Failed to invite"), + description: ((e && e.message) ? e.message : _t("Operation failed")), + }); + } + }; + + private onAddressChange = (ev: ChangeEvent, index: number) => { + const targets = arrayFastClone(this.state.emailTargets); + if (index >= targets.length) { + targets.push(ev.target.value); + } else { + targets[index] = ev.target.value; + } + this.setState({emailTargets: targets}); + }; + + private onAddressBlur = (index: number) => { + const targets = arrayFastClone(this.state.emailTargets); + if (index >= targets.length) return; // not important + if (targets[index].trim() === "") { + targets.splice(index, 1); + this.setState({emailTargets: targets}); + } + }; + + private onShowPeopleClick = () => { + this.setState({showPeople: !this.state.showPeople}); + }; + + private setPersonToggle = (person: IPerson, selected: boolean) => { + const targets = arrayFastClone(this.state.userTargets); + if (selected && !targets.includes(person.userId)) { + targets.push(person.userId); + } else if (!selected && targets.includes(person.userId)) { + targets.splice(targets.indexOf(person.userId), 1); + } + this.setState({userTargets: targets}); + }; + + private renderPerson(person: IPerson, key: any) { + const avatarSize = 36; + return ( +
    + +
    + {person.user.name} + {person.userId} +
    + this.setPersonToggle(person, e.target.checked)} /> +
    + ); + } + + private onShowMorePeople = () => { + this.setState({numPeople: this.state.numPeople + 5}); // arbitrary increase + }; + + public render() { + const emailAddresses = []; + this.state.emailTargets.forEach((address, i) => { + emailAddresses.push(( + this.onAddressChange(e, i)} + label={_t("Email address")} + placeholder={_t("Email address")} + onBlur={() => this.onAddressBlur(i)} + /> + )); + }); + + // Push a clean input + emailAddresses.push(( + this.onAddressChange(e, emailAddresses.length)} + label={emailAddresses.length > 0 ? _t("Add another email") : _t("Email address")} + placeholder={emailAddresses.length > 0 ? _t("Add another email") : _t("Email address")} + /> + )); + + let peopleIntro = null; + const people = []; + if (this.state.showPeople) { + const humansToPresent = this.state.people.slice(0, this.state.numPeople); + humansToPresent.forEach((person, i) => { + people.push(this.renderPerson(person, i)); + }); + if (humansToPresent.length < this.state.people.length) { + people.push(( + {_t("Show more")} + )); + } + } + if (this.state.people.length > 0) { + peopleIntro = ( +
    + {_t("People you know on %(brand)s", {brand: SdkConfig.get().brand})} + + {this.state.showPeople ? _t("Hide") : _t("Show")} + +
    + ); + } + + let buttonText = _t("Skip"); + const targetCount = this.state.userTargets.length + this.state.emailTargets.length; + if (targetCount > 0) { + buttonText = _t("Send %(count)s invites", {count: targetCount}); + } + + return ( + +
    +
    + {emailAddresses} + {peopleIntro} + {people} + {buttonText} +
    +
    +
    + ); + } +} diff --git a/src/components/views/dialogs/ConfirmRedactDialog.js b/src/components/views/dialogs/ConfirmRedactDialog.js index 71139155ec..3106df1d5b 100644 --- a/src/components/views/dialogs/ConfirmRedactDialog.js +++ b/src/components/views/dialogs/ConfirmRedactDialog.js @@ -15,17 +15,14 @@ limitations under the License. */ import React from 'react'; -import createReactClass from 'create-react-class'; import * as sdk from '../../../index'; import { _t } from '../../../languageHandler'; /* * A dialog for confirming a redaction. */ -export default createReactClass({ - displayName: 'ConfirmRedactDialog', - - render: function() { +export default class ConfirmRedactDialog extends React.Component { + render() { const QuestionDialog = sdk.getComponent('views.dialogs.QuestionDialog'); return ( ); - }, -}); + } +} diff --git a/src/components/views/dialogs/ConfirmUserActionDialog.js b/src/components/views/dialogs/ConfirmUserActionDialog.js index 2495c46327..44f57f047e 100644 --- a/src/components/views/dialogs/ConfirmUserActionDialog.js +++ b/src/components/views/dialogs/ConfirmUserActionDialog.js @@ -15,7 +15,6 @@ limitations under the License. */ import React from 'react'; -import createReactClass from 'create-react-class'; import PropTypes from 'prop-types'; import { MatrixClient } from 'matrix-js-sdk'; import * as sdk from '../../../index'; @@ -30,9 +29,8 @@ import { GroupMemberType } from '../../../groups'; * to make it obvious what is going to happen. * Also tweaks the style for 'dangerous' actions (albeit only with colour) */ -export default createReactClass({ - displayName: 'ConfirmUserActionDialog', - propTypes: { +export default class ConfirmUserActionDialog extends React.Component { + static propTypes = { // matrix-js-sdk (room) member object. Supply either this or 'groupMember' member: PropTypes.object, // group member object. Supply either this or 'member' @@ -48,35 +46,36 @@ export default createReactClass({ askReason: PropTypes.bool, danger: PropTypes.bool, onFinished: PropTypes.func.isRequired, - }, + }; - getDefaultProps: () => ({ + static defaultProps = { danger: false, askReason: false, - }), + }; + + constructor(props) { + super(props); - // TODO: [REACT-WARNING] Move this to constructor - UNSAFE_componentWillMount: function() { this._reasonField = null; - }, + } - onOk: function() { + onOk = () => { let reason; if (this._reasonField) { reason = this._reasonField.value; } this.props.onFinished(true, reason); - }, + }; - onCancel: function() { + onCancel = () => { this.props.onFinished(false); - }, + }; - _collectReasonField: function(e) { + _collectReasonField = e => { this._reasonField = e; - }, + }; - render: function() { + render() { const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); const MemberAvatar = sdk.getComponent("views.avatars.MemberAvatar"); @@ -134,5 +133,5 @@ export default createReactClass({ onCancel={this.onCancel} /> ); - }, -}); + } +} diff --git a/src/components/views/dialogs/CreateCommunityPrototypeDialog.tsx b/src/components/views/dialogs/CreateCommunityPrototypeDialog.tsx new file mode 100644 index 0000000000..1d9d92b9c9 --- /dev/null +++ b/src/components/views/dialogs/CreateCommunityPrototypeDialog.tsx @@ -0,0 +1,227 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React, { ChangeEvent } from 'react'; +import BaseDialog from "./BaseDialog"; +import { _t } from "../../../languageHandler"; +import { IDialogProps } from "./IDialogProps"; +import Field from "../elements/Field"; +import AccessibleButton from "../elements/AccessibleButton"; +import { MatrixClientPeg } from "../../../MatrixClientPeg"; +import InfoTooltip from "../elements/InfoTooltip"; +import dis from "../../../dispatcher/dispatcher"; +import {showCommunityRoomInviteDialog} from "../../../RoomInvite"; +import GroupStore from "../../../stores/GroupStore"; + +interface IProps extends IDialogProps { +} + +interface IState { + name: string; + localpart: string; + error: string; + busy: boolean; + avatarFile: File; + avatarPreview: string; +} + +export default class CreateCommunityPrototypeDialog extends React.PureComponent { + private avatarUploadRef: React.RefObject = React.createRef(); + + constructor(props: IProps) { + super(props); + + this.state = { + name: "", + localpart: "", + error: null, + busy: false, + avatarFile: null, + avatarPreview: null, + }; + } + + private onNameChange = (ev: ChangeEvent) => { + const localpart = (ev.target.value || "").toLowerCase().replace(/[^a-z0-9.\-_]/g, '-'); + this.setState({name: ev.target.value, localpart}); + }; + + private onSubmit = async (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + + if (this.state.busy) return; + + // We'll create the community now to see if it's taken, leaving it active in + // the background for the user to look at while they invite people. + this.setState({busy: true}); + try { + let avatarUrl = ''; // must be a string for synapse to accept it + if (this.state.avatarFile) { + avatarUrl = await MatrixClientPeg.get().uploadContent(this.state.avatarFile); + } + + const result = await MatrixClientPeg.get().createGroup({ + localpart: this.state.localpart, + profile: { + name: this.state.name, + avatar_url: avatarUrl, + }, + }); + + // Ensure the tag gets selected now that we've created it + dis.dispatch({action: 'deselect_tags'}, true); + dis.dispatch({ + action: 'select_tag', + tag: result.group_id, + }); + + // Close our own dialog before moving much further + this.props.onFinished(true); + + if (result.room_id) { + // Force the group store to update as it might have missed the general chat + await GroupStore.refreshGroupRooms(result.group_id); + dis.dispatch({ + action: 'view_room', + room_id: result.room_id, + }); + showCommunityRoomInviteDialog(result.room_id, this.state.name); + } else { + dis.dispatch({ + action: 'view_group', + group_id: result.group_id, + group_is_new: true, + }); + } + } catch (e) { + console.error(e); + this.setState({ + busy: false, + error: _t( + "There was an error creating your community. The name may be taken or the " + + "server is unable to process your request.", + ), + }); + } + }; + + private onAvatarChanged = (e: ChangeEvent) => { + if (!e.target.files || !e.target.files.length) { + this.setState({avatarFile: null}); + } else { + this.setState({busy: true}); + const file = e.target.files[0]; + const reader = new FileReader(); + reader.onload = (ev: ProgressEvent) => { + this.setState({avatarFile: file, busy: false, avatarPreview: ev.target.result as string}); + }; + reader.readAsDataURL(file); + } + }; + + private onChangeAvatar = () => { + if (this.avatarUploadRef.current) this.avatarUploadRef.current.click(); + }; + + public render() { + let communityId = null; + if (this.state.localpart) { + communityId = ( + + {_t("Community ID: +:%(domain)s", { + domain: MatrixClientPeg.getHomeserverName(), + }, { + localpart: () => {this.state.localpart}, + })} + + + ); + } + + let helpText = ( + + {_t("You can change this later if needed.")} + + ); + if (this.state.error) { + const classes = "mx_CreateCommunityPrototypeDialog_subtext mx_CreateCommunityPrototypeDialog_subtext_error"; + helpText = ( + + {this.state.error} + + ); + } + + let preview = ; + if (!this.state.avatarPreview) { + preview =
    + } + + return ( + +
    +
    +
    + + {helpText} + + {/*nbsp is to reserve the height of this element when there's nothing*/} +  {communityId} + + + {_t("Create")} + +
    +
    + + + {preview} + +
    + {_t("Add image (optional)")} + + {_t("An image will help people identify your community.")} + +
    +
    +
    +
    +
    + ); + } +} diff --git a/src/components/views/dialogs/CreateGroupDialog.js b/src/components/views/dialogs/CreateGroupDialog.js index 10285ccee0..6636153c98 100644 --- a/src/components/views/dialogs/CreateGroupDialog.js +++ b/src/components/views/dialogs/CreateGroupDialog.js @@ -15,46 +15,42 @@ limitations under the License. */ import React from 'react'; -import createReactClass from 'create-react-class'; import PropTypes from 'prop-types'; import * as sdk from '../../../index'; import dis from '../../../dispatcher/dispatcher'; import { _t } from '../../../languageHandler'; import {MatrixClientPeg} from '../../../MatrixClientPeg'; -export default createReactClass({ - displayName: 'CreateGroupDialog', - propTypes: { +export default class CreateGroupDialog extends React.Component { + static propTypes = { onFinished: PropTypes.func.isRequired, - }, + }; - getInitialState: function() { - return { - groupName: '', - groupId: '', - groupError: null, - creating: false, - createError: null, - }; - }, + state = { + groupName: '', + groupId: '', + groupError: null, + creating: false, + createError: null, + }; - _onGroupNameChange: function(e) { + _onGroupNameChange = e => { this.setState({ groupName: e.target.value, }); - }, + }; - _onGroupIdChange: function(e) { + _onGroupIdChange = e => { this.setState({ groupId: e.target.value, }); - }, + }; - _onGroupIdBlur: function(e) { + _onGroupIdBlur = e => { this._checkGroupId(); - }, + }; - _checkGroupId: function(e) { + _checkGroupId(e) { let error = null; if (!this.state.groupId) { error = _t("Community IDs cannot be empty."); @@ -67,9 +63,9 @@ export default createReactClass({ createError: null, }); return error; - }, + } - _onFormSubmit: function(e) { + _onFormSubmit = e => { e.preventDefault(); if (this._checkGroupId()) return; @@ -94,13 +90,13 @@ export default createReactClass({ }).finally(() => { this.setState({creating: false}); }); - }, + }; - _onCancel: function() { + _onCancel = () => { this.props.onFinished(false); - }, + }; - render: function() { + render() { const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); const Spinner = sdk.getComponent('elements.Spinner'); @@ -171,5 +167,5 @@ export default createReactClass({ ); - }, -}); + } +} diff --git a/src/components/views/dialogs/CreateRoomDialog.js b/src/components/views/dialogs/CreateRoomDialog.js index ce7ac6e59c..2b6bb5e187 100644 --- a/src/components/views/dialogs/CreateRoomDialog.js +++ b/src/components/views/dialogs/CreateRoomDialog.js @@ -16,7 +16,6 @@ limitations under the License. */ import React from 'react'; -import createReactClass from 'create-react-class'; import PropTypes from 'prop-types'; import * as sdk from '../../../index'; import SdkConfig from '../../../SdkConfig'; @@ -25,17 +24,19 @@ import { _t } from '../../../languageHandler'; import {MatrixClientPeg} from '../../../MatrixClientPeg'; import {Key} from "../../../Keyboard"; import {privateShouldBeEncrypted} from "../../../createRoom"; +import {CommunityPrototypeStore} from "../../../stores/CommunityPrototypeStore"; -export default createReactClass({ - displayName: 'CreateRoomDialog', - propTypes: { +export default class CreateRoomDialog extends React.Component { + static propTypes = { onFinished: PropTypes.func.isRequired, defaultPublic: PropTypes.bool, - }, + }; + + constructor(props) { + super(props); - getInitialState() { const config = SdkConfig.get(); - return { + this.state = { isPublic: this.props.defaultPublic || false, isEncrypted: privateShouldBeEncrypted(), name: "", @@ -44,8 +45,12 @@ export default createReactClass({ detailsOpen: false, noFederate: config.default_federate === false, nameIsValid: false, + canChangeEncryption: true, }; - }, + + MatrixClientPeg.get().doesServerForceEncryptionForPreset("private") + .then(isForced => this.setState({canChangeEncryption: !isForced})); + } _roomCreateOptions() { const opts = {}; @@ -67,31 +72,41 @@ export default createReactClass({ } if (!this.state.isPublic) { - opts.encryption = this.state.isEncrypted; + if (this.state.canChangeEncryption) { + opts.encryption = this.state.isEncrypted; + } else { + // the server should automatically do this for us, but for safety + // we'll demand it too. + opts.encryption = true; + } + } + + if (CommunityPrototypeStore.instance.getSelectedCommunityId()) { + opts.associatedWithCommunity = CommunityPrototypeStore.instance.getSelectedCommunityId(); } return opts; - }, + } componentDidMount() { this._detailsRef.addEventListener("toggle", this.onDetailsToggled); // move focus to first field when showing dialog this._nameFieldRef.focus(); - }, + } componentWillUnmount() { this._detailsRef.removeEventListener("toggle", this.onDetailsToggled); - }, + } - _onKeyDown: function(event) { + _onKeyDown = event => { if (event.key === Key.ENTER) { this.onOk(); event.preventDefault(); event.stopPropagation(); } - }, + }; - onOk: async function() { + onOk = async () => { const activeElement = document.activeElement; if (activeElement) { activeElement.blur(); @@ -117,51 +132,51 @@ export default createReactClass({ field.validate({ allowEmpty: false, focused: true }); } } - }, + }; - onCancel: function() { + onCancel = () => { this.props.onFinished(false); - }, + }; - onNameChange(ev) { + onNameChange = ev => { this.setState({name: ev.target.value}); - }, + }; - onTopicChange(ev) { + onTopicChange = ev => { this.setState({topic: ev.target.value}); - }, + }; - onPublicChange(isPublic) { + onPublicChange = isPublic => { this.setState({isPublic}); - }, + }; - onEncryptedChange(isEncrypted) { + onEncryptedChange = isEncrypted => { this.setState({isEncrypted}); - }, + }; - onAliasChange(alias) { + onAliasChange = alias => { this.setState({alias}); - }, + }; - onDetailsToggled(ev) { + onDetailsToggled = ev => { this.setState({detailsOpen: ev.target.open}); - }, + }; - onNoFederateChange(noFederate) { + onNoFederateChange = noFederate => { this.setState({noFederate}); - }, + }; - collectDetailsRef(ref) { + collectDetailsRef = ref => { this._detailsRef = ref; - }, + }; - async onNameValidate(fieldState) { - const result = await this._validateRoomName(fieldState); + onNameValidate = async fieldState => { + const result = await CreateRoomDialog._validateRoomName(fieldState); this.setState({nameIsValid: result.valid}); return result; - }, + }; - _validateRoomName: withValidation({ + static _validateRoomName = withValidation({ rules: [ { key: "required", @@ -169,34 +184,45 @@ export default createReactClass({ invalid: () => _t("Please enter a name for the room"), }, ], - }), + }); - render: function() { + render() { const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); const Field = sdk.getComponent('views.elements.Field'); const LabelledToggleSwitch = sdk.getComponent('views.elements.LabelledToggleSwitch'); const RoomAliasField = sdk.getComponent('views.elements.RoomAliasField'); - let publicPrivateLabel; let aliasField; if (this.state.isPublic) { - publicPrivateLabel = (

    {_t("Set a room address to easily share your room with other people.")}

    ); const domain = MatrixClientPeg.get().getDomain(); aliasField = (
    this._aliasFieldRef = ref} onChange={this.onAliasChange} domain={domain} value={this.state.alias} />
    ); - } else { - publicPrivateLabel = (

    {_t("This room is private, and can only be joined by invitation.")}

    ); + } + + let publicPrivateLabel =

    {_t( + "Private rooms can be found and joined by invitation only. Public rooms can be " + + "found and joined by anyone.", + )}

    ; + if (CommunityPrototypeStore.instance.getSelectedCommunityId()) { + publicPrivateLabel =

    {_t( + "Private rooms can be found and joined by invitation only. Public rooms can be " + + "found and joined by anyone in this community.", + )}

    ; } let e2eeSection; if (!this.state.isPublic) { let microcopy; if (privateShouldBeEncrypted()) { - microcopy = _t("You can’t disable this later. Bridges & most bots won’t work yet."); + if (this.state.canChangeEncryption) { + microcopy = _t("You can’t disable this later. Bridges & most bots won’t work yet."); + } else { + microcopy = _t("Your server requires encryption to be enabled in private rooms."); + } } else { microcopy = _t("Your server admin has disabled end-to-end encryption by default " + "in private rooms & Direct Messages."); @@ -207,12 +233,30 @@ export default createReactClass({ onChange={this.onEncryptedChange} value={this.state.isEncrypted} className='mx_CreateRoomDialog_e2eSwitch' // for end-to-end tests + disabled={!this.state.canChangeEncryption} />

    { microcopy }

    ; } - const title = this.state.isPublic ? _t('Create a public room') : _t('Create a private room'); + let federateLabel = _t( + "You might enable this if the room will only be used for collaborating with internal " + + "teams on your homeserver. This cannot be changed later.", + ); + if (SdkConfig.get().default_federate === false) { + // We only change the label if the default setting is different to avoid jarring text changes to the + // user. They will have read the implications of turning this off/on, so no need to rephrase for them. + federateLabel = _t( + "You might disable this if the room will be used for collaborating with external " + + "teams who have their own homeserver. This cannot be changed later.", + ); + } + + let title = this.state.isPublic ? _t('Create a public room') : _t('Create a private room'); + if (CommunityPrototypeStore.instance.getSelectedCommunityId()) { + const name = CommunityPrototypeStore.instance.getSelectedCommunityName(); + title = _t("Create a room in %(communityName)s", {communityName: name}); + } return ( { this.state.detailsOpen ? _t('Hide advanced') : _t('Show advanced') } - + +

    {federateLabel}

    @@ -236,5 +288,5 @@ export default createReactClass({ onCancel={this.onCancel} /> ); - }, -}); + } +} diff --git a/src/components/views/dialogs/EditCommunityPrototypeDialog.tsx b/src/components/views/dialogs/EditCommunityPrototypeDialog.tsx new file mode 100644 index 0000000000..3071854b3e --- /dev/null +++ b/src/components/views/dialogs/EditCommunityPrototypeDialog.tsx @@ -0,0 +1,167 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React, { ChangeEvent } from 'react'; +import BaseDialog from "./BaseDialog"; +import { _t } from "../../../languageHandler"; +import { IDialogProps } from "./IDialogProps"; +import Field from "../elements/Field"; +import AccessibleButton from "../elements/AccessibleButton"; +import { MatrixClientPeg } from "../../../MatrixClientPeg"; +import { CommunityPrototypeStore } from "../../../stores/CommunityPrototypeStore"; +import FlairStore from "../../../stores/FlairStore"; + +interface IProps extends IDialogProps { + communityId: string; +} + +interface IState { + name: string; + error: string; + busy: boolean; + currentAvatarUrl: string; + avatarFile: File; + avatarPreview: string; +} + +// XXX: This is a lot of duplication from the create dialog, just in a different shape +export default class EditCommunityPrototypeDialog extends React.PureComponent { + private avatarUploadRef: React.RefObject = React.createRef(); + + constructor(props: IProps) { + super(props); + + const profile = CommunityPrototypeStore.instance.getCommunityProfile(props.communityId); + + this.state = { + name: profile?.name || "", + error: null, + busy: false, + avatarFile: null, + avatarPreview: null, + currentAvatarUrl: profile?.avatarUrl, + }; + } + + private onNameChange = (ev: ChangeEvent) => { + this.setState({name: ev.target.value}); + }; + + private onSubmit = async (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + + if (this.state.busy) return; + + // We'll create the community now to see if it's taken, leaving it active in + // the background for the user to look at while they invite people. + this.setState({busy: true}); + try { + let avatarUrl = this.state.currentAvatarUrl || ""; // must be a string for synapse to accept it + if (this.state.avatarFile) { + avatarUrl = await MatrixClientPeg.get().uploadContent(this.state.avatarFile); + } + + await MatrixClientPeg.get().setGroupProfile(this.props.communityId, { + name: this.state.name, + avatar_url: avatarUrl, + }); + + // ask the flair store to update the profile too + await FlairStore.refreshGroupProfile(MatrixClientPeg.get(), this.props.communityId); + + // we did it, so close the dialog + this.props.onFinished(true); + } catch (e) { + console.error(e); + this.setState({ + busy: false, + error: _t("There was an error updating your community. The server is unable to process your request."), + }); + } + }; + + private onAvatarChanged = (e: ChangeEvent) => { + if (!e.target.files || !e.target.files.length) { + this.setState({avatarFile: null}); + } else { + this.setState({busy: true}); + const file = e.target.files[0]; + const reader = new FileReader(); + reader.onload = (ev: ProgressEvent) => { + this.setState({avatarFile: file, busy: false, avatarPreview: ev.target.result as string}); + }; + reader.readAsDataURL(file); + } + }; + + private onChangeAvatar = () => { + if (this.avatarUploadRef.current) this.avatarUploadRef.current.click(); + }; + + public render() { + let preview = ; + if (!this.state.avatarPreview) { + if (this.state.currentAvatarUrl) { + const url = MatrixClientPeg.get().mxcUrlToHttp(this.state.currentAvatarUrl); + preview = ; + } else { + preview =
    + } + } + + return ( + +
    +
    +
    + +
    +
    + + {preview} +
    + {_t("Add image (optional)")} + + {_t("An image will help people identify your community.")} + +
    +
    + + {_t("Save")} + +
    +
    +
    + ); + } +} diff --git a/src/components/views/dialogs/ErrorDialog.js b/src/components/views/dialogs/ErrorDialog.js index fbc5509457..acebdcd854 100644 --- a/src/components/views/dialogs/ErrorDialog.js +++ b/src/components/views/dialogs/ErrorDialog.js @@ -26,14 +26,12 @@ limitations under the License. */ import React from 'react'; -import createReactClass from 'create-react-class'; import PropTypes from 'prop-types'; import * as sdk from '../../../index'; import { _t } from '../../../languageHandler'; -export default createReactClass({ - displayName: 'ErrorDialog', - propTypes: { +export default class ErrorDialog extends React.Component { + static propTypes = { title: PropTypes.string, description: PropTypes.oneOfType([ PropTypes.element, @@ -43,18 +41,16 @@ export default createReactClass({ focus: PropTypes.bool, onFinished: PropTypes.func.isRequired, headerImage: PropTypes.string, - }, + }; - getDefaultProps: function() { - return { - focus: true, - title: null, - description: null, - button: null, - }; - }, + static defaultProps = { + focus: true, + title: null, + description: null, + button: null, + }; - render: function() { + render() { const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); return ( ); - }, -}); + } +} diff --git a/src/contexts/RoomContext.js b/src/components/views/dialogs/IDialogProps.ts similarity index 66% rename from src/contexts/RoomContext.js rename to src/components/views/dialogs/IDialogProps.ts index 8613be195c..1027ca7607 100644 --- a/src/contexts/RoomContext.js +++ b/src/components/views/dialogs/IDialogProps.ts @@ -1,5 +1,5 @@ /* -Copyright 2019 The Matrix.org Foundation C.I.C. +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. @@ -14,12 +14,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { createContext } from "react"; - -const RoomContext = createContext({ - canReact: undefined, - canReply: undefined, - room: undefined, -}); -RoomContext.displayName = "RoomContext"; -export default RoomContext; +export interface IDialogProps { + onFinished: (bool) => void; +} diff --git a/src/components/views/dialogs/InfoDialog.js b/src/components/views/dialogs/InfoDialog.js index b63f6ba9c6..8125bc3edd 100644 --- a/src/components/views/dialogs/InfoDialog.js +++ b/src/components/views/dialogs/InfoDialog.js @@ -17,15 +17,13 @@ limitations under the License. */ import React from 'react'; -import createReactClass from 'create-react-class'; import PropTypes from 'prop-types'; import * as sdk from '../../../index'; import { _t } from '../../../languageHandler'; import classNames from "classnames"; -export default createReactClass({ - displayName: 'InfoDialog', - propTypes: { +export default class InfoDialog extends React.Component { + static propTypes = { className: PropTypes.string, title: PropTypes.string, description: PropTypes.node, @@ -33,21 +31,19 @@ export default createReactClass({ onFinished: PropTypes.func, hasCloseButton: PropTypes.bool, onKeyDown: PropTypes.func, - }, + }; - getDefaultProps: function() { - return { - title: '', - description: '', - hasCloseButton: false, - }; - }, + static defaultProps = { + title: '', + description: '', + hasCloseButton: false, + }; - onFinished: function() { + onFinished = () => { this.props.onFinished(); - }, + }; - render: function() { + render() { const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); return ( @@ -69,5 +65,5 @@ export default createReactClass({ ); - }, -}); + } +} diff --git a/src/components/views/dialogs/InteractiveAuthDialog.js b/src/components/views/dialogs/InteractiveAuthDialog.js index b06ce63ecd..22291225ad 100644 --- a/src/components/views/dialogs/InteractiveAuthDialog.js +++ b/src/components/views/dialogs/InteractiveAuthDialog.js @@ -17,7 +17,6 @@ limitations under the License. */ import React from 'react'; -import createReactClass from 'create-react-class'; import PropTypes from 'prop-types'; import * as sdk from '../../../index'; @@ -27,10 +26,8 @@ import AccessibleButton from '../elements/AccessibleButton'; import {ERROR_USER_CANCELLED} from "../../structures/InteractiveAuth"; import {SSOAuthEntry} from "../auth/InteractiveAuthEntryComponents"; -export default createReactClass({ - displayName: 'InteractiveAuthDialog', - - propTypes: { +export default class InteractiveAuthDialog extends React.Component { + static propTypes = { // matrix client to use for UI auth requests matrixClient: PropTypes.object.isRequired, @@ -70,19 +67,17 @@ export default createReactClass({ // // Default is defined in _getDefaultDialogAesthetics() aestheticsForStagePhases: PropTypes.object, - }, + }; - getInitialState: function() { - return { - authError: null, + state = { + authError: null, - // See _onUpdateStagePhase() - uiaStage: null, - uiaStagePhase: null, - }; - }, + // See _onUpdateStagePhase() + uiaStage: null, + uiaStagePhase: null, + }; - _getDefaultDialogAesthetics: function() { + _getDefaultDialogAesthetics() { const ssoAesthetics = { [SSOAuthEntry.PHASE_PREAUTH]: { title: _t("Use Single Sign On to continue"), @@ -102,9 +97,9 @@ export default createReactClass({ [SSOAuthEntry.LOGIN_TYPE]: ssoAesthetics, [SSOAuthEntry.UNSTABLE_LOGIN_TYPE]: ssoAesthetics, }; - }, + } - _onAuthFinished: function(success, result) { + _onAuthFinished = (success, result) => { if (success) { this.props.onFinished(true, result); } else { @@ -116,18 +111,18 @@ export default createReactClass({ }); } } - }, + }; - _onUpdateStagePhase: function(newStage, newPhase) { + _onUpdateStagePhase = (newStage, newPhase) => { // We copy the stage and stage phase params into state for title selection in render() this.setState({uiaStage: newStage, uiaStagePhase: newPhase}); - }, + }; - _onDismissClick: function() { + _onDismissClick = () => { this.props.onFinished(false); - }, + }; - render: function() { + render() { const InteractiveAuth = sdk.getComponent("structures.InteractiveAuth"); const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); @@ -190,5 +185,5 @@ export default createReactClass({ { content } ); - }, -}); + } +} diff --git a/src/components/views/dialogs/InviteDialog.js b/src/components/views/dialogs/InviteDialog.js index c90811ed5a..73101056f3 100644 --- a/src/components/views/dialogs/InviteDialog.js +++ b/src/components/views/dialogs/InviteDialog.js @@ -32,11 +32,14 @@ import IdentityAuthClient from "../../../IdentityAuthClient"; import Modal from "../../../Modal"; import {humanizeTime} from "../../../utils/humanize"; import createRoom, {canEncryptToAllUsers, privateShouldBeEncrypted} from "../../../createRoom"; -import {inviteMultipleToRoom} from "../../../RoomInvite"; +import {inviteMultipleToRoom, showCommunityInviteDialog} from "../../../RoomInvite"; import {Key} from "../../../Keyboard"; import {Action} from "../../../dispatcher/actions"; import {DefaultTagID} from "../../../stores/room-list/models"; import RoomListStore from "../../../stores/room-list/RoomListStore"; +import {CommunityPrototypeStore} from "../../../stores/CommunityPrototypeStore"; +import SettingsStore from "../../../settings/SettingsStore"; +import {UIFeature} from "../../../settings/UIFeature"; // we have a number of types defined from the Matrix spec which can't reasonably be altered here. /* eslint-disable camelcase */ @@ -327,7 +330,7 @@ export default class InviteDialog extends React.PureComponent { this.state = { targets: [], // array of Member objects (see interface above) filterText: "", - recents: this._buildRecents(alreadyInvited), + recents: InviteDialog.buildRecents(alreadyInvited), numRecentsShown: INITIAL_ROOMS_SHOWN, suggestions: this._buildSuggestions(alreadyInvited), numSuggestionsShown: INITIAL_ROOMS_SHOWN, @@ -344,7 +347,7 @@ export default class InviteDialog extends React.PureComponent { this._editorRef = createRef(); } - _buildRecents(excludedTargetIds: Set): {userId: string, user: RoomMember, lastActive: number} { + static buildRecents(excludedTargetIds: Set): {userId: string, user: RoomMember, lastActive: number} { const rooms = DMRoomMap.shared().getUniqueRoomsWithIndividuals(); // map of userId => js-sdk Room // Also pull in all the rooms tagged as DefaultTagID.DM so we don't miss anything. Sometimes the @@ -548,7 +551,7 @@ export default class InviteDialog extends React.PureComponent { if (this.state.filterText.startsWith('@')) { // Assume mxid newMember = new DirectoryMember({user_id: this.state.filterText, display_name: null, avatar_url: null}); - } else { + } else if (SettingsStore.getValue(UIFeature.IdentityServer)) { // Assume email newMember = new ThreepidMember(this.state.filterText); } @@ -733,7 +736,7 @@ export default class InviteDialog extends React.PureComponent { this.setState({tryingIdentityServer: true}); return; } - if (term.indexOf('@') > 0 && Email.looksValid(term)) { + if (term.indexOf('@') > 0 && Email.looksValid(term) && SettingsStore.getValue(UIFeature.IdentityServer)) { // Start off by suggesting the plain email while we try and resolve it // to a real account. this.setState({ @@ -909,12 +912,23 @@ export default class InviteDialog extends React.PureComponent { this.props.onFinished(); }; + _onCommunityInviteClick = (e) => { + this.props.onFinished(); + showCommunityInviteDialog(CommunityPrototypeStore.instance.getSelectedCommunityId()); + }; + _renderSection(kind: "recents"|"suggestions") { let sourceMembers = kind === 'recents' ? this.state.recents : this.state.suggestions; let showNum = kind === 'recents' ? this.state.numRecentsShown : this.state.numSuggestionsShown; const showMoreFn = kind === 'recents' ? this._showMoreRecents.bind(this) : this._showMoreSuggestions.bind(this); const lastActive = (m) => kind === 'recents' ? m.lastActive : null; let sectionName = kind === 'recents' ? _t("Recent Conversations") : _t("Suggestions"); + let sectionSubname = null; + + if (kind === 'suggestions' && CommunityPrototypeStore.instance.getSelectedCommunityId()) { + const communityName = CommunityPrototypeStore.instance.getSelectedCommunityName(); + sectionSubname = _t("May include members not in %(communityName)s", {communityName}); + } if (this.props.kind === KIND_INVITE) { sectionName = kind === 'recents' ? _t("Recently Direct Messaged") : _t("Suggestions"); @@ -993,6 +1007,7 @@ export default class InviteDialog extends React.PureComponent { return (

    {sectionName}

    + {sectionSubname ?

    {sectionSubname}

    : null} {tiles} {showMore}
    @@ -1024,7 +1039,9 @@ export default class InviteDialog extends React.PureComponent { } _renderIdentityServerWarning() { - if (!this.state.tryingIdentityServer || this.state.canUseIdentityServer) { + if (!this.state.tryingIdentityServer || this.state.canUseIdentityServer || + !SettingsStore.getValue(UIFeature.IdentityServer) + ) { return null; } @@ -1073,30 +1090,92 @@ export default class InviteDialog extends React.PureComponent { let buttonText; let goButtonFn; + const identityServersEnabled = SettingsStore.getValue(UIFeature.IdentityServer); + const userId = MatrixClientPeg.get().getUserId(); if (this.props.kind === KIND_DM) { title = _t("Direct Messages"); - helpText = _t( - "Start a conversation with someone using their name, username (like ) or email address.", - {}, - {userId: () => { - return {userId}; - }}, - ); + + if (identityServersEnabled) { + helpText = _t( + "Start a conversation with someone using their name, username (like ) or email address.", + {}, + {userId: () => { + return ( + {userId} + ); + }}, + ); + } else { + helpText = _t( + "Start a conversation with someone using their name or username (like ).", + {}, + {userId: () => { + return ( + {userId} + ); + }}, + ); + } + + if (CommunityPrototypeStore.instance.getSelectedCommunityId()) { + const communityName = CommunityPrototypeStore.instance.getSelectedCommunityName(); + const inviteText = _t("This won't invite them to %(communityName)s. " + + "To invite someone to %(communityName)s, click here", + {communityName}, { + userId: () => { + return ( + {userId} + ); + }, + a: (sub) => { + return ( + {sub} + ); + }, + }, + ); + helpText = + { helpText } {inviteText} + ; + } buttonText = _t("Go"); goButtonFn = this._startDm; } else { // KIND_INVITE title = _t("Invite to this room"); - helpText = _t( - "Invite someone using their name, username (like ), email address or share this room.", - {}, - { - userId: () => - {userId}, - a: (sub) => - {sub}, - }, - ); + + if (identityServersEnabled) { + helpText = _t( + "Invite someone using their name, username (like ), email address or " + + "share this room.", + {}, + { + userId: () => + {userId}, + a: (sub) => + {sub}, + }, + ); + } else { + helpText = _t( + "Invite someone using their name, username (like ) or share this room.", + {}, + { + userId: () => + {userId}, + a: (sub) => + {sub}, + }, + ); + } + buttonText = _t("Invite"); goButtonFn = this._inviteUsers; } diff --git a/src/components/views/dialogs/LogoutDialog.js b/src/components/views/dialogs/LogoutDialog.js index 930acaa0b8..af36dba2b6 100644 --- a/src/components/views/dialogs/LogoutDialog.js +++ b/src/components/views/dialogs/LogoutDialog.js @@ -20,7 +20,8 @@ import Modal from '../../../Modal'; import * as sdk from '../../../index'; import dis from '../../../dispatcher/dispatcher'; import { _t } from '../../../languageHandler'; -import {MatrixClientPeg} from '../../../MatrixClientPeg'; +import { MatrixClientPeg } from '../../../MatrixClientPeg'; +import RestoreKeyBackupDialog from './security/RestoreKeyBackupDialog'; export default class LogoutDialog extends React.Component { defaultProps = { @@ -73,7 +74,7 @@ export default class LogoutDialog extends React.Component { _onExportE2eKeysClicked() { Modal.createTrackedDialogAsync('Export E2E Keys', '', - import('../../../async-components/views/dialogs/ExportE2eKeysDialog'), + import('../../../async-components/views/dialogs/security/ExportE2eKeysDialog'), { matrixClient: MatrixClientPeg.get(), }, @@ -93,14 +94,13 @@ export default class LogoutDialog extends React.Component { // A key backup exists for this account, but the creating device is not // verified, so restore the backup which will give us the keys from it and // allow us to trust it (ie. upload keys to it) - const RestoreKeyBackupDialog = sdk.getComponent('dialogs.keybackup.RestoreKeyBackupDialog'); Modal.createTrackedDialog( 'Restore Backup', '', RestoreKeyBackupDialog, null, null, /* priority = */ false, /* static = */ true, ); } else { Modal.createTrackedDialogAsync("Key Backup", "Key Backup", - import("../../../async-components/views/dialogs/keybackup/CreateKeyBackupDialog"), + import("../../../async-components/views/dialogs/security/CreateKeyBackupDialog"), null, null, /* priority = */ false, /* static = */ true, ); } diff --git a/src/components/views/dialogs/QuestionDialog.js b/src/components/views/dialogs/QuestionDialog.js index 07a1eae5d5..d6de60195f 100644 --- a/src/components/views/dialogs/QuestionDialog.js +++ b/src/components/views/dialogs/QuestionDialog.js @@ -16,14 +16,12 @@ limitations under the License. */ import React from 'react'; -import createReactClass from 'create-react-class'; import PropTypes from 'prop-types'; import * as sdk from '../../../index'; import { _t } from '../../../languageHandler'; -export default createReactClass({ - displayName: 'QuestionDialog', - propTypes: { +export default class QuestionDialog extends React.Component { + static propTypes = { title: PropTypes.string, description: PropTypes.node, extraButtons: PropTypes.node, @@ -34,29 +32,27 @@ export default createReactClass({ headerImage: PropTypes.string, quitOnly: PropTypes.bool, // quitOnly doesn't show the cancel button just the quit [x]. fixedWidth: PropTypes.bool, - }, + }; - getDefaultProps: function() { - return { - title: "", - description: "", - extraButtons: null, - focus: true, - hasCancelButton: true, - danger: false, - quitOnly: false, - }; - }, + static defaultProps = { + title: "", + description: "", + extraButtons: null, + focus: true, + hasCancelButton: true, + danger: false, + quitOnly: false, + }; - onOk: function() { + onOk = () => { this.props.onFinished(true); - }, + }; - onCancel: function() { + onCancel = () => { this.props.onFinished(false); - }, + }; - render: function() { + render() { const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); let primaryButtonClass = ""; @@ -88,5 +84,5 @@ export default createReactClass({ ); - }, -}); + } +} diff --git a/src/components/views/dialogs/RoomSettingsDialog.js b/src/components/views/dialogs/RoomSettingsDialog.js index 613708e436..a43b284c42 100644 --- a/src/components/views/dialogs/RoomSettingsDialog.js +++ b/src/components/views/dialogs/RoomSettingsDialog.js @@ -29,6 +29,7 @@ import * as sdk from "../../../index"; import {MatrixClientPeg} from "../../../MatrixClientPeg"; import dis from "../../../dispatcher/dispatcher"; import SettingsStore from "../../../settings/SettingsStore"; +import {UIFeature} from "../../../settings/UIFeature"; export const ROOM_GENERAL_TAB = "ROOM_GENERAL_TAB"; export const ROOM_SECURITY_TAB = "ROOM_SECURITY_TAB"; @@ -96,12 +97,14 @@ export default class RoomSettingsDialog extends React.Component { )); } - tabs.push(new Tab( - ROOM_ADVANCED_TAB, - _td("Advanced"), - "mx_RoomSettingsDialog_warningIcon", - , - )); + if (SettingsStore.getValue(UIFeature.AdvancedSettings)) { + tabs.push(new Tab( + ROOM_ADVANCED_TAB, + _td("Advanced"), + "mx_RoomSettingsDialog_warningIcon", + , + )); + } return tabs; } diff --git a/src/components/views/dialogs/RoomUpgradeDialog.js b/src/components/views/dialogs/RoomUpgradeDialog.js index c45d82303b..85e97444ed 100644 --- a/src/components/views/dialogs/RoomUpgradeDialog.js +++ b/src/components/views/dialogs/RoomUpgradeDialog.js @@ -15,38 +15,33 @@ limitations under the License. */ import React from 'react'; -import createReactClass from 'create-react-class'; import PropTypes from 'prop-types'; import * as sdk from '../../../index'; import {MatrixClientPeg} from '../../../MatrixClientPeg'; import Modal from '../../../Modal'; import { _t } from '../../../languageHandler'; -export default createReactClass({ - displayName: 'RoomUpgradeDialog', - - propTypes: { +export default class RoomUpgradeDialog extends React.Component { + static propTypes = { room: PropTypes.object.isRequired, onFinished: PropTypes.func.isRequired, - }, + }; - componentDidMount: async function() { + state = { + busy: true, + }; + + async componentDidMount() { const recommended = await this.props.room.getRecommendedVersion(); this._targetVersion = recommended.version; this.setState({busy: false}); - }, + } - getInitialState: function() { - return { - busy: true, - }; - }, - - _onCancelClick: function() { + _onCancelClick = () => { this.props.onFinished(false); - }, + }; - _onUpgradeClick: function() { + _onUpgradeClick = () => { this.setState({busy: true}); MatrixClientPeg.get().upgradeRoom(this.props.room.roomId, this._targetVersion).then(() => { this.props.onFinished(true); @@ -59,9 +54,9 @@ export default createReactClass({ }).finally(() => { this.setState({busy: false}); }); - }, + }; - render: function() { + render() { const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); const Spinner = sdk.getComponent('views.elements.Spinner'); @@ -106,5 +101,5 @@ export default createReactClass({ {buttons} ); - }, -}); + } +} diff --git a/src/components/views/dialogs/ServerOfflineDialog.tsx b/src/components/views/dialogs/ServerOfflineDialog.tsx index f6767dcb8d..81f628343b 100644 --- a/src/components/views/dialogs/ServerOfflineDialog.tsx +++ b/src/components/views/dialogs/ServerOfflineDialog.tsx @@ -27,9 +27,9 @@ import Spinner from "../elements/Spinner"; import AccessibleButton from "../elements/AccessibleButton"; import { UPDATE_EVENT } from "../../../stores/AsyncStore"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; +import { IDialogProps } from "./IDialogProps"; -interface IProps { - onFinished: (bool) => void; +interface IProps extends IDialogProps { } export default class ServerOfflineDialog extends React.PureComponent { diff --git a/src/components/views/dialogs/SessionRestoreErrorDialog.js b/src/components/views/dialogs/SessionRestoreErrorDialog.js index 3706172085..bae6b19fbe 100644 --- a/src/components/views/dialogs/SessionRestoreErrorDialog.js +++ b/src/components/views/dialogs/SessionRestoreErrorDialog.js @@ -17,7 +17,6 @@ limitations under the License. */ import React from 'react'; -import createReactClass from 'create-react-class'; import PropTypes from 'prop-types'; import * as sdk from '../../../index'; import SdkConfig from '../../../SdkConfig'; @@ -25,20 +24,18 @@ import Modal from '../../../Modal'; import { _t } from '../../../languageHandler'; -export default createReactClass({ - displayName: 'SessionRestoreErrorDialog', - - propTypes: { +export default class SessionRestoreErrorDialog extends React.Component { + static propTypes = { error: PropTypes.string.isRequired, onFinished: PropTypes.func.isRequired, - }, + }; - _sendBugReport: function() { + _sendBugReport = () => { const BugReportDialog = sdk.getComponent("dialogs.BugReportDialog"); Modal.createTrackedDialog('Session Restore Error', 'Send Bug Report Dialog', BugReportDialog, {}); - }, + }; - _onClearStorageClick: function() { + _onClearStorageClick = () => { const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); Modal.createTrackedDialog('Session Restore Confirm Logout', '', QuestionDialog, { title: _t("Sign out"), @@ -48,15 +45,15 @@ export default createReactClass({ danger: true, onFinished: this.props.onFinished, }); - }, + }; - _onRefreshClick: function() { + _onRefreshClick = () => { // Is this likely to help? Probably not, but giving only one button // that clears your storage seems awful. window.location.reload(true); - }, + }; - render: function() { + render() { const brand = SdkConfig.get().brand; const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); @@ -110,5 +107,5 @@ export default createReactClass({ { dialogButtons } ); - }, -}); + } +} diff --git a/src/components/views/dialogs/SetEmailDialog.js b/src/components/views/dialogs/SetEmailDialog.js index 2e38d6a7c4..6514d94dc9 100644 --- a/src/components/views/dialogs/SetEmailDialog.js +++ b/src/components/views/dialogs/SetEmailDialog.js @@ -16,7 +16,6 @@ limitations under the License. */ import React from 'react'; -import createReactClass from 'create-react-class'; import PropTypes from 'prop-types'; import * as sdk from '../../../index'; import * as Email from '../../../email'; @@ -25,31 +24,28 @@ import { _t } from '../../../languageHandler'; import Modal from '../../../Modal'; -/** +/* * Prompt the user to set an email address. * * On success, `onFinished(true)` is called. */ -export default createReactClass({ - displayName: 'SetEmailDialog', - propTypes: { +export default class SetEmailDialog extends React.Component { + static propTypes = { onFinished: PropTypes.func.isRequired, - }, + }; - getInitialState: function() { - return { - emailAddress: '', - emailBusy: false, - }; - }, + state = { + emailAddress: '', + emailBusy: false, + }; - onEmailAddressChanged: function(value) { + onEmailAddressChanged = value => { this.setState({ emailAddress: value, }); - }, + }; - onSubmit: function() { + onSubmit = () => { const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); @@ -81,21 +77,21 @@ export default createReactClass({ }); }); this.setState({emailBusy: true}); - }, + }; - onCancelled: function() { + onCancelled = () => { this.props.onFinished(false); - }, + }; - onEmailDialogFinished: function(ok) { + onEmailDialogFinished = ok => { if (ok) { this.verifyEmailAddress(); } else { this.setState({emailBusy: false}); } - }, + }; - verifyEmailAddress: function() { + verifyEmailAddress() { this._addThreepid.checkEmailLinkClicked().then(() => { this.props.onFinished(true); }, (err) => { @@ -119,9 +115,9 @@ export default createReactClass({ }); } }); - }, + } - render: function() { + render() { const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); const Spinner = sdk.getComponent('elements.Spinner'); const EditableText = sdk.getComponent('elements.EditableText'); @@ -161,5 +157,5 @@ export default createReactClass({
    ); - }, -}); + } +} diff --git a/src/components/views/dialogs/SetMxIdDialog.js b/src/components/views/dialogs/SetMxIdDialog.js deleted file mode 100644 index f99d065e7e..0000000000 --- a/src/components/views/dialogs/SetMxIdDialog.js +++ /dev/null @@ -1,307 +0,0 @@ -/* -Copyright 2016 OpenMarket Ltd -Copyright 2017 Vector Creations Ltd - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import React, {createRef} from 'react'; -import createReactClass from 'create-react-class'; -import PropTypes from 'prop-types'; -import * as sdk from '../../../index'; -import {MatrixClientPeg} from '../../../MatrixClientPeg'; -import classnames from 'classnames'; -import { Key } from '../../../Keyboard'; -import { _t } from '../../../languageHandler'; -import { SAFE_LOCALPART_REGEX } from '../../../Registration'; - -// The amount of time to wait for further changes to the input username before -// sending a request to the server -const USERNAME_CHECK_DEBOUNCE_MS = 250; - -/** - * Prompt the user to set a display name. - * - * On success, `onFinished(true, newDisplayName)` is called. - */ -export default createReactClass({ - displayName: 'SetMxIdDialog', - propTypes: { - onFinished: PropTypes.func.isRequired, - // Called when the user requests to register with a different homeserver - onDifferentServerClicked: PropTypes.func.isRequired, - // Called if the user wants to switch to login instead - onLoginClick: PropTypes.func.isRequired, - }, - - getInitialState: function() { - return { - // The entered username - username: '', - // Indicate ongoing work on the username - usernameBusy: false, - // Indicate error with username - usernameError: '', - // Assume the homeserver supports username checking until "M_UNRECOGNIZED" - usernameCheckSupport: true, - - // Whether the auth UI is currently being used - doingUIAuth: false, - // Indicate error with auth - authError: '', - }; - }, - - // TODO: [REACT-WARNING] Replace component with real class, use constructor for refs - UNSAFE_componentWillMount: function() { - this._input_value = createRef(); - this._uiAuth = createRef(); - }, - - componentDidMount: function() { - this._input_value.current.select(); - - this._matrixClient = MatrixClientPeg.get(); - }, - - onValueChange: function(ev) { - this.setState({ - username: ev.target.value, - usernameBusy: true, - usernameError: '', - }, () => { - if (!this.state.username || !this.state.usernameCheckSupport) { - this.setState({ - usernameBusy: false, - }); - return; - } - - // Debounce the username check to limit number of requests sent - if (this._usernameCheckTimeout) { - clearTimeout(this._usernameCheckTimeout); - } - this._usernameCheckTimeout = setTimeout(() => { - this._doUsernameCheck().finally(() => { - this.setState({ - usernameBusy: false, - }); - }); - }, USERNAME_CHECK_DEBOUNCE_MS); - }); - }, - - onKeyUp: function(ev) { - if (ev.key === Key.ENTER) { - this.onSubmit(); - } - }, - - onSubmit: function(ev) { - if (this._uiAuth.current) { - this._uiAuth.current.tryContinue(); - } - this.setState({ - doingUIAuth: true, - }); - }, - - _doUsernameCheck: function() { - // We do a quick check ahead of the username availability API to ensure the - // user ID roughly looks okay from a Matrix perspective. - if (!SAFE_LOCALPART_REGEX.test(this.state.username)) { - this.setState({ - usernameError: _t("A username can only contain lower case letters, numbers and '=_-./'"), - }); - return Promise.reject(); - } - - // Check if username is available - return this._matrixClient.isUsernameAvailable(this.state.username).then( - (isAvailable) => { - if (isAvailable) { - this.setState({usernameError: ''}); - } - }, - (err) => { - // Indicate whether the homeserver supports username checking - const newState = { - usernameCheckSupport: err.errcode !== "M_UNRECOGNIZED", - }; - console.error('Error whilst checking username availability: ', err); - switch (err.errcode) { - case "M_USER_IN_USE": - newState.usernameError = _t('Username not available'); - break; - case "M_INVALID_USERNAME": - newState.usernameError = _t( - 'Username invalid: %(errMessage)s', - { errMessage: err.message}, - ); - break; - case "M_UNRECOGNIZED": - // This homeserver doesn't support username checking, assume it's - // fine and rely on the error appearing in registration step. - newState.usernameError = ''; - break; - case undefined: - newState.usernameError = _t('Something went wrong!'); - break; - default: - newState.usernameError = _t( - 'An error occurred: %(error_string)s', - { error_string: err.message }, - ); - break; - } - this.setState(newState); - }, - ); - }, - - _generatePassword: function() { - return Math.random().toString(36).slice(2); - }, - - _makeRegisterRequest: function(auth) { - // Not upgrading - changing mxids - const guestAccessToken = null; - if (!this._generatedPassword) { - this._generatedPassword = this._generatePassword(); - } - return this._matrixClient.register( - this.state.username, - this._generatedPassword, - undefined, // session id: included in the auth dict already - auth, - {}, - guestAccessToken, - ); - }, - - _onUIAuthFinished: function(success, response) { - this.setState({ - doingUIAuth: false, - }); - - if (!success) { - this.setState({ authError: response.message }); - return; - } - - this.props.onFinished(true, { - userId: response.user_id, - deviceId: response.device_id, - homeserverUrl: this._matrixClient.getHomeserverUrl(), - identityServerUrl: this._matrixClient.getIdentityServerUrl(), - accessToken: response.access_token, - password: this._generatedPassword, - }); - }, - - render: function() { - const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); - const InteractiveAuth = sdk.getComponent('structures.InteractiveAuth'); - - let auth; - if (this.state.doingUIAuth) { - auth = ; - } - const inputClasses = classnames({ - "mx_SetMxIdDialog_input": true, - "error": Boolean(this.state.usernameError), - }); - - let usernameIndicator = null; - if (this.state.usernameBusy) { - usernameIndicator =
    {_t("Checking...")}
    ; - } else { - const usernameAvailable = this.state.username && - this.state.usernameCheckSupport && !this.state.usernameError; - const usernameIndicatorClasses = classnames({ - "error": Boolean(this.state.usernameError), - "success": usernameAvailable, - }); - usernameIndicator =
    - { usernameAvailable ? _t('Username available') : this.state.usernameError } -
    ; - } - - let authErrorIndicator = null; - if (this.state.authError) { - authErrorIndicator =
    - { this.state.authError } -
    ; - } - const canContinue = this.state.username && - !this.state.usernameError && - !this.state.usernameBusy; - - return ( - -
    -
    - -
    - { usernameIndicator } -

    - { _t( - 'This will be your account name on the ' + - 'homeserver, or you can pick a different server.', - {}, - { - 'span': { this.props.homeserverUrl }, - 'a': (sub) => { sub }, - }, - ) } -

    -

    - { _t( - 'If you already have a Matrix account you can log in instead.', - {}, - { 'a': (sub) => { sub } }, - ) } -

    - { auth } - { authErrorIndicator } -
    -
    - -
    -
    - ); - }, -}); diff --git a/src/components/views/dialogs/SetPasswordDialog.js b/src/components/views/dialogs/SetPasswordDialog.js deleted file mode 100644 index fcc6e67656..0000000000 --- a/src/components/views/dialogs/SetPasswordDialog.js +++ /dev/null @@ -1,136 +0,0 @@ -/* -Copyright 2017 Vector Creations Ltd -Copyright 2018 New Vector Ltd -Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -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 createReactClass from 'create-react-class'; -import PropTypes from 'prop-types'; -import * as sdk from '../../../index'; -import { _t } from '../../../languageHandler'; -import Modal from '../../../Modal'; - -const WarmFuzzy = function(props) { - const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); - let title = _t('You have successfully set a password!'); - if (props.didSetEmail) { - title = _t('You have successfully set a password and an email address!'); - } - const advice = _t('You can now return to your account after signing out, and sign in on other devices.'); - let extraAdvice = null; - if (!props.didSetEmail) { - extraAdvice = _t('Remember, you can always set an email address in user settings if you change your mind.'); - } - - return -
    -

    - { advice } -

    -

    - { extraAdvice } -

    -
    -
    - -
    -
    ; -}; - -/** - * Prompt the user to set a password - * - * On success, `onFinished()` when finished - */ -export default createReactClass({ - displayName: 'SetPasswordDialog', - propTypes: { - onFinished: PropTypes.func.isRequired, - }, - - getInitialState: function() { - return { - error: null, - }; - }, - - componentDidMount: function() { - console.info('SetPasswordDialog component did mount'); - }, - - _onPasswordChanged: function(res) { - Modal.createDialog(WarmFuzzy, { - didSetEmail: res.didSetEmail, - onFinished: () => { - this.props.onFinished(); - }, - }); - }, - - _onPasswordChangeError: function(err) { - let errMsg = err.error || ""; - if (err.httpStatus === 403) { - errMsg = _t('Failed to change password. Is your password correct?'); - } else if (err.httpStatus) { - errMsg += ' ' + _t( - '(HTTP status %(httpStatus)s)', - { httpStatus: err.httpStatus }, - ); - } - this.setState({ - error: errMsg, - }); - }, - - render: function() { - const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); - const ChangePassword = sdk.getComponent('views.settings.ChangePassword'); - - return ( - -
    -

    - { _t('This will allow you to return to your account after signing out, and sign in on other sessions.') } -

    - -
    - { this.state.error } -
    -
    -
    - ); - }, -}); diff --git a/src/components/views/dialogs/ShareDialog.tsx b/src/components/views/dialogs/ShareDialog.tsx index dc2a987f13..1569977d58 100644 --- a/src/components/views/dialogs/ShareDialog.tsx +++ b/src/components/views/dialogs/ShareDialog.tsx @@ -31,6 +31,9 @@ import {toRightOf} from "../../structures/ContextMenu"; import {copyPlaintext, selectText} from "../../../utils/strings"; import StyledCheckbox from '../elements/StyledCheckbox'; import AccessibleTooltipButton from '../elements/AccessibleTooltipButton'; +import { IDialogProps } from "./IDialogProps"; +import SettingsStore from "../../../settings/SettingsStore"; +import {UIFeature} from "../../../settings/UIFeature"; const socials = [ { @@ -60,8 +63,7 @@ const socials = [ }, ]; -interface IProps { - onFinished: () => void; +interface IProps extends IDialogProps { target: Room | User | Group | RoomMember | MatrixEvent; permalinkCreator: RoomPermalinkCreator; } @@ -186,8 +188,8 @@ export default class ShareDialog extends React.PureComponent { title = _t('Share Room Message'); checkbox =
    { _t('Link to selected message') } @@ -197,34 +199,18 @@ export default class ShareDialog extends React.PureComponent { const matrixToUrl = this.getUrl(); const encodedUrl = encodeURIComponent(matrixToUrl); - const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); - return -
    - - { checkbox } -
    + const showQrCode = SettingsStore.getValue(UIFeature.ShareQRCode); + const showSocials = SettingsStore.getValue(UIFeature.ShareSocial); + let qrSocialSection; + if (showQrCode || showSocials) { + qrSocialSection = <> +
    -
    + { showQrCode &&
    -
    -
    +
    } + { showSocials &&
    { socials.map((social) => ( { {social.name} )) } -
    +
    }
    + ; + } + + const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); + return +
    + + { checkbox } + { qrSocialSection }
    ; } diff --git a/src/components/views/dialogs/SlashCommandHelpDialog.js b/src/components/views/dialogs/SlashCommandHelpDialog.js index bae5b37993..5b4148e939 100644 --- a/src/components/views/dialogs/SlashCommandHelpDialog.js +++ b/src/components/views/dialogs/SlashCommandHelpDialog.js @@ -24,6 +24,7 @@ export default ({onFinished}) => { const categories = {}; Commands.forEach(cmd => { + if (!cmd.isEnabled()) return; if (!categories[cmd.category]) { categories[cmd.category] = []; } diff --git a/src/components/views/dialogs/TextInputDialog.js b/src/components/views/dialogs/TextInputDialog.js index d7ca3f144d..571ed7e413 100644 --- a/src/components/views/dialogs/TextInputDialog.js +++ b/src/components/views/dialogs/TextInputDialog.js @@ -15,14 +15,12 @@ limitations under the License. */ import React, {createRef} from 'react'; -import createReactClass from 'create-react-class'; import PropTypes from 'prop-types'; import * as sdk from '../../../index'; import Field from "../elements/Field"; -export default createReactClass({ - displayName: 'TextInputDialog', - propTypes: { +export default class TextInputDialog extends React.Component { + static propTypes = { title: PropTypes.string, description: PropTypes.oneOfType([ PropTypes.element, @@ -36,39 +34,36 @@ export default createReactClass({ hasCancel: PropTypes.bool, validator: PropTypes.func, // result of withValidation fixedWidth: PropTypes.bool, - }, + }; - getDefaultProps: function() { - return { - title: "", - value: "", - description: "", - focus: true, - hasCancel: true, - }; - }, + static defaultProps = { + title: "", + value: "", + description: "", + focus: true, + hasCancel: true, + }; - getInitialState: function() { - return { + constructor(props) { + super(props); + + this._field = createRef(); + + this.state = { value: this.props.value, valid: false, }; - }, + } - // TODO: [REACT-WARNING] Replace component with real class, use constructor for refs - UNSAFE_componentWillMount: function() { - this._field = createRef(); - }, - - componentDidMount: function() { + componentDidMount() { if (this.props.focus) { // Set the cursor at the end of the text input // this._field.current.value = this.props.value; this._field.current.focus(); } - }, + } - onOk: async function(ev) { + onOk = async ev => { ev.preventDefault(); if (this.props.validator) { await this._field.current.validate({ allowEmpty: false }); @@ -80,27 +75,27 @@ export default createReactClass({ } } this.props.onFinished(true, this.state.value); - }, + }; - onCancel: function() { + onCancel = () => { this.props.onFinished(false); - }, + }; - onChange: function(ev) { + onChange = ev => { this.setState({ value: ev.target.value, }); - }, + }; - onValidate: async function(fieldState) { + onValidate = async fieldState => { const result = await this.props.validator(fieldState); this.setState({ valid: result.valid, }); return result; - }, + }; - render: function() { + render() { const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); return ( @@ -137,5 +132,5 @@ export default createReactClass({ /> ); - }, -}); + } +} diff --git a/src/components/views/dialogs/UserSettingsDialog.js b/src/components/views/dialogs/UserSettingsDialog.js index ffde03fe31..7164540aea 100644 --- a/src/components/views/dialogs/UserSettingsDialog.js +++ b/src/components/views/dialogs/UserSettingsDialog.js @@ -32,6 +32,7 @@ import FlairUserSettingsTab from "../settings/tabs/user/FlairUserSettingsTab"; import * as sdk from "../../../index"; import SdkConfig from "../../../SdkConfig"; import MjolnirUserSettingsTab from "../settings/tabs/user/MjolnirUserSettingsTab"; +import {UIFeature} from "../../../settings/UIFeature"; export const USER_GENERAL_TAB = "USER_GENERAL_TAB"; export const USER_APPEARANCE_TAB = "USER_APPEARANCE_TAB"; @@ -86,12 +87,14 @@ export default class UserSettingsDialog extends React.Component { "mx_UserSettingsDialog_appearanceIcon", , )); - tabs.push(new Tab( - USER_FLAIR_TAB, - _td("Flair"), - "mx_UserSettingsDialog_flairIcon", - , - )); + if (SettingsStore.getValue(UIFeature.Flair)) { + tabs.push(new Tab( + USER_FLAIR_TAB, + _td("Flair"), + "mx_UserSettingsDialog_flairIcon", + , + )); + } tabs.push(new Tab( USER_NOTIFICATIONS_TAB, _td("Notifications"), @@ -104,12 +107,16 @@ export default class UserSettingsDialog extends React.Component { "mx_UserSettingsDialog_preferencesIcon", , )); - tabs.push(new Tab( - USER_VOICE_TAB, - _td("Voice & Video"), - "mx_UserSettingsDialog_voiceIcon", - , - )); + + if (SettingsStore.getValue(UIFeature.Voip)) { + tabs.push(new Tab( + USER_VOICE_TAB, + _td("Voice & Video"), + "mx_UserSettingsDialog_voiceIcon", + , + )); + } + tabs.push(new Tab( USER_SECURITY_TAB, _td("Security & Privacy"), diff --git a/src/components/views/dialogs/secretstorage/AccessSecretStorageDialog.js b/src/components/views/dialogs/security/AccessSecretStorageDialog.js similarity index 97% rename from src/components/views/dialogs/secretstorage/AccessSecretStorageDialog.js rename to src/components/views/dialogs/security/AccessSecretStorageDialog.js index 5c01a6907f..21655e7fd4 100644 --- a/src/components/views/dialogs/secretstorage/AccessSecretStorageDialog.js +++ b/src/components/views/dialogs/security/AccessSecretStorageDialog.js @@ -15,7 +15,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { debounce } from 'lodash'; +import {debounce} from "lodash"; import classNames from 'classnames'; import React from 'react'; import PropTypes from "prop-types"; @@ -289,7 +289,12 @@ export default class AccessSecretStorageDialog extends React.PureComponent { content =

    {_t("Use your Security Key to continue.")}

    -
    +
    diff --git a/src/components/views/dialogs/ConfirmDestroyCrossSigningDialog.js b/src/components/views/dialogs/security/ConfirmDestroyCrossSigningDialog.js similarity index 96% rename from src/components/views/dialogs/ConfirmDestroyCrossSigningDialog.js rename to src/components/views/dialogs/security/ConfirmDestroyCrossSigningDialog.js index 9e1980e98d..abc1586205 100644 --- a/src/components/views/dialogs/ConfirmDestroyCrossSigningDialog.js +++ b/src/components/views/dialogs/security/ConfirmDestroyCrossSigningDialog.js @@ -16,8 +16,8 @@ limitations under the License. import React from 'react'; import PropTypes from 'prop-types'; -import {_t} from "../../../languageHandler"; -import * as sdk from "../../../index"; +import {_t} from "../../../../languageHandler"; +import * as sdk from "../../../../index"; export default class ConfirmDestroyCrossSigningDialog extends React.Component { static propTypes = { diff --git a/src/components/views/dialogs/security/CreateCrossSigningDialog.js b/src/components/views/dialogs/security/CreateCrossSigningDialog.js new file mode 100644 index 0000000000..226419e759 --- /dev/null +++ b/src/components/views/dialogs/security/CreateCrossSigningDialog.js @@ -0,0 +1,187 @@ +/* +Copyright 2018, 2019 New Vector Ltd +Copyright 2019, 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { MatrixClientPeg } from '../../../../MatrixClientPeg'; +import { _t } from '../../../../languageHandler'; +import Modal from '../../../../Modal'; +import { SSOAuthEntry } from '../../auth/InteractiveAuthEntryComponents'; +import DialogButtons from '../../elements/DialogButtons'; +import BaseDialog from '../BaseDialog'; +import Spinner from '../../elements/Spinner'; +import InteractiveAuthDialog from '../InteractiveAuthDialog'; + +/* + * 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. + */ +export default class CreateCrossSigningDialog extends React.PureComponent { + static propTypes = { + accountPassword: PropTypes.string, + }; + + constructor(props) { + super(props); + + this.state = { + error: null, + // Does the server offer a UI auth flow with just m.login.password + // for /keys/device_signing/upload? + canUploadKeysWithPasswordOnly: null, + accountPassword: props.accountPassword || "", + }; + + if (this.state.accountPassword) { + // If we have an account password in memory, let's simplify and + // assume it means password auth is also supported for device + // signing key upload as well. This avoids hitting the server to + // test auth flows, which may be slow under high load. + this.state.canUploadKeysWithPasswordOnly = true; + } else { + this._queryKeyUploadAuth(); + } + } + + componentDidMount() { + this._bootstrapCrossSigning(); + } + + async _queryKeyUploadAuth() { + try { + await MatrixClientPeg.get().uploadDeviceSigningKeys(null, {}); + // We should never get here: the server should always require + // UI auth to upload device signing keys. If we do, we upload + // no keys which would be a no-op. + console.log("uploadDeviceSigningKeys unexpectedly succeeded without UI auth!"); + } catch (error) { + if (!error.data || !error.data.flows) { + console.log("uploadDeviceSigningKeys advertised no flows!"); + return; + } + const canUploadKeysWithPasswordOnly = error.data.flows.some(f => { + return f.stages.length === 1 && f.stages[0] === 'm.login.password'; + }); + this.setState({ + canUploadKeysWithPasswordOnly, + }); + } + } + + _doBootstrapUIAuth = async (makeRequest) => { + if (this.state.canUploadKeysWithPasswordOnly && this.state.accountPassword) { + await makeRequest({ + type: 'm.login.password', + identifier: { + type: 'm.id.user', + user: MatrixClientPeg.get().getUserId(), + }, + // TODO: Remove `user` once servers support proper UIA + // See https://github.com/matrix-org/synapse/issues/5665 + user: MatrixClientPeg.get().getUserId(), + password: this.state.accountPassword, + }); + } else { + const dialogAesthetics = { + [SSOAuthEntry.PHASE_PREAUTH]: { + title: _t("Use Single Sign On to continue"), + body: _t("To continue, use Single Sign On to prove your identity."), + continueText: _t("Single Sign On"), + continueKind: "primary", + }, + [SSOAuthEntry.PHASE_POSTAUTH]: { + title: _t("Confirm encryption setup"), + body: _t("Click the button below to confirm setting up encryption."), + continueText: _t("Confirm"), + continueKind: "primary", + }, + }; + + const { finished } = Modal.createTrackedDialog( + 'Cross-signing keys dialog', '', InteractiveAuthDialog, + { + title: _t("Setting up keys"), + matrixClient: MatrixClientPeg.get(), + makeRequest, + aestheticsForStagePhases: { + [SSOAuthEntry.LOGIN_TYPE]: dialogAesthetics, + [SSOAuthEntry.UNSTABLE_LOGIN_TYPE]: dialogAesthetics, + }, + }, + ); + const [confirmed] = await finished; + if (!confirmed) { + throw new Error("Cross-signing key upload auth canceled"); + } + } + } + + _bootstrapCrossSigning = async () => { + this.setState({ + error: null, + }); + + const cli = MatrixClientPeg.get(); + + try { + await cli.bootstrapCrossSigning({ + authUploadDeviceSigningKeys: this._doBootstrapUIAuth, + }); + this.props.onFinished(true); + } catch (e) { + this.setState({ error: e }); + console.error("Error bootstrapping cross-signing", e); + } + } + + _onCancel = () => { + this.props.onFinished(false); + } + + render() { + let content; + if (this.state.error) { + content =
    +

    {_t("Unable to set up keys")}

    +
    + +
    +
    ; + } else { + content =
    + +
    ; + } + + return ( + +
    + {content} +
    +
    + ); + } +} diff --git a/src/components/views/dialogs/keybackup/RestoreKeyBackupDialog.js b/src/components/views/dialogs/security/RestoreKeyBackupDialog.js similarity index 99% rename from src/components/views/dialogs/keybackup/RestoreKeyBackupDialog.js rename to src/components/views/dialogs/security/RestoreKeyBackupDialog.js index dd34dfbbf0..2362133460 100644 --- a/src/components/views/dialogs/keybackup/RestoreKeyBackupDialog.js +++ b/src/components/views/dialogs/security/RestoreKeyBackupDialog.js @@ -21,7 +21,7 @@ import * as sdk from '../../../../index'; import {MatrixClientPeg} from '../../../../MatrixClientPeg'; import { MatrixClient } from 'matrix-js-sdk'; import { _t } from '../../../../languageHandler'; -import { accessSecretStorage } from '../../../../CrossSigningManager'; +import { accessSecretStorage } from '../../../../SecurityManager'; const RESTORE_TYPE_PASSPHRASE = 0; const RESTORE_TYPE_RECOVERYKEY = 1; diff --git a/src/components/views/dialogs/SetupEncryptionDialog.js b/src/components/views/dialogs/security/SetupEncryptionDialog.js similarity index 80% rename from src/components/views/dialogs/SetupEncryptionDialog.js rename to src/components/views/dialogs/security/SetupEncryptionDialog.js index d7723de588..9ce3144534 100644 --- a/src/components/views/dialogs/SetupEncryptionDialog.js +++ b/src/components/views/dialogs/security/SetupEncryptionDialog.js @@ -16,16 +16,16 @@ limitations under the License. import React from 'react'; import PropTypes from 'prop-types'; -import SetupEncryptionBody from '../../structures/auth/SetupEncryptionBody'; -import BaseDialog from './BaseDialog'; -import { _t } from '../../../languageHandler'; -import { SetupEncryptionStore, PHASE_DONE } from '../../../stores/SetupEncryptionStore'; +import SetupEncryptionBody from '../../../structures/auth/SetupEncryptionBody'; +import BaseDialog from '../BaseDialog'; +import { _t } from '../../../../languageHandler'; +import { SetupEncryptionStore, PHASE_DONE } from '../../../../stores/SetupEncryptionStore'; function iconFromPhase(phase) { if (phase === PHASE_DONE) { - return require("../../../../res/img/e2e/verified.svg"); + return require("../../../../../res/img/e2e/verified.svg"); } else { - return require("../../../../res/img/e2e/warning.svg"); + return require("../../../../../res/img/e2e/warning.svg"); } } diff --git a/src/components/views/elements/AccessibleTooltipButton.tsx b/src/components/views/elements/AccessibleTooltipButton.tsx index 0388c565ad..29e79dc396 100644 --- a/src/components/views/elements/AccessibleTooltipButton.tsx +++ b/src/components/views/elements/AccessibleTooltipButton.tsx @@ -62,7 +62,8 @@ export default class AccessibleTooltipButton extends React.PureComponent { ev.stopPropagation(); Analytics.trackEvent('Action Button', 'click', this.props.action); dis.dispatch({action: this.props.action}); - }, + }; - _onMouseEnter: function() { + _onMouseEnter = () => { if (this.props.tooltip) this.setState({showTooltip: true}); if (this.props.mouseOverAction) { dis.dispatch({action: this.props.mouseOverAction}); } - }, + }; - _onMouseLeave: function() { + _onMouseLeave = () => { this.setState({showTooltip: false}); - }, + }; - render: function() { + render() { const TintableSvg = sdk.getComponent("elements.TintableSvg"); let tooltip; @@ -94,5 +87,5 @@ export default createReactClass({ { tooltip } ); - }, -}); + } +} diff --git a/src/components/views/elements/AddressSelector.js b/src/components/views/elements/AddressSelector.js index ab29723a45..45cdbeced8 100644 --- a/src/components/views/elements/AddressSelector.js +++ b/src/components/views/elements/AddressSelector.js @@ -17,15 +17,12 @@ limitations under the License. import React from 'react'; import PropTypes from 'prop-types'; -import createReactClass from 'create-react-class'; import * as sdk from '../../../index'; import classNames from 'classnames'; import { UserAddressType } from '../../../UserAddress'; -export default createReactClass({ - displayName: 'AddressSelector', - - propTypes: { +export default class AddressSelector extends React.Component { + static propTypes = { onSelected: PropTypes.func.isRequired, // List of the addresses to display @@ -37,90 +34,91 @@ export default createReactClass({ // Element to put as a header on top of the list header: PropTypes.node, - }, + }; - getInitialState: function() { - return { + constructor(props) { + super(props); + + this.state = { selected: this.props.selected === undefined ? 0 : this.props.selected, hover: false, }; - }, + } // TODO: [REACT-WARNING] Replace with appropriate lifecycle event - UNSAFE_componentWillReceiveProps: function(props) { + UNSAFE_componentWillReceiveProps(props) { // Make sure the selected item isn't outside the list bounds const selected = this.state.selected; const maxSelected = this._maxSelected(props.addressList); if (selected > maxSelected) { this.setState({ selected: maxSelected }); } - }, + } - componentDidUpdate: function() { + componentDidUpdate() { // As the user scrolls with the arrow keys keep the selected item // at the top of the window. if (this.scrollElement && this.props.addressList.length > 0 && !this.state.hover) { const elementHeight = this.addressListElement.getBoundingClientRect().height; this.scrollElement.scrollTop = (this.state.selected * elementHeight) - elementHeight; } - }, + } - moveSelectionTop: function() { + moveSelectionTop = () => { if (this.state.selected > 0) { this.setState({ selected: 0, hover: false, }); } - }, + }; - moveSelectionUp: function() { + moveSelectionUp = () => { if (this.state.selected > 0) { this.setState({ selected: this.state.selected - 1, hover: false, }); } - }, + }; - moveSelectionDown: function() { + moveSelectionDown = () => { if (this.state.selected < this._maxSelected(this.props.addressList)) { this.setState({ selected: this.state.selected + 1, hover: false, }); } - }, + }; - chooseSelection: function() { + chooseSelection = () => { this.selectAddress(this.state.selected); - }, + }; - onClick: function(index) { + onClick = index => { this.selectAddress(index); - }, + }; - onMouseEnter: function(index) { + onMouseEnter = index => { this.setState({ selected: index, hover: true, }); - }, + }; - onMouseLeave: function() { + onMouseLeave = () => { this.setState({ hover: false }); - }, + }; - selectAddress: function(index) { + selectAddress = index => { // Only try to select an address if one exists if (this.props.addressList.length !== 0) { this.props.onSelected(index); this.setState({ hover: false }); } - }, + }; - createAddressListTiles: function() { - const self = this; + createAddressListTiles() { const AddressTile = sdk.getComponent("elements.AddressTile"); const maxSelected = this._maxSelected(this.props.addressList); const addressList = []; @@ -157,15 +155,15 @@ export default createReactClass({ } } return addressList; - }, + } - _maxSelected: function(list) { + _maxSelected(list) { const listSize = list.length === 0 ? 0 : list.length - 1; const maxSelected = listSize > (this.props.truncateAt - 1) ? (this.props.truncateAt - 1) : listSize; return maxSelected; - }, + } - render: function() { + render() { const classes = classNames({ "mx_AddressSelector": true, "mx_AddressSelector_empty": this.props.addressList.length === 0, @@ -177,5 +175,5 @@ export default createReactClass({ { this.createAddressListTiles() }
    ); - }, -}); + } +} diff --git a/src/components/views/elements/AddressTile.js b/src/components/views/elements/AddressTile.js index e5ea2e5d20..dc6c6b2914 100644 --- a/src/components/views/elements/AddressTile.js +++ b/src/components/views/elements/AddressTile.js @@ -17,7 +17,6 @@ limitations under the License. import React from 'react'; import PropTypes from 'prop-types'; -import createReactClass from 'create-react-class'; import classNames from 'classnames'; import * as sdk from "../../../index"; import {MatrixClientPeg} from "../../../MatrixClientPeg"; @@ -25,25 +24,21 @@ import { _t } from '../../../languageHandler'; import { UserAddressType } from '../../../UserAddress.js'; -export default createReactClass({ - displayName: 'AddressTile', - - propTypes: { +export default class AddressTile extends React.Component { + static propTypes = { address: UserAddressType.isRequired, canDismiss: PropTypes.bool, onDismissed: PropTypes.func, justified: PropTypes.bool, - }, + }; - getDefaultProps: function() { - return { - canDismiss: false, - onDismissed: function() {}, // NOP - justified: false, - }; - }, + static defaultProps = { + canDismiss: false, + onDismissed: function() {}, // NOP + justified: false, + }; - render: function() { + render() { const address = this.props.address; const name = address.displayName || address.address; @@ -144,5 +139,5 @@ export default createReactClass({ { dismiss }
    ); - }, -}); + } +} diff --git a/src/components/views/elements/AppTile.js b/src/components/views/elements/AppTile.js index a52dea3e0a..c732d8ec95 100644 --- a/src/components/views/elements/AppTile.js +++ b/src/components/views/elements/AppTile.js @@ -18,11 +18,9 @@ limitations under the License. */ import url from 'url'; -import qs from 'qs'; import React, {createRef} from 'react'; import PropTypes from 'prop-types'; import {MatrixClientPeg} from '../../../MatrixClientPeg'; -import WidgetMessaging from '../../../WidgetMessaging'; import AccessibleButton from './AccessibleButton'; import Modal from '../../../Modal'; import { _t } from '../../../languageHandler'; @@ -34,35 +32,16 @@ import WidgetUtils from '../../../utils/WidgetUtils'; import dis from '../../../dispatcher/dispatcher'; import ActiveWidgetStore from '../../../stores/ActiveWidgetStore'; import classNames from 'classnames'; -import {IntegrationManagers} from "../../../integrations/IntegrationManagers"; import SettingsStore from "../../../settings/SettingsStore"; import {aboveLeftOf, ContextMenu, ContextMenuButton} from "../../structures/ContextMenu"; import PersistedElement from "./PersistedElement"; import {WidgetType} from "../../../widgets/WidgetType"; -import {Capability} from "../../../widgets/WidgetApi"; -import {sleep} from "../../../utils/promise"; import {SettingLevel} from "../../../settings/SettingLevel"; - -const ALLOWED_APP_URL_SCHEMES = ['https:', 'http:']; -const ENABLE_REACT_PERF = false; - -/** - * Does template substitution on a URL (or any string). Variables will be - * passed through encodeURIComponent. - * @param {string} uriTemplate The path with template variables e.g. '/foo/$bar'. - * @param {Object} variables The key/value pairs to replace the template - * variables with. E.g. { '$bar': 'baz' }. - * @return {string} The result of replacing all template variables e.g. '/foo/baz'. - */ -function uriFromTemplate(uriTemplate, variables) { - let out = uriTemplate; - for (const [key, val] of Object.entries(variables)) { - out = out.replace( - '$' + key, encodeURIComponent(val), - ); - } - return out; -} +import WidgetStore from "../../../stores/WidgetStore"; +import {Action} from "../../../dispatcher/actions"; +import {StopGapWidget} from "../../../stores/widgets/StopGapWidget"; +import {ElementWidgetActions} from "../../../stores/widgets/ElementWidgetActions"; +import {MatrixCapabilities} from "matrix-widget-api"; export default class AppTile extends React.Component { constructor(props) { @@ -70,11 +49,14 @@ export default class AppTile extends React.Component { // The key used for PersistedElement this._persistKey = 'widget_' + this.props.app.id; + this._sgWidget = new StopGapWidget(this.props); + this._sgWidget.on("preparing", this._onWidgetPrepared); + this._sgWidget.on("ready", this._onWidgetReady); + this.iframe = null; // ref to the iframe (callback style) this.state = this._getNewState(props); this._onAction = this._onAction.bind(this); - this._onLoaded = this._onLoaded.bind(this); this._onEditClick = this._onEditClick.bind(this); this._onDeleteClick = this._onDeleteClick.bind(this); this._onRevokeClicked = this._onRevokeClicked.bind(this); @@ -87,7 +69,6 @@ export default class AppTile extends React.Component { this._onReloadWidgetClick = this._onReloadWidgetClick.bind(this); this._contextMenuButton = createRef(); - this._appFrame = createRef(); this._menu_bar = createRef(); } @@ -100,16 +81,16 @@ export default class AppTile extends React.Component { _getNewState(newProps) { // This is a function to make the impact of calling SettingsStore slightly less const hasPermissionToLoad = () => { + if (this._usingLocalWidget()) return true; + const currentlyAllowedWidgets = SettingsStore.getValue("allowedWidgets", newProps.room.roomId); return !!currentlyAllowedWidgets[newProps.app.eventId]; }; - const PersistedElement = sdk.getComponent("elements.PersistedElement"); return { initialising: true, // True while we are mangling the widget URL // True while the iframe content is loading loading: this.props.waitForIframeLoad && !PersistedElement.isMounted(this._persistKey), - widgetUrl: this._addWurlParams(newProps.app.url), // Assume that widget has permission to load if we are the user who // added it to the room, or if explicitly granted by the user hasPermissionToLoad: newProps.userId === newProps.creatorUserId || hasPermissionToLoad(), @@ -120,43 +101,6 @@ export default class AppTile extends React.Component { }; } - /** - * Does the widget support a given capability - * @param {string} capability Capability to check for - * @return {Boolean} True if capability supported - */ - _hasCapability(capability) { - return ActiveWidgetStore.widgetHasCapability(this.props.app.id, capability); - } - - /** - * Add widget instance specific parameters to pass in wUrl - * Properties passed to widget instance: - * - widgetId - * - origin / parent URL - * @param {string} urlString Url string to modify - * @return {string} - * Url string with parameters appended. - * If url can not be parsed, it is returned unmodified. - */ - _addWurlParams(urlString) { - try { - const parsed = new URL(urlString); - - // TODO: Replace these with proper widget params - // See https://github.com/matrix-org/matrix-doc/pull/1958/files#r405714833 - parsed.searchParams.set('widgetId', this.props.app.id); - parsed.searchParams.set('parentUrl', window.location.href.split('#', 2)[0]); - - // Replace the encoded dollar signs back to dollar signs. They have no special meaning - // in HTTP, but URL parsers encode them anyways. - return parsed.toString().replace(/%24/g, '$'); - } catch (e) { - console.error("Failed to add widget URL params:", e); - return urlString; - } - } - isMixedContent() { const parentContentProtocol = window.location.protocol; const u = url.parse(this.props.app.url); @@ -172,7 +116,7 @@ export default class AppTile extends React.Component { componentDidMount() { // Only fetch IM token on mount if we're showing and have permission to load if (this.props.show && this.state.hasPermissionToLoad) { - this.setScalarToken(); + this._startWidget(); } // Widget action listeners @@ -186,93 +130,45 @@ export default class AppTile extends React.Component { // if it's not remaining on screen, get rid of the PersistedElement container if (!ActiveWidgetStore.getWidgetPersistence(this.props.app.id)) { ActiveWidgetStore.destroyPersistentWidget(this.props.app.id); - const PersistedElement = sdk.getComponent("elements.PersistedElement"); PersistedElement.destroyElement(this._persistKey); } + + if (this._sgWidget) { + this._sgWidget.stop(); + } } - // TODO: Generify the name of this function. It's not just scalar tokens. - /** - * Adds a scalar token to the widget URL, if required - * Component initialisation is only complete when this function has resolved - */ - setScalarToken() { - if (!WidgetUtils.isScalarUrl(this.props.app.url)) { - console.warn('Widget does not match integration manager, refusing to set auth token', url); - this.setState({ - error: null, - widgetUrl: this._addWurlParams(this.props.app.url), - initialising: false, - }); - return; + _resetWidget(newProps) { + if (this._sgWidget) { + this._sgWidget.stop(); } + this._sgWidget = new StopGapWidget(newProps); + this._sgWidget.on("preparing", this._onWidgetPrepared); + this._sgWidget.on("ready", this._onWidgetReady); + this._startWidget(); + } - const managers = IntegrationManagers.sharedInstance(); - if (!managers.hasManager()) { - console.warn("No integration manager - not setting scalar token", url); - this.setState({ - error: null, - widgetUrl: this._addWurlParams(this.props.app.url), - initialising: false, - }); - return; - } - - // TODO: Pick the right manager for the widget - - const defaultManager = managers.getPrimaryManager(); - if (!WidgetUtils.isScalarUrl(defaultManager.apiUrl)) { - console.warn('Unknown integration manager, refusing to set auth token', url); - this.setState({ - error: null, - widgetUrl: this._addWurlParams(this.props.app.url), - initialising: false, - }); - return; - } - - // Fetch the token before loading the iframe as we need it to mangle the URL - if (!this._scalarClient) { - this._scalarClient = defaultManager.getScalarClient(); - } - this._scalarClient.getScalarToken().then((token) => { - // Append scalar_token as a query param if not already present - this._scalarClient.scalarToken = token; - const u = url.parse(this._addWurlParams(this.props.app.url)); - const params = qs.parse(u.query); - if (!params.scalar_token) { - params.scalar_token = encodeURIComponent(token); - // u.search must be set to undefined, so that u.format() uses query parameters - https://nodejs.org/docs/latest/api/url.html#url_url_format_url_options - u.search = undefined; - u.query = params; - } - - this.setState({ - error: null, - widgetUrl: u.format(), - initialising: false, - }); - - // Fetch page title from remote content if not already set - if (!this.state.widgetPageTitle && params.url) { - this._fetchWidgetTitle(params.url); - } - }, (err) => { - console.error("Failed to get scalar_token", err); - this.setState({ - error: err.message, - initialising: false, - }); + _startWidget() { + this._sgWidget.prepare().then(() => { + this.setState({initialising: false}); }); } + _iframeRefChange = (ref) => { + this.iframe = ref; + if (ref) { + this._sgWidget.start(ref); + } else { + this._resetWidget(this.props); + } + }; + // TODO: [REACT-WARNING] Replace with appropriate lifecycle event UNSAFE_componentWillReceiveProps(nextProps) { // eslint-disable-line camelcase if (nextProps.app.url !== this.props.app.url) { this._getNewState(nextProps); - // Fetch IM token for new URL if we're showing and have permission to load if (this.props.show && this.state.hasPermissionToLoad) { - this.setScalarToken(); + this._resetWidget(nextProps); } } @@ -283,9 +179,9 @@ export default class AppTile extends React.Component { loading: true, }); } - // Fetch IM token now that we're showing if we already have permission to load + // Start the widget now that we're showing if we already have permission to load if (this.state.hasPermissionToLoad) { - this.setScalarToken(); + this._startWidget(); } } @@ -310,35 +206,19 @@ export default class AppTile extends React.Component { if (this.props.onEditClick) { this.props.onEditClick(); } else { - // TODO: Open the right manager for the widget - if (SettingsStore.getValue("feature_many_integration_managers")) { - IntegrationManagers.sharedInstance().openAll( - this.props.room, - 'type_' + this.props.app.type, - this.props.app.id, - ); - } else { - IntegrationManagers.sharedInstance().getPrimaryManager().open( - this.props.room, - 'type_' + this.props.app.type, - this.props.app.id, - ); - } + WidgetUtils.editWidget(this.props.room, this.props.app); } } _onSnapshotClick() { - console.log("Requesting widget snapshot"); - ActiveWidgetStore.getWidgetMessaging(this.props.app.id).getScreenshot() - .catch((err) => { - console.error("Failed to get screenshot", err); - }) - .then((screenshot) => { - dis.dispatch({ - action: 'picture_snapshot', - file: screenshot, - }, true); + this._sgWidget.widgetApi.takeScreenshot().then(data => { + dis.dispatch({ + action: 'picture_snapshot', + file: data.screenshot, }); + }).catch(err => { + console.error("Failed to take screenshot: ", err); + }); } /** @@ -346,35 +226,28 @@ export default class AppTile extends React.Component { * @private * @returns {Promise<*>} Resolves when the widget is terminated, or timeout passed. */ - _endWidgetActions() { - let terminationPromise; - - if (this._hasCapability(Capability.ReceiveTerminate)) { - // Wait for widget to terminate within a timeout - const timeout = 2000; - const messaging = ActiveWidgetStore.getWidgetMessaging(this.props.app.id); - terminationPromise = Promise.race([messaging.terminate(), sleep(timeout)]); - } else { - terminationPromise = Promise.resolve(); + async _endWidgetActions() { // widget migration dev note: async to maintain signature + // HACK: This is a really dirty way to ensure that Jitsi cleans up + // its hold on the webcam. Without this, the widget holds a media + // stream open, even after death. See https://github.com/vector-im/element-web/issues/7351 + if (this.iframe) { + // In practice we could just do `+= ''` to trick the browser + // into thinking the URL changed, however I can foresee this + // being optimized out by a browser. Instead, we'll just point + // the iframe at a page that is reasonably safe to use in the + // event the iframe doesn't wink away. + // This is relative to where the Element instance is located. + this.iframe.src = 'about:blank'; } - return terminationPromise.finally(() => { - // HACK: This is a really dirty way to ensure that Jitsi cleans up - // its hold on the webcam. Without this, the widget holds a media - // stream open, even after death. See https://github.com/vector-im/element-web/issues/7351 - if (this._appFrame.current) { - // In practice we could just do `+= ''` to trick the browser - // into thinking the URL changed, however I can foresee this - // being optimized out by a browser. Instead, we'll just point - // the iframe at a page that is reasonably safe to use in the - // event the iframe doesn't wink away. - // This is relative to where the Element instance is located. - this._appFrame.current.src = 'about:blank'; - } + if (WidgetType.JITSI.matches(this.props.app.type)) { + dis.dispatch({action: 'hangup_conference'}); + } - // Delete the widget from the persisted store for good measure. - PersistedElement.destroyElement(this._persistKey); - }); + // Delete the widget from the persisted store for good measure. + PersistedElement.destroyElement(this._persistKey); + + this._sgWidget.stop({forceDestroy: true}); } /* If user has permission to modify widgets, delete the widget, @@ -419,101 +292,47 @@ export default class AppTile extends React.Component { } } + _onUnpinClicked = () => { + WidgetStore.instance.unpinWidget(this.props.app.id); + } + _onRevokeClicked() { console.info("Revoke widget permissions - %s", this.props.app.id); this._revokeWidgetPermission(); } - /** - * Called when widget iframe has finished loading - */ - _onLoaded() { - // Destroy the old widget messaging before starting it back up again. Some widgets - // have startup routines that run when they are loaded, so we just need to reinitialize - // the messaging for them. - ActiveWidgetStore.delWidgetMessaging(this.props.app.id); - this._setupWidgetMessaging(); - - ActiveWidgetStore.setRoomId(this.props.app.id, this.props.room.roomId); + _onWidgetPrepared = () => { this.setState({loading: false}); - } + }; - _setupWidgetMessaging() { - // FIXME: There's probably no reason to do this here: it should probably be done entirely - // in ActiveWidgetStore. - const widgetMessaging = new WidgetMessaging( - this.props.app.id, - this.props.app.url, - this._getRenderedUrl(), - this.props.userWidget, - this._appFrame.current.contentWindow, - ); - ActiveWidgetStore.setWidgetMessaging(this.props.app.id, widgetMessaging); - widgetMessaging.getCapabilities().then((requestedCapabilities) => { - console.log(`Widget ${this.props.app.id} requested capabilities: ` + requestedCapabilities); - requestedCapabilities = requestedCapabilities || []; - - // Allow whitelisted capabilities - let requestedWhitelistCapabilies = []; - - if (this.props.whitelistCapabilities && this.props.whitelistCapabilities.length > 0) { - requestedWhitelistCapabilies = requestedCapabilities.filter(function(e) { - return this.indexOf(e)>=0; - }, this.props.whitelistCapabilities); - - if (requestedWhitelistCapabilies.length > 0 ) { - console.log(`Widget ${this.props.app.id} allowing requested, whitelisted properties: ` + - requestedWhitelistCapabilies, - ); - } - } - - // TODO -- Add UI to warn about and optionally allow requested capabilities - - ActiveWidgetStore.setWidgetCapabilities(this.props.app.id, requestedWhitelistCapabilies); - - if (this.props.onCapabilityRequest) { - this.props.onCapabilityRequest(requestedCapabilities); - } - - // We only tell Jitsi widgets that we're ready because they're realistically the only ones - // using this custom extension to the widget API. - if (WidgetType.JITSI.matches(this.props.app.type)) { - widgetMessaging.flagReadyToContinue(); - } - }).catch((err) => { - console.log(`Failed to get capabilities for widget type ${this.props.app.type}`, this.props.app.id, err); - }); - } + _onWidgetReady = () => { + if (WidgetType.JITSI.matches(this.props.app.type)) { + this._sgWidget.widgetApi.transport.send(ElementWidgetActions.ClientReady, {}); + } + }; _onAction(payload) { if (payload.widgetId === this.props.app.id) { switch (payload.action) { case 'm.sticker': - if (this._hasCapability('m.sticker')) { - dis.dispatch({action: 'post_sticker_message', data: payload.data}); - } else { - console.warn('Ignoring sticker message. Invalid capability'); - } - break; + if (this._sgWidget.widgetApi.hasCapability(MatrixCapabilities.StickerSending)) { + dis.dispatch({action: 'post_sticker_message', data: payload.data}); + } else { + console.warn('Ignoring sticker message. Invalid capability'); + } + break; + + case Action.AppTileDelete: + this._onDeleteClick(); + break; + + case Action.AppTileRevoke: + this._onRevokeClicked(); + break; } } } - /** - * Set remote content title on AppTile - * @param {string} url Url to check for title - */ - _fetchWidgetTitle(url) { - this._scalarClient.getScalarPageTitle(url).then((widgetPageTitle) => { - if (widgetPageTitle) { - this.setState({widgetPageTitle: widgetPageTitle}); - } - }, (err) =>{ - console.error("Failed to get page title", err); - }); - } - _grantWidgetPermission() { const roomId = this.props.room.roomId; console.info("Granting permission for widget to load: " + this.props.app.eventId); @@ -523,7 +342,7 @@ export default class AppTile extends React.Component { this.setState({hasPermissionToLoad: true}); // Fetch a token for the integration manager, now that we're allowed to - this.setScalarToken(); + this._startWidget(); }).catch(err => { console.error(err); // We don't really need to do anything about this - the user will just hit the button again. @@ -542,6 +361,7 @@ export default class AppTile extends React.Component { ActiveWidgetStore.destroyPersistentWidget(this.props.app.id); const PersistedElement = sdk.getComponent("elements.PersistedElement"); PersistedElement.destroyElement(this._persistKey); + this._sgWidget.stop(); }).catch(err => { console.error(err); // We don't really need to do anything about this - the user will just hit the button again. @@ -571,6 +391,9 @@ export default class AppTile extends React.Component { if (this.props.show) { // if we were being shown, end the widget as we're about to be minimized. this._endWidgetActions(); + } else { + // restart the widget actions + this._resetWidget(this.props); } dis.dispatch({ action: 'appsDrawer', @@ -580,94 +403,19 @@ export default class AppTile extends React.Component { } /** - * Replace the widget template variables in a url with their values - * - * @param {string} u The URL with template variables - * @param {string} widgetType The widget's type - * - * @returns {string} url with temlate variables replaced + * Whether we're using a local version of the widget rather than loading the + * actual widget URL + * @returns {bool} true If using a local version of the widget */ - _templatedUrl(u, widgetType: string) { - const targetData = {}; - if (WidgetType.JITSI.matches(widgetType)) { - targetData['domain'] = 'jitsi.riot.im'; // v1 jitsi widgets have this hardcoded - } - const myUserId = MatrixClientPeg.get().credentials.userId; - const myUser = MatrixClientPeg.get().getUser(myUserId); - const vars = Object.assign(targetData, this.props.app.data, { - 'matrix_user_id': myUserId, - 'matrix_room_id': this.props.room.roomId, - 'matrix_display_name': myUser ? myUser.displayName : myUserId, - 'matrix_avatar_url': myUser ? MatrixClientPeg.get().mxcUrlToHttp(myUser.avatarUrl) : '', - - // TODO: Namespace themes through some standard - 'theme': SettingsStore.getValue("theme"), - }); - - if (vars.conferenceId === undefined) { - // we'll need to parse the conference ID out of the URL for v1 Jitsi widgets - const parsedUrl = new URL(this.props.app.url); - vars.conferenceId = parsedUrl.searchParams.get("confId"); - } - - return uriFromTemplate(u, vars); - } - - /** - * Get the URL used in the iframe - * In cases where we supply our own UI for a widget, this is an internal - * URL different to the one used if the widget is popped out to a separate - * tab / browser - * - * @returns {string} url - */ - _getRenderedUrl() { - let url; - - if (WidgetType.JITSI.matches(this.props.app.type)) { - console.log("Replacing Jitsi widget URL with local wrapper"); - url = WidgetUtils.getLocalJitsiWrapperUrl({forLocalRender: true}); - url = this._addWurlParams(url); - } else { - url = this._getSafeUrl(this.state.widgetUrl); - } - return this._templatedUrl(url, this.props.app.type); - } - - _getPopoutUrl() { - if (WidgetType.JITSI.matches(this.props.app.type)) { - return this._templatedUrl( - WidgetUtils.getLocalJitsiWrapperUrl({forLocalRender: false}), - this.props.app.type, - ); - } else { - // use app.url, not state.widgetUrl, because we want the one without - // the wURL params for the popped-out version. - return this._templatedUrl(this._getSafeUrl(this.props.app.url), this.props.app.type); - } - } - - _getSafeUrl(u) { - const parsedWidgetUrl = url.parse(u, true); - if (ENABLE_REACT_PERF) { - parsedWidgetUrl.search = null; - parsedWidgetUrl.query.react_perf = true; - } - let safeWidgetUrl = ''; - if (ALLOWED_APP_URL_SCHEMES.includes(parsedWidgetUrl.protocol)) { - safeWidgetUrl = url.format(parsedWidgetUrl); - } - - // Replace all the dollar signs back to dollar signs as they don't affect HTTP at all. - // We also need the dollar signs in-tact for variable substitution. - return safeWidgetUrl.replace(/%24/g, '$'); + _usingLocalWidget() { + return WidgetType.JITSI.matches(this.props.app.type); } _getTileTitle() { const name = this.formatAppTileName(); const titleSpacer =  - ; let title = ''; - if (this.state.widgetPageTitle && this.state.widgetPageTitle != this.formatAppTileName()) { + if (this.state.widgetPageTitle && this.state.widgetPageTitle !== this.formatAppTileName()) { title = this.state.widgetPageTitle; } @@ -690,9 +438,9 @@ export default class AppTile extends React.Component { // twice from the same computer, which Jitsi can have problems with (audio echo/gain-loop). if (WidgetType.JITSI.matches(this.props.app.type) && this.props.show) { this._endWidgetActions().then(() => { - if (this._appFrame.current) { + if (this.iframe) { // Reload iframe - this._appFrame.current.src = this._getRenderedUrl(); + this.iframe.src = this._sgWidget.embedUrl; this.setState({}); } }); @@ -700,13 +448,13 @@ export default class AppTile extends React.Component { // Using Object.assign workaround as the following opens in a new window instead of a new tab. // window.open(this._getPopoutUrl(), '_blank', 'noopener=yes'); Object.assign(document.createElement('a'), - { target: '_blank', href: this._getPopoutUrl(), rel: 'noreferrer noopener'}).click(); + { target: '_blank', href: this._sgWidget.popoutUrl, rel: 'noreferrer noopener'}).click(); } _onReloadWidgetClick() { // Reload iframe in this way to avoid cross-origin restrictions // eslint-disable-next-line no-self-assign - this._appFrame.current.src = this._appFrame.current.src; + this.iframe.src = this.iframe.src; } _onContextMenuClick = () => { @@ -735,7 +483,7 @@ 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;"; + const iframeFeatures = "microphone; camera; encrypted-media; autoplay; display-capture;"; const appTileBodyClass = 'mx_AppTileBody' + (this.props.miniMode ? '_mini ' : ' '); @@ -752,7 +500,7 @@ export default class AppTile extends React.Component { @@ -777,11 +525,11 @@ export default class AppTile extends React.Component { { this.state.loading && loadingElement } ; + return