Merge branch 'develop' into foldleft/better-errors

This commit is contained in:
Zoe 2020-03-26 13:38:50 +00:00
commit 9c392ce8bb
193 changed files with 9547 additions and 4493 deletions

View file

@ -1,6 +1,5 @@
# autogenerated file: run scripts/generate-eslint-error-ignore-file to update. # autogenerated file: run scripts/generate-eslint-error-ignore-file to update.
src/component-index.js
src/components/structures/RoomDirectory.js src/components/structures/RoomDirectory.js
src/components/structures/RoomStatusBar.js src/components/structures/RoomStatusBar.js
src/components/structures/RoomView.js src/components/structures/RoomView.js
@ -10,7 +9,6 @@ src/components/structures/UploadBar.js
src/components/views/avatars/BaseAvatar.js src/components/views/avatars/BaseAvatar.js
src/components/views/avatars/MemberAvatar.js src/components/views/avatars/MemberAvatar.js
src/components/views/create_room/RoomAlias.js src/components/views/create_room/RoomAlias.js
src/components/views/dialogs/DeactivateAccountDialog.js
src/components/views/dialogs/SetPasswordDialog.js src/components/views/dialogs/SetPasswordDialog.js
src/components/views/dialogs/UnknownDeviceDialog.js src/components/views/dialogs/UnknownDeviceDialog.js
src/components/views/elements/AddressSelector.js src/components/views/elements/AddressSelector.js
@ -31,7 +29,6 @@ src/components/views/rooms/MemberInfo.js
src/components/views/rooms/MemberList.js src/components/views/rooms/MemberList.js
src/components/views/rooms/RoomList.js src/components/views/rooms/RoomList.js
src/components/views/rooms/RoomPreviewBar.js src/components/views/rooms/RoomPreviewBar.js
src/components/views/rooms/SearchBar.js
src/components/views/rooms/SearchResultTile.js src/components/views/rooms/SearchResultTile.js
src/components/views/settings/ChangeAvatar.js src/components/views/settings/ChangeAvatar.js
src/components/views/settings/ChangePassword.js src/components/views/settings/ChangePassword.js
@ -44,7 +41,6 @@ src/notifications/ContentRules.js
src/notifications/PushRuleVectorState.js src/notifications/PushRuleVectorState.js
src/PlatformPeg.js src/PlatformPeg.js
src/rageshake/rageshake.js src/rageshake/rageshake.js
src/rageshake/submit-rageshake.js
src/ratelimitedfunc.js src/ratelimitedfunc.js
src/Rooms.js src/Rooms.js
src/Unread.js src/Unread.js
@ -60,7 +56,6 @@ test/components/views/dialogs/InteractiveAuthDialog-test.js
test/mock-clock.js test/mock-clock.js
test/notifications/ContentRules-test.js test/notifications/ContentRules-test.js
test/notifications/PushRuleVectorState-test.js test/notifications/PushRuleVectorState-test.js
test/stores/RoomViewStore-test.js
src/component-index.js src/component-index.js
test/end-to-end-tests/node_modules/ test/end-to-end-tests/node_modules/
test/end-to-end-tests/riot/ test/end-to-end-tests/riot/

View file

@ -1,3 +1,254 @@
Changes in [2.2.3](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v2.2.3) (2020-03-17)
===================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v2.2.3-rc.1...v2.2.3)
* Upgrade JS SDK to 5.1.1
* Add default on config setting to control call button in composer
[\#4228](https://github.com/matrix-org/matrix-react-sdk/pull/4228)
* Fix: make alternative addresses UX less confusing
[\#4226](https://github.com/matrix-org/matrix-react-sdk/pull/4226)
* Fix: best-effort to join room without canonical alias over federation from
room directory
[\#4211](https://github.com/matrix-org/matrix-react-sdk/pull/4211)
Changes in [2.2.3-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v2.2.3-rc.1) (2020-03-11)
=============================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v2.2.1...v2.2.3-rc.1)
* Update from Weblate
[\#4200](https://github.com/matrix-org/matrix-react-sdk/pull/4200)
* Revert "enable 4s when accepting a verification request"
[\#4198](https://github.com/matrix-org/matrix-react-sdk/pull/4198)
* Don't remount main split children on rhs collapse
[\#4197](https://github.com/matrix-org/matrix-react-sdk/pull/4197)
* Add fallback label for canonical alias events that dont change anything
[\#4195](https://github.com/matrix-org/matrix-react-sdk/pull/4195)
* Immediately switch to verification dialog when clicking [Continue] from new
session dialog
[\#4196](https://github.com/matrix-org/matrix-react-sdk/pull/4196)
* Enable 4S if needed when trying to verify or accepting verification
[\#4194](https://github.com/matrix-org/matrix-react-sdk/pull/4194)
* Remove extraneous tab stop from room tree view.
[\#4193](https://github.com/matrix-org/matrix-react-sdk/pull/4193)
* Remove v1 identity server fallbacks
[\#4191](https://github.com/matrix-org/matrix-react-sdk/pull/4191)
* Allow editing of alt_aliases according to MSC2432
[\#4187](https://github.com/matrix-org/matrix-react-sdk/pull/4187)
* Update timeline rendering of aliases
[\#4189](https://github.com/matrix-org/matrix-react-sdk/pull/4189)
* Fix mark as read button for dark theme
[\#4190](https://github.com/matrix-org/matrix-react-sdk/pull/4190)
* Un-linkify version in settings
[\#4188](https://github.com/matrix-org/matrix-react-sdk/pull/4188)
* Make Mjolnir stop more robust
[\#4186](https://github.com/matrix-org/matrix-react-sdk/pull/4186)
* Fix secret sharing names to match spec
[\#4185](https://github.com/matrix-org/matrix-react-sdk/pull/4185)
* Share secrets with another device on request
[\#4172](https://github.com/matrix-org/matrix-react-sdk/pull/4172)
* Fall back to to_device verification if other user hasn't uploaded cross-
signing keys
[\#4181](https://github.com/matrix-org/matrix-react-sdk/pull/4181)
* Disable edits on redacted events
[\#4182](https://github.com/matrix-org/matrix-react-sdk/pull/4182)
* Use crypto.verification.request even when xsign is disabled
[\#4180](https://github.com/matrix-org/matrix-react-sdk/pull/4180)
* Reword the status for the currently indexing rooms.
[\#4084](https://github.com/matrix-org/matrix-react-sdk/pull/4084)
* Moved read receipts to the bottom of the message
[\#3892](https://github.com/matrix-org/matrix-react-sdk/pull/3892)
* Include a mark as read X under the scroll to unread button
[\#4159](https://github.com/matrix-org/matrix-react-sdk/pull/4159)
* Show the room presence indicator, even when cross-singing is enabled
[\#4178](https://github.com/matrix-org/matrix-react-sdk/pull/4178)
* Add local echo when clicking "Manually Verify" in unverified session dialog
[\#4179](https://github.com/matrix-org/matrix-react-sdk/pull/4179)
* link to matrix.org/security-disclosure-policy in help screen
[\#4129](https://github.com/matrix-org/matrix-react-sdk/pull/4129)
* only show verify button if user has uploaded cross-signing keys
[\#4174](https://github.com/matrix-org/matrix-react-sdk/pull/4174)
* Fix room alias references in topics
[\#4176](https://github.com/matrix-org/matrix-react-sdk/pull/4176)
* Fix not being able to start chats when you have no rooms
[\#4177](https://github.com/matrix-org/matrix-react-sdk/pull/4177)
* Disable registration flows on SSO servers
[\#4170](https://github.com/matrix-org/matrix-react-sdk/pull/4170)
* Don't group blank membership changes
[\#4160](https://github.com/matrix-org/matrix-react-sdk/pull/4160)
* Ensure the room list always triggers updates on itself
[\#4175](https://github.com/matrix-org/matrix-react-sdk/pull/4175)
* Fix composer touch bar flickering on keypress in Chrome
[\#4173](https://github.com/matrix-org/matrix-react-sdk/pull/4173)
* Document scrollpanel and BACAT scrolling
[\#4167](https://github.com/matrix-org/matrix-react-sdk/pull/4167)
* riot-desktop open SSO in browser so user doesn't have to auth twice
[\#4158](https://github.com/matrix-org/matrix-react-sdk/pull/4158)
* Lock login and registration buttons after submit
[\#4165](https://github.com/matrix-org/matrix-react-sdk/pull/4165)
* Suggest the server's results as lower quality in the invite dialog
[\#4149](https://github.com/matrix-org/matrix-react-sdk/pull/4149)
* Adjust scroll offset with relative scrolling
[\#4166](https://github.com/matrix-org/matrix-react-sdk/pull/4166)
* only automatically download in usercontent if user requested it
[\#4163](https://github.com/matrix-org/matrix-react-sdk/pull/4163)
* Fix having to decrypt & download in two steps
[\#4162](https://github.com/matrix-org/matrix-react-sdk/pull/4162)
* Use bash for release script
[\#4161](https://github.com/matrix-org/matrix-react-sdk/pull/4161)
* Revert to manual sorting for custom tag rooms
[\#4157](https://github.com/matrix-org/matrix-react-sdk/pull/4157)
* Fix the last char of people's names being cut off in the invite dialog
[\#4150](https://github.com/matrix-org/matrix-react-sdk/pull/4150)
* Add /whois SlashCommand to open UserInfo
[\#4154](https://github.com/matrix-org/matrix-react-sdk/pull/4154)
* word-break in pills and wrap the background correctly
[\#4155](https://github.com/matrix-org/matrix-react-sdk/pull/4155)
* don't show "This alias is available to use" if the alias is invalid
[\#4153](https://github.com/matrix-org/matrix-react-sdk/pull/4153)
* Don't ask to enable analytics when Do Not Track is enabled
[\#4098](https://github.com/matrix-org/matrix-react-sdk/pull/4098)
* Fix MELS not breaking on day boundaries regression
[\#4152](https://github.com/matrix-org/matrix-react-sdk/pull/4152)
* Fix Quote on search results page
[\#4151](https://github.com/matrix-org/matrix-react-sdk/pull/4151)
* Ensure errors when creating a DM are raised to the user
[\#4144](https://github.com/matrix-org/matrix-react-sdk/pull/4144)
* Add a Login button to startAnyRegistrationFlow
[\#3829](https://github.com/matrix-org/matrix-react-sdk/pull/3829)
* Use latest backup status directly rather than via state
[\#4147](https://github.com/matrix-org/matrix-react-sdk/pull/4147)
* Prefer account password variation of upgrading
[\#4146](https://github.com/matrix-org/matrix-react-sdk/pull/4146)
* Hide user avatars from screen readers in group and room user lists.
[\#4145](https://github.com/matrix-org/matrix-react-sdk/pull/4145)
* Room List sorting algorithms
[\#4085](https://github.com/matrix-org/matrix-react-sdk/pull/4085)
* Clear selected tags when disabling tag panel
[\#4143](https://github.com/matrix-org/matrix-react-sdk/pull/4143)
* Ignore cursor jumping shortcuts with shift
[\#4142](https://github.com/matrix-org/matrix-react-sdk/pull/4142)
* add local echo for clicking 'start verification' button
[\#4138](https://github.com/matrix-org/matrix-react-sdk/pull/4138)
* Fix formatting buttons not marking the composer as modified
[\#4141](https://github.com/matrix-org/matrix-react-sdk/pull/4141)
* Upgrade deps
[\#4136](https://github.com/matrix-org/matrix-react-sdk/pull/4136)
* Remove debug line from Analytics
[\#4137](https://github.com/matrix-org/matrix-react-sdk/pull/4137)
* Use the right function for creating binary verification QR codes
[\#4140](https://github.com/matrix-org/matrix-react-sdk/pull/4140)
* Ensure verification QR codes use the right buffer size
[\#4139](https://github.com/matrix-org/matrix-react-sdk/pull/4139)
* Don't prefix QR codes with the length of the static marker string
[\#4128](https://github.com/matrix-org/matrix-react-sdk/pull/4128)
* Solve fixed-width digit display in flowed text
[\#4127](https://github.com/matrix-org/matrix-react-sdk/pull/4127)
* Limit UserInfo Displayname to 3 lines to get rid of scrollbars
[\#4135](https://github.com/matrix-org/matrix-react-sdk/pull/4135)
Changes in [2.2.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v2.2.1) (2020-03-04)
===================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v2.2.0...v2.2.1)
* Adjust scroll offset with relative scrolling
[\#4171](https://github.com/matrix-org/matrix-react-sdk/pull/4171)
* Disable registration flows on SSO servers
[\#4169](https://github.com/matrix-org/matrix-react-sdk/pull/4169)
Changes in [2.2.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v2.2.0) (2020-03-02)
===================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v2.2.0-rc.1...v2.2.0)
* Upgrade JS SDK to 5.1.0
* Ignore cursor jumping shortcuts with shift
[\#4142](https://github.com/matrix-org/matrix-react-sdk/pull/4142)
Changes in [2.2.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v2.2.0-rc.1) (2020-02-26)
=============================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v2.1.1...v2.2.0-rc.1)
* Upgrade JS SDK to 5.1.0-rc.1
* Fix message context menu breaking on invalid m.room.pinned_events event
[\#4133](https://github.com/matrix-org/matrix-react-sdk/pull/4133)
* Update from Weblate
[\#4134](https://github.com/matrix-org/matrix-react-sdk/pull/4134)
* Notify platform of language changes
[\#4121](https://github.com/matrix-org/matrix-react-sdk/pull/4121)
* Handle errors when previewing rooms more safely
[\#4132](https://github.com/matrix-org/matrix-react-sdk/pull/4132)
* Don't try to collapse zero events with a group
[\#4131](https://github.com/matrix-org/matrix-react-sdk/pull/4131)
* Don't print errors when the tab is used with no autocomplete present
[\#4130](https://github.com/matrix-org/matrix-react-sdk/pull/4130)
* Improve UI feedback while waiting for network
[\#4126](https://github.com/matrix-org/matrix-react-sdk/pull/4126)
* Ensure DMs tagged outside of account data work in the invite dialog
[\#4123](https://github.com/matrix-org/matrix-react-sdk/pull/4123)
* Show a warning dialog when user indicates a new session wasn't them
[\#4125](https://github.com/matrix-org/matrix-react-sdk/pull/4125)
* Show cancel events as hidden events if we wouldn't usually render them
[\#4120](https://github.com/matrix-org/matrix-react-sdk/pull/4120)
* Collapsed room list has unaligned room tiles #4030 version 2
[\#4033](https://github.com/matrix-org/matrix-react-sdk/pull/4033)
* Check for cross-signing homeserver support
[\#4118](https://github.com/matrix-org/matrix-react-sdk/pull/4118)
* Don't leak if show_sas never comes (or already came)
[\#4119](https://github.com/matrix-org/matrix-react-sdk/pull/4119)
* Add verification request viewer in devtools
[\#4106](https://github.com/matrix-org/matrix-react-sdk/pull/4106)
* update phase when request prop changes
[\#4117](https://github.com/matrix-org/matrix-react-sdk/pull/4117)
* Handle file downloading locally in electron rather than sending to browser
[\#4113](https://github.com/matrix-org/matrix-react-sdk/pull/4113)
* Remove unused CIDER setting watcher
[\#4116](https://github.com/matrix-org/matrix-react-sdk/pull/4116)
* Use alt_aliases for pills and autocomplete
[\#4102](https://github.com/matrix-org/matrix-react-sdk/pull/4102)
* Add shortcuts for beginning / end of composer
[\#4108](https://github.com/matrix-org/matrix-react-sdk/pull/4108)
* Update from Weblate
[\#4115](https://github.com/matrix-org/matrix-react-sdk/pull/4115)
* Revert "Fix escaped markdown passing backslashes through"
[\#4114](https://github.com/matrix-org/matrix-react-sdk/pull/4114)
* Fix a couple of React warnings/errors
[\#4112](https://github.com/matrix-org/matrix-react-sdk/pull/4112)
* Fix two big DOM leaks which were locking Chrome solid.
[\#4111](https://github.com/matrix-org/matrix-react-sdk/pull/4111)
* Filter out empty strings when pasting IDs into the invite dialog
[\#4109](https://github.com/matrix-org/matrix-react-sdk/pull/4109)
* Remove buildkite pipeline
[\#4107](https://github.com/matrix-org/matrix-react-sdk/pull/4107)
* Use binary packing for verification QR codes
[\#4091](https://github.com/matrix-org/matrix-react-sdk/pull/4091)
* Fix several small bugs with the invite/DM dialog
[\#4099](https://github.com/matrix-org/matrix-react-sdk/pull/4099)
* ignore e2e tests node_modules during linting
[\#4103](https://github.com/matrix-org/matrix-react-sdk/pull/4103)
* Apply null-guard to room pills for when we can't fetch the room
[\#4104](https://github.com/matrix-org/matrix-react-sdk/pull/4104)
* Fix theme being overridden to light even after login is completed
[\#4105](https://github.com/matrix-org/matrix-react-sdk/pull/4105)
* Fix bug where SSSS could be overwritten if user never cross-signs
[\#4100](https://github.com/matrix-org/matrix-react-sdk/pull/4100)
* Accept canonical alias for pills
[\#4096](https://github.com/matrix-org/matrix-react-sdk/pull/4096)
* Fix: don't advertise ability to scan a QR code for verification
[\#4094](https://github.com/matrix-org/matrix-react-sdk/pull/4094)
* Fixes for printing event indexing stats.
[\#4082](https://github.com/matrix-org/matrix-react-sdk/pull/4082)
* Remove exec so release script continues
[\#4095](https://github.com/matrix-org/matrix-react-sdk/pull/4095)
* Use Persistent Storage where possible
[\#4092](https://github.com/matrix-org/matrix-react-sdk/pull/4092)
* Fix user page (missing null check)
[\#4088](https://github.com/matrix-org/matrix-react-sdk/pull/4088)
* Cancel verification request on dialog close
[\#4081](https://github.com/matrix-org/matrix-react-sdk/pull/4081)
* Fix various memory leaks due to method re-binding
[\#4093](https://github.com/matrix-org/matrix-react-sdk/pull/4093)
* Fix share message context menu option keyboard a11y
[\#4073](https://github.com/matrix-org/matrix-react-sdk/pull/4073)
Changes in [2.1.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v2.1.1) (2020-02-19) Changes in [2.1.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v2.1.1) (2020-02-19)
=================================================================================================== ===================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v2.1.0...v2.1.1) [Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v2.1.0...v2.1.1)

View file

@ -34,7 +34,7 @@ All code lands on the `develop` branch - `master` is only used for stable releas
**Please file PRs against `develop`!!** **Please file PRs against `develop`!!**
Please follow the standard Matrix contributor's guide: Please follow the standard Matrix contributor's guide:
https://github.com/matrix-org/synapse/tree/master/CONTRIBUTING.rst https://github.com/matrix-org/matrix-js-sdk/blob/develop/CONTRIBUTING.rst
Please follow the Matrix JS/React code style as per: Please follow the Matrix JS/React code style as per:
https://github.com/matrix-org/matrix-react-sdk/blob/master/code_style.md https://github.com/matrix-org/matrix-react-sdk/blob/master/code_style.md

31
docs/jitsi.md Normal file
View file

@ -0,0 +1,31 @@
# Jitsi Wrapper
**Note**: These are developer docs. Please consult your client's documentation for
instructions on setting up Jitsi.
The react-sdk wraps all Jitsi call widgets in a local wrapper called `jitsi.html`
which takes several parameters:
*Query string*:
* `widgetId`: The ID of the widget. This is needed for communication back to the
react-sdk.
* `parentUrl`: The URL of the parent window. This is also needed for
communication back to the react-sdk.
*Hash/fragment (formatted as a query string)*:
* `conferenceDomain`: The domain to connect Jitsi Meet to.
* `conferenceId`: The room or conference ID to connect Jitsi Meet to.
* `isAudioOnly`: Boolean for whether this is a voice-only conference. May not
be present, should default to `false`.
* `displayName`: The display name of the user viewing the widget. May not
be present or could be null.
* `avatarUrl`: The HTTP(S) URL for the avatar of the user viewing the widget. May
not be present or could be null.
* `userId`: The MXID of the user viewing the widget. May not be present or could
be null.
The react-sdk will assume that `jitsi.html` is at the path of wherever it is currently
being served. For example, `https://riot.im/develop/jitsi.html` or `vector://webapp/jitsi.html`.
The `jitsi.html` wrapper can use the react-sdk's `WidgetApi` to communicate, making
it easier to actually implement the feature.

28
docs/scrolling.md Normal file
View file

@ -0,0 +1,28 @@
# ScrollPanel
## Updates
During an onscroll event, we check whether we're getting close to the top or bottom edge of the loaded content. If close enough, we fire a request to load more through the callback passed in the `onFillRequest` prop. This returns a promise is passed down from `TimelinePanel`, where it will call paginate on the `TimelineWindow` and once the events are received back, update its state with the new events. This update trickles down to the `MessagePanel`, which rerenders all tiles and passed that to `ScrollPanel`. ScrollPanels `componentDidUpdate` method gets called, and we do the scroll housekeeping there (read below). Once the rerender has completed, the `setState` callback is called and we resolve the promise returned by `onFillRequest`. Now we check the DOM to see if we need more fill requests.
## Prevent Shrinking
ScrollPanel supports a mode to prevent it shrinking. This is used to prevent a jump when at the bottom of the timeline and people start and stop typing. It gets cleared automatically when 200px above the bottom of the timeline.
## BACAT (Bottom-Aligned, Clipped-At-Top) scrolling
BACAT scrolling implements a different way of restoring the scroll position in the timeline while tiles out of view are changing height or tiles are being added or removed. It was added in https://github.com/matrix-org/matrix-react-sdk/pull/2842.
The motivation for the changes is having noticed that setting scrollTop while scrolling tends to not work well, with it interrupting ongoing scrolling and also querying scrollTop reporting outdated values and consecutive scroll adjustments cancelling each out previous ones. This seems to be worse on macOS than other platforms, presumably because of a higher resolution in scroll events there. Also see https://github.com/vector-im/riot-web/issues/528. The BACAT approach allows to only have to change the scroll offset when adding or removing tiles.
The approach taken instead is to vertically align the timeline tiles to the bottom of the scroll container (using flexbox) and give the timeline inside the scroll container an explicit height, initially set to a multiple of the PAGE_SIZE (400px at time of writing) as needed by the content. When scrolled up, we can compensate for anything that grew below the viewport by changing the height of the timeline to maintain what's currently visible in the viewport without adjusting the scrollTop and hence without jumping.
For anything above the viewport growing or shrinking, we don't need to do anything as the timeline is bottom-aligned. We do need to update the height manually to keep all content visible as more is loaded. To maintain scroll position after the portion above the viewport changes height, we need to set the scrollTop, as we cannot balance it out with more height changes. We do this 100ms after the user has stopped scrolling, so setting scrollTop has not nasty side-effects.
As of https://github.com/matrix-org/matrix-react-sdk/pull/4166, we are scrolling to compensate for height changes by calling `scrollBy(0, x)` rather than reading and than setting `scrollTop`, as reading `scrollTop` can (again, especially on macOS) easily return values that are out of sync with what is on the screen, probably because scrolling can be done [off the main thread](https://wiki.mozilla.org/Platform/GFX/APZ) in some circumstances. This seems to further prevent jumps.
### How does it work?
`componentDidUpdate` is called when a tile in the timeline is updated (as we rerender the whole timeline) or tiles are added or removed (see Updates section before). From here, `checkScroll` is called, which calls `_restoreSavedScrollState`. Now, we increase the timeline height if something below the viewport grew by adjusting `this._bottomGrowth`. `bottomGrowth` is the height added to the timeline (on top of the height from the number of pages calculated at the last `_updateHeight` run) to compensate for growth below the viewport. This is cleared during the next run of `_updateHeight`. Remember that the tiles in the timeline are aligned to the bottom.
From `_restoreSavedScrollState` we also call `_updateHeight` which waits until the user stops scrolling for 100ms and then recalculates the amount of pages of 400px the timeline should be sized to, to be able to show all of its (newly added) content. We have to adjust the scroll offset (which is why we wait until scrolling has stopped) now because the space above the viewport has likely changed.

View file

@ -1,6 +1,6 @@
{ {
"name": "matrix-react-sdk", "name": "matrix-react-sdk",
"version": "2.1.1", "version": "2.2.3",
"description": "SDK for matrix.org using React", "description": "SDK for matrix.org using React",
"author": "matrix.org", "author": "matrix.org",
"repository": { "repository": {
@ -40,15 +40,15 @@
"rethemendex": "res/css/rethemendex.sh", "rethemendex": "res/css/rethemendex.sh",
"clean": "rimraf lib", "clean": "rimraf lib",
"build": "yarn clean && git rev-parse HEAD > git-revision.txt && yarn build:compile && yarn build:types", "build": "yarn clean && git rev-parse HEAD > git-revision.txt && yarn build:compile && yarn build:types",
"build:compile": "yarn reskindex && babel -d lib --verbose --extensions \".ts,.js\" src", "build:compile": "yarn reskindex && babel -d lib --verbose --extensions \".ts,.js,.tsx\" src",
"build:types": "tsc --emitDeclarationOnly", "build:types": "tsc --emitDeclarationOnly --jsx react",
"start": "echo THIS IS FOR LEGACY PURPOSES ONLY. && yarn start:all", "start": "echo THIS IS FOR LEGACY PURPOSES ONLY. && yarn start:all",
"start:all": "concurrently --kill-others-on-fail --prefix \"{time} [{name}]\" -n build,reskindex \"yarn start:build\" \"yarn reskindex:watch\"", "start:all": "concurrently --kill-others-on-fail --prefix \"{time} [{name}]\" -n build,reskindex \"yarn start:build\" \"yarn reskindex:watch\"",
"start:build": "babel src -w -s -d lib --verbose --extensions \".ts,.js\"", "start:build": "babel src -w -s -d lib --verbose --extensions \".ts,.js\"",
"lint": "yarn lint:types && yarn lint:ts && yarn lint:js && yarn lint:style", "lint": "yarn lint:types && yarn lint:ts && yarn lint:js && yarn lint:style",
"lint:js": "eslint --max-warnings 0 --ignore-path .eslintignore.errorfiles src test", "lint:js": "eslint --max-warnings 0 --ignore-path .eslintignore.errorfiles src test",
"lint:ts": "tslint --project ./tsconfig.json -t stylish", "lint:ts": "tslint --project ./tsconfig.json -t stylish",
"lint:types": "tsc --noEmit", "lint:types": "tsc --noEmit --jsx react",
"lint:style": "stylelint 'res/css/**/*.scss'", "lint:style": "stylelint 'res/css/**/*.scss'",
"test": "jest", "test": "jest",
"test:e2e": "./test/end-to-end-tests/run.sh --riot-url http://localhost:8080" "test:e2e": "./test/end-to-end-tests/run.sh --riot-url http://localhost:8080"
@ -72,7 +72,6 @@
"flux": "2.1.1", "flux": "2.1.1",
"focus-visible": "^5.0.2", "focus-visible": "^5.0.2",
"fuse.js": "^2.2.0", "fuse.js": "^2.2.0",
"gemini-scrollbar": "github:matrix-org/gemini-scrollbar#91e1e566",
"gfm.css": "^1.1.1", "gfm.css": "^1.1.1",
"glob-to-regexp": "^0.4.1", "glob-to-regexp": "^0.4.1",
"highlight.js": "^9.15.8", "highlight.js": "^9.15.8",
@ -84,6 +83,7 @@
"minimist": "^1.2.0", "minimist": "^1.2.0",
"pako": "^1.0.5", "pako": "^1.0.5",
"png-chunks-extract": "^1.0.0", "png-chunks-extract": "^1.0.0",
"project-name-generator": "^2.1.7",
"prop-types": "^15.5.8", "prop-types": "^15.5.8",
"qrcode": "^1.4.4", "qrcode": "^1.4.4",
"qrcode-react": "^0.1.16", "qrcode-react": "^0.1.16",
@ -93,7 +93,6 @@
"react-beautiful-dnd": "^4.0.1", "react-beautiful-dnd": "^4.0.1",
"react-dom": "^16.9.0", "react-dom": "^16.9.0",
"react-focus-lock": "^2.2.1", "react-focus-lock": "^2.2.1",
"react-gemini-scrollbar": "github:matrix-org/react-gemini-scrollbar#9cf17f63b7c0b0ec5f31df27da0f82f7238dc594",
"resize-observer-polyfill": "^1.5.0", "resize-observer-polyfill": "^1.5.0",
"sanitize-html": "^1.18.4", "sanitize-html": "^1.18.4",
"text-encoding-utf-8": "^1.0.1", "text-encoding-utf-8": "^1.0.1",
@ -118,6 +117,8 @@
"@babel/preset-typescript": "^7.7.4", "@babel/preset-typescript": "^7.7.4",
"@babel/register": "^7.7.4", "@babel/register": "^7.7.4",
"@peculiar/webcrypto": "^1.0.22", "@peculiar/webcrypto": "^1.0.22",
"@types/classnames": "^2.2.10",
"@types/react": "16.9",
"babel-eslint": "^10.0.3", "babel-eslint": "^10.0.3",
"babel-jest": "^24.9.0", "babel-jest": "^24.9.0",
"chokidar": "^3.3.1", "chokidar": "^3.3.1",

View file

@ -1,4 +1,4 @@
#!/bin/sh #!/bin/bash
# #
# Script to perform a release of matrix-react-sdk. # Script to perform a release of matrix-react-sdk.
# #
@ -11,6 +11,7 @@ cd `dirname $0`
for i in matrix-js-sdk for i in matrix-js-sdk
do do
echo "Checking version of $i..."
depver=`cat package.json | jq -r .dependencies[\"$i\"]` depver=`cat package.json | jq -r .dependencies[\"$i\"]`
latestver=`yarn info -s $i dist-tags.next` latestver=`yarn info -s $i dist-tags.next`
if [ "$depver" != "$latestver" ] if [ "$depver" != "$latestver" ]

View file

@ -42,10 +42,15 @@ pre, code {
font-size: 100% !important; font-size: 100% !important;
} }
.error, .warning { .error, .warning,
.text-error, .text-warning {
color: $warning-color; color: $warning-color;
} }
.text-success {
color: $accent-color;
}
b { b {
// On Firefox, the default weight for `<b>` is `bolder` which results in no bold // On Firefox, the default weight for `<b>` is `bolder` which results in no bold
// effect since we only have specific weights of our fonts available. // effect since we only have specific weights of our fonts available.
@ -202,37 +207,6 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus {
transition: opacity 0.2s ease-in-out; transition: opacity 0.2s ease-in-out;
} }
/* XXX: critical hack to GeminiScrollbar to allow them to work in FF 42 and Chrome 48.
Stop the scrollbar view from pushing out the container's overall sizing, which causes
flexbox to adapt to the new size and cause the view to keep growing.
*/
.gm-scrollbar-container .gm-scroll-view {
position: absolute;
}
/* Expand thumbs on hoverover */
.gm-scrollbar {
border-radius: 5px !important;
}
.gm-scrollbar.-vertical {
width: 6px;
transition: width 120ms ease-out !important;
}
.gm-scrollbar.-vertical:hover,
.gm-scrollbar.-vertical:active {
width: 8px;
transition: width 120ms ease-out !important;
}
.gm-scrollbar.-horizontal {
height: 6px;
transition: height 120ms ease-out !important;
}
.gm-scrollbar.-horizontal:hover,
.gm-scrollbar.-horizontal:active {
height: 8px;
transition: height 120ms ease-out !important;
}
// These are magic constants which are excluded from tinting, to let themes // 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 // (which only have CSS, unlike skins) tell the app what their non-tinted
// colourscheme is by inspecting the stylesheet DOM. // colourscheme is by inspecting the stylesheet DOM.

View file

@ -65,6 +65,7 @@
@import "./views/dialogs/_GroupAddressPicker.scss"; @import "./views/dialogs/_GroupAddressPicker.scss";
@import "./views/dialogs/_IncomingSasDialog.scss"; @import "./views/dialogs/_IncomingSasDialog.scss";
@import "./views/dialogs/_InviteDialog.scss"; @import "./views/dialogs/_InviteDialog.scss";
@import "./views/dialogs/_KeyboardShortcutsDialog.scss";
@import "./views/dialogs/_MessageEditHistoryDialog.scss"; @import "./views/dialogs/_MessageEditHistoryDialog.scss";
@import "./views/dialogs/_NewSessionReviewDialog.scss"; @import "./views/dialogs/_NewSessionReviewDialog.scss";
@import "./views/dialogs/_RoomSettingsDialog.scss"; @import "./views/dialogs/_RoomSettingsDialog.scss";
@ -177,7 +178,6 @@
@import "./views/rooms/_RoomTile.scss"; @import "./views/rooms/_RoomTile.scss";
@import "./views/rooms/_RoomUpgradeWarningBar.scss"; @import "./views/rooms/_RoomUpgradeWarningBar.scss";
@import "./views/rooms/_SearchBar.scss"; @import "./views/rooms/_SearchBar.scss";
@import "./views/rooms/_SearchableEntityList.scss";
@import "./views/rooms/_SendMessageComposer.scss"; @import "./views/rooms/_SendMessageComposer.scss";
@import "./views/rooms/_Stickers.scss"; @import "./views/rooms/_Stickers.scss";
@import "./views/rooms/_TopUnreadMessagesBar.scss"; @import "./views/rooms/_TopUnreadMessagesBar.scss";

View file

@ -180,10 +180,6 @@ limitations under the License.
line-height: 2em; line-height: 2em;
} }
.mx_GroupView > .mx_MainSplit {
flex: 1;
}
.mx_GroupView_body { .mx_GroupView_body {
flex-grow: 1; flex-grow: 1;
} }
@ -341,8 +337,8 @@ limitations under the License.
display: none; display: none;
} }
.mx_GroupView_body .gm-scroll-view > * { .mx_GroupView_body .mx_AutoHideScrollbar_offset > * {
margin: 11px 50px 0px 68px; margin: 11px 50px 50px 68px;
} }
.mx_GroupView_groupDesc textarea { .mx_GroupView_groupDesc textarea {
@ -370,7 +366,7 @@ limitations under the License.
padding: 40px 20px; padding: 40px 20px;
} }
.mx_GroupView .mx_MemberInfo .gm-scroll-view > :not(.mx_MemberInfo_avatar) { .mx_GroupView .mx_MemberInfo .mx_AutoHideScrollbar_offset > :not(.mx_MemberInfo_avatar) {
padding-left: 16px; padding-left: 16px;
padding-right: 16px; padding-right: 16px;
} }

View file

@ -18,6 +18,7 @@ limitations under the License.
display: flex; display: flex;
flex-direction: row; flex-direction: row;
min-width: 0; min-width: 0;
height: 100%;
} }
// move hit area 5px to the right so it doesn't overlap with the timeline scrollbar // move hit area 5px to the right so it doesn't overlap with the timeline scrollbar

View file

@ -76,13 +76,6 @@ limitations under the License.
flex: 1 1 0; flex: 1 1 0;
min-width: 0; min-width: 0;
/* Experimental fix for https://github.com/vector-im/vector-web/issues/947
and https://github.com/vector-im/vector-web/issues/946.
Empirically this stops the MessagePanel's width exploding outwards when
gemini is in 'prevented' mode
*/
overflow-x: auto;
/* To fix https://github.com/vector-im/riot-web/issues/3298 where Safari /* To fix https://github.com/vector-im/riot-web/issues/3298 where Safari
needed height 100% all the way down to the HomePage. Height does not needed height 100% all the way down to the HomePage. Height does not
have to be auto, empirically. have to be auto, empirically.

View file

@ -67,9 +67,6 @@ limitations under the License.
} }
} }
.mx_MyGroups_headerCard_header { .mx_MyGroups_headerCard_header {
font-weight: bold; font-weight: bold;
margin-bottom: 10px; margin-bottom: 10px;
@ -98,6 +95,11 @@ limitations under the License.
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow-y: auto;
}
.mx_MyGroups_scrollable {
overflow-y: inherit;
} }
.mx_MyGroups_placeholder { .mx_MyGroups_placeholder {

View file

@ -1,5 +1,6 @@
/* /*
Copyright 2015, 2016 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -45,9 +46,8 @@ limitations under the License.
} }
.mx_RoomDirectory_listheader { .mx_RoomDirectory_listheader {
display: flex; display: block;
margin-top: 12px; margin-top: 13px;
margin-bottom: 12px;
} }
.mx_RoomDirectory_searchbox { .mx_RoomDirectory_searchbox {
@ -64,7 +64,7 @@ limitations under the License.
} }
.mx_RoomDirectory_table { .mx_RoomDirectory_table {
font-size: 14px; font-size: 12px;
color: $primary-fg-color; color: $primary-fg-color;
width: 100%; width: 100%;
text-align: left; text-align: left;
@ -112,6 +112,7 @@ limitations under the License.
.mx_RoomDirectory_name { .mx_RoomDirectory_name {
display: inline-block; display: inline-block;
font-size: 18px;
font-weight: 600; font-weight: 600;
} }
@ -148,8 +149,8 @@ limitations under the License.
padding: 0; padding: 0;
} }
.mx_RoomDirectory p { .mx_RoomDirectory > span {
font-size: 14px; font-size: 15px;
margin-top: 0; margin-top: 0;
.mx_AccessibleButton { .mx_AccessibleButton {

View file

@ -23,6 +23,7 @@ limitations under the License.
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
min-height: 0;
} }
.mx_TagPanel_items_selected { .mx_TagPanel_items_selected {
@ -57,6 +58,7 @@ limitations under the License.
.mx_TagPanel .mx_TagPanel_scroller { .mx_TagPanel .mx_TagPanel_scroller {
flex-grow: 1; flex-grow: 1;
width: 100%;
} }
.mx_TagPanel .mx_TagPanel_tagTileContainer { .mx_TagPanel .mx_TagPanel_tagTileContainer {

View file

@ -37,6 +37,10 @@ limitations under the License.
font-size: 15px; font-size: 15px;
} }
.mx_CompleteSecurity_waiting {
color: $notice-secondary-color;
}
.mx_CompleteSecurity_actionRow { .mx_CompleteSecurity_actionRow {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;

View file

@ -0,0 +1,65 @@
/*
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_KeyboardShortcutsDialog {
display: flex;
flex-wrap: wrap;
-webkit-box-orient: vertical;
-webkit-box-direction: normal;
flex-direction: column;
margin-bottom: -50px;
max-height: 1100px; // XXX: this may need adjusting when adding new shortcuts
.mx_KeyboardShortcutsDialog_category {
width: 33.3333%; // 3 columns
margin: 0 0 40px;
& > div {
padding-left: 5px;
}
}
h3 {
margin: 0 0 10px;
}
h5 {
margin: 15px 0 5px;
font-weight: normal;
}
kbd {
padding: 5px;
border-radius: 4px;
background-color: $reaction-row-button-bg-color;
margin-right: 5px;
min-width: 20px;
text-align: center;
display: inline-block;
border: 1px solid $kbd-border-color;
box-shadow: 0 2px $kbd-border-color;
margin-bottom: 4px;
text-transform: capitalize;
& + kbd {
margin-left: 5px;
}
}
.mx_KeyboardShortcutsDialog_inline div {
display: inline;
}
}

View file

@ -14,14 +14,6 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
// CSS voodoo to support a gemini-scrollbar for the contents of the dialog
.mx_Dialog_unknownDevice .mx_Dialog {
// ideally we'd shrink the height to fit when needed, but in practice this
// is a pain in the ass. plus might as well make the dialog big given how
// important it is.
height: 100%;
}
.mx_UnknownDeviceDialog { .mx_UnknownDeviceDialog {
height: 100%; height: 100%;
display: flex; display: flex;
@ -44,6 +36,7 @@ limitations under the License.
.mx_UnknownDeviceDialog .mx_Dialog_content { .mx_UnknownDeviceDialog .mx_Dialog_content {
margin-bottom: 24px; margin-bottom: 24px;
overflow-y: scroll;
} }
.mx_UnknownDeviceDialog_deviceList > li { .mx_UnknownDeviceDialog_deviceList > li {

View file

@ -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"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -15,70 +15,149 @@ limitations under the License.
*/ */
.mx_NetworkDropdown { .mx_NetworkDropdown {
height: 32px;
position: relative; position: relative;
} width: max-content;
padding-right: 32px;
margin-left: auto;
margin-right: 9px;
margin-top: 12px;
.mx_NetworkDropdown_input { .mx_AccessibleButton {
position: relative; width: max-content;
border-radius: 3px; }
border: 1px solid $strong-input-border-color;
font-weight: 300;
font-size: 13px;
user-select: none;
}
.mx_NetworkDropdown_arrow {
border-color: $primary-fg-color transparent transparent;
border-style: solid;
border-width: 5px 5px 0;
display: block;
height: 0;
position: absolute;
right: 10px;
top: 16px;
width: 0;
}
.mx_NetworkDropdown_networkoption {
height: 37px;
line-height: 37px;
padding-left: 8px;
padding-right: 8px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.mx_NetworkDropdown_networkoption img {
margin: 5px;
width: 25px;
vertical-align: middle;
}
input.mx_NetworkDropdown_networkoption, input.mx_NetworkDropdown_networkoption:focus {
border: 0;
padding-top: 0;
padding-bottom: 0;
} }
.mx_NetworkDropdown_menu { .mx_NetworkDropdown_menu {
position: absolute; min-width: 204px;
left: -1px;
right: -1px;
top: 100%;
z-index: 2;
margin: 0; margin: 0;
padding: 0px; box-sizing: border-box;
border-radius: 3px; border-radius: 4px;
border: 1px solid $accent-color; border: 1px solid $dialog-close-fg-color;
background-color: $primary-bg-color; background-color: $primary-bg-color;
} }
.mx_NetworkDropdown_menu .mx_NetworkDropdown_networkoption:hover {
background-color: $focus-bg-color;
}
.mx_NetworkDropdown_menu_network { .mx_NetworkDropdown_menu_network {
font-weight: bold; font-weight: bold;
} }
.mx_NetworkDropdown_server {
padding: 12px 0;
border-bottom: 1px solid $input-darker-fg-color;
.mx_NetworkDropdown_server_title {
padding: 0 10px;
font-size: 15px;
font-weight: 600;
line-height: 20px;
margin-bottom: 4px;
// remove server button
.mx_AccessibleButton {
position: absolute;
display: inline;
right: 12px;
height: 16px;
width: 16px;
margin-top: 4px;
&::after {
content: "";
position: absolute;
width: 16px;
height: 16px;
mask-repeat: no-repeat;
mask-position: center;
mask-size: contain;
mask-image: url('$(res)/img/feather-customised/x.svg');
background-color: $notice-primary-color;
}
}
}
.mx_NetworkDropdown_server_subtitle {
padding: 0 10px;
font-size: 10px;
line-height: 14px;
margin-top: -4px;
margin-bottom: 4px;
color: $muted-fg-color;
}
.mx_NetworkDropdown_server_network {
font-size: 12px;
line-height: 16px;
padding: 4px 10px;
cursor: pointer;
position: relative;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
&[aria-checked=true]::after {
content: "";
position: absolute;
width: 16px;
height: 16px;
right: 10px;
mask-repeat: no-repeat;
mask-position: center;
mask-size: contain;
mask-image: url('$(res)/img/feather-customised/check.svg');
background-color: $input-valid-border-color;
}
}
}
.mx_NetworkDropdown_server_add,
.mx_NetworkDropdown_server_network {
&:hover {
background-color: $header-panel-bg-color;
}
}
.mx_NetworkDropdown_server_add {
padding: 16px 10px 16px 32px;
position: relative;
border-radius: 0 0 4px 4px;
&::before {
content: "";
position: absolute;
width: 16px;
height: 16px;
left: 7px;
mask-repeat: no-repeat;
mask-position: center;
mask-size: contain;
mask-image: url('$(res)/img/feather-customised/plus.svg');
background-color: $muted-fg-color;
}
}
.mx_NetworkDropdown_handle {
position: relative;
&::after {
content: "";
position: absolute;
width: 24px;
height: 24px;
right: -28px; // - (24 + 4)
mask-repeat: no-repeat;
mask-position: center;
mask-size: contain;
mask-image: url('$(res)/img/feather-customised/chevron-down.svg');
background-color: $primary-fg-color;
}
.mx_NetworkDropdown_handle_server {
color: $muted-fg-color;
font-size: 12px;
}
}
.mx_NetworkDropdown_dialog .mx_Dialog {
width: 45vw;
}

View file

@ -18,7 +18,6 @@ limitations under the License.
display: flex; display: flex;
padding-left: 9px; padding-left: 9px;
padding-right: 9px; padding-right: 9px;
margin: 0 5px 0 0 !important;
} }
.mx_DirectorySearchBox_joinButton { .mx_DirectorySearchBox_joinButton {

View file

@ -20,14 +20,21 @@ limitations under the License.
} }
.mx_EditableItem { .mx_EditableItem {
display: flex;
margin-bottom: 5px; margin-bottom: 5px;
margin-left: 15px;
} }
.mx_EditableItem_delete { .mx_EditableItem_delete {
order: 3;
margin-right: 5px; margin-right: 5px;
cursor: pointer; cursor: pointer;
vertical-align: middle; vertical-align: middle;
width: 14px;
height: 14px;
mask-image: url('$(res)/img/feather-customised/cancel.svg');
mask-repeat: no-repeat;
background-color: $warning-color;
mask-size: 100%;
} }
.mx_EditableItem_email { .mx_EditableItem_email {
@ -36,12 +43,19 @@ limitations under the License.
.mx_EditableItem_promptText { .mx_EditableItem_promptText {
margin-right: 10px; margin-right: 10px;
order: 2;
} }
.mx_EditableItem_confirmBtn { .mx_EditableItem_confirmBtn {
margin-right: 5px; margin-right: 5px;
} }
.mx_EditableItem_item {
flex: auto 1 0;
order: 1;
}
.mx_EditableItemList_label { .mx_EditableItemList_label {
margin-bottom: 5px; margin-bottom: 5px;
} }

View file

@ -13,6 +13,11 @@
padding-left: 5px; padding-left: 5px;
} }
a.mx_Pill {
word-break: break-all;
display: inline;
}
/* More specific to override `.markdown-body a` text-decoration */ /* More specific to override `.markdown-body a` text-decoration */
.mx_EventTile_content .markdown-body a.mx_Pill { .mx_EventTile_content .markdown-body a.mx_Pill {
text-decoration: none; text-decoration: none;

View file

@ -137,12 +137,19 @@ limitations under the License.
font-size: 18px; font-size: 18px;
line-height: 25px; line-height: 25px;
flex: 1; flex: 1;
overflow-x: auto;
max-height: 50px;
display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
// limit to 2 lines, show an ellipsis if it overflows
// this looks webkit specific but is supported by Firefox 68+
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
word-break: break-all;
text-overflow: ellipsis;
.mx_E2EIcon { .mx_E2EIcon {
margin: 5px; margin: 5px;
} }

View file

@ -26,3 +26,21 @@ limitations under the License.
outline: none; outline: none;
box-shadow: none; box-shadow: none;
} }
.mx_AliasSettings {
summary {
cursor: pointer;
color: $accent-color;
font-weight: 600;
list-style: none;
// list-style doesn't do it for webkit
&::-webkit-details-marker {
display: none;
}
}
.mx_AliasSettings_localAliasHeader {
margin-top: 35px;
}
}

View file

@ -112,8 +112,6 @@ limitations under the License.
.mx_EventTile_line, .mx_EventTile_reply { .mx_EventTile_line, .mx_EventTile_reply {
position: relative; position: relative;
/* ideally should be 100px, but 95px gives us a max thumbnail size of 800x600, which is nice */
margin-right: 110px;
padding-left: 65px; /* left gutter */ padding-left: 65px; /* left gutter */
padding-top: 4px; padding-top: 4px;
padding-bottom: 2px; padding-bottom: 2px;
@ -122,6 +120,13 @@ limitations under the License.
line-height: 22px; line-height: 22px;
} }
.mx_RoomView_timeline_rr_enabled {
.mx_EventTile_line, .mx_EventTile_reply {
/* ideally should be 100px, but 95px gives us a max thumbnail size of 800x600, which is nice */
margin-right: 110px;
}
}
.mx_EventTile_bubbleContainer { .mx_EventTile_bubbleContainer {
display: grid; display: grid;
grid-template-columns: 1fr 100px; grid-template-columns: 1fr 100px;

View file

@ -33,6 +33,13 @@ limitations under the License.
} }
} }
h3,
.mx_RoomPreviewBar_message p {
// break-word, with fallback to break-all, which is wider supported
word-break: break-all;
word-break: break-word;
}
.mx_Spinner { .mx_Spinner {
width: auto; width: auto;
height: auto; height: auto;

View file

@ -1,77 +0,0 @@
/*
Copyright 2016 OpenMarket 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_SearchableEntityList {
display: flex;
flex-direction: column;
}
.mx_SearchableEntityList_query {
font-family: $font-family;
border-radius: 3px;
border: 1px solid $input-border-color;
padding: 9px;
color: $primary-fg-color;
background-color: $primary-bg-color;
margin-left: 3px;
font-size: 15px;
margin-bottom: 8px;
width: 189px;
}
.mx_SearchableEntityList_query::-moz-placeholder {
color: $primary-fg-color;
opacity: 0.5;
font-size: 12px;
}
.mx_SearchableEntityList_query::-webkit-input-placeholder {
color: $primary-fg-color;
opacity: 0.5;
font-size: 12px;
}
.mx_SearchableEntityList_listWrapper {
flex: 1;
overflow-y: auto;
}
.mx_SearchableEntityList_list {
display: table;
table-layout: fixed;
width: 100%;
}
.mx_SearchableEntityList_list .mx_EntityTile_chevron {
display: none;
}
.mx_SearchableEntityList_hrWrapper {
width: 100%;
flex: 0 0 auto;
}
.mx_SearchableEntityList hr {
height: 1px;
border: 0px;
color: $primary-fg-color;
background-color: $primary-fg-color;
margin-right: 15px;
margin-top: 11px;
margin-bottom: 11px;
}

View file

@ -51,8 +51,30 @@ limitations under the License.
position: absolute; position: absolute;
width: 38px; width: 38px;
height: 38px; height: 38px;
mask: url('$(res)/img/icon-jump-to-first-unread.svg'); mask-image: url('$(res)/img/icon-jump-to-first-unread.svg');
mask-repeat: no-repeat; mask-repeat: no-repeat;
mask-position: 9px 13px; mask-position: 9px 13px;
background: $roomtile-name-color; background: $roomtile-name-color;
} }
.mx_TopUnreadMessagesBar_markAsRead {
display: block;
width: 18px;
height: 18px;
background: $primary-bg-color;
border: 1.3px solid $roomtile-name-color;
border-radius: 10px;
margin: 5px auto;
}
.mx_TopUnreadMessagesBar_markAsRead::before {
content: "";
position: absolute;
width: 18px;
height: 18px;
mask-image: url('$(res)/img/cancel.svg');
mask-repeat: no-repeat;
mask-size: 10px;
mask-position: 4px 4px;
background: $roomtile-name-color;
}

View file

@ -45,9 +45,17 @@ limitations under the License.
margin: 10px 100px 10px 0; // Align with the rest of the view margin: 10px 100px 10px 0; // Align with the rest of the view
} }
.mx_SettingsTab_section .mx_SettingsFlag { .mx_SettingsTab_section {
margin-right: 100px; margin-bottom: 24px;
margin-bottom: 10px;
.mx_SettingsFlag {
margin-right: 100px;
margin-bottom: 10px;
}
&.mx_SettingsTab_subsectionText .mx_SettingsFlag {
margin-right: 0px !important;
}
} }
.mx_SettingsTab_section .mx_SettingsFlag .mx_SettingsFlag_label { .mx_SettingsTab_section .mx_SettingsFlag .mx_SettingsFlag_label {

View file

@ -14,6 +14,12 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
.mx_PreferencesUserSettingsTab .mx_Field { .mx_PreferencesUserSettingsTab {
@mixin mx_Settings_fullWidthField; .mx_Field {
@mixin mx_Settings_fullWidthField;
}
.mx_SettingsTab_section {
margin-bottom: 30px;
}
} }

View file

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 9L12 15L18 9" stroke="#2E2F32" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 195 B

View file

@ -165,6 +165,8 @@ $reaction-row-button-hover-border-color: $header-panel-text-primary-color;
$reaction-row-button-selected-bg-color: #1f6954; $reaction-row-button-selected-bg-color: #1f6954;
$reaction-row-button-selected-border-color: $accent-color; $reaction-row-button-selected-border-color: $accent-color;
$kbd-border-color: #000000;
$tooltip-timeline-bg-color: $tagpanel-bg-color; $tooltip-timeline-bg-color: $tagpanel-bg-color;
$tooltip-timeline-fg-color: #ffffff; $tooltip-timeline-fg-color: #ffffff;
@ -219,10 +221,6 @@ $user-tile-hover-bg-color: $header-panel-bg-color;
filter: invert(1); filter: invert(1);
} }
.gm-scrollbar .thumb {
filter: invert(1);
}
// markdown overrides: // markdown overrides:
.mx_EventTile_content .markdown-body pre:hover { .mx_EventTile_content .markdown-body pre:hover {
border-color: #808080 !important; // inverted due to rules below border-color: #808080 !important; // inverted due to rules below

View file

@ -5,9 +5,12 @@
Arial empirically gets it right, hence prioritising Arial here. */ Arial empirically gets it right, hence prioritising Arial here. */
/* We fall through to Twemoji for emoji rather than falling through /* We fall through to Twemoji for emoji rather than falling through
to native Emoji fonts (if any) to ensure cross-browser consistency */ to native Emoji fonts (if any) to ensure cross-browser consistency */
$font-family: Nunito, Twemoji, 'Apple Color Emoji', 'Segoe UI Emoji', 'Noto Color Emoji', Arial, Helvetica, Sans-Serif; /* Noto Color Emoji contains digits, in fixed-width, therefore causing
digits in flowed text to stand out.
TODO: Consider putting all emoji fonts to the end rather than the front. */
$font-family: Nunito, Twemoji, 'Apple Color Emoji', 'Segoe UI Emoji', Arial, Helvetica, Sans-Serif, 'Noto Color Emoji';
$monospace-font-family: Inconsolata, Twemoji, 'Apple Color Emoji', 'Segoe UI Emoji', 'Noto Color Emoji', Courier, monospace; $monospace-font-family: Inconsolata, Twemoji, 'Apple Color Emoji', 'Segoe UI Emoji', Courier, monospace, 'Noto Color Emoji';
// unified palette // unified palette
// try to use these colors when possible // try to use these colors when possible
@ -287,6 +290,8 @@ $reaction-row-button-hover-border-color: $focus-bg-color;
$reaction-row-button-selected-bg-color: #e9fff9; $reaction-row-button-selected-bg-color: #e9fff9;
$reaction-row-button-selected-border-color: $accent-color; $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: $tagpanel-bg-color;
$tooltip-timeline-fg-color: #ffffff; $tooltip-timeline-fg-color: #ffffff;

View file

@ -6,16 +6,8 @@
set -ev set -ev
upload_logs() {
echo "--- Uploading logs"
buildkite-agent artifact upload "logs/**/*;synapse/installations/consent/homeserver.log"
}
handle_error() { handle_error() {
EXIT_CODE=$? EXIT_CODE=$?
if [ $TESTS_STARTED -eq 1 ]; then
upload_logs
fi
exit $EXIT_CODE exit $EXIT_CODE
} }

View file

@ -237,7 +237,7 @@ const walkOpts = {
const fullPath = path.join(root, fileStats.name); const fullPath = path.join(root, fileStats.name);
let trs; let trs;
if (fileStats.name.endsWith('.js')) { if (fileStats.name.endsWith('.js') || fileStats.name.endsWith('.tsx')) {
trs = getTranslationsJs(fullPath); trs = getTranslationsJs(fullPath);
} else if (fileStats.name.endsWith('.html')) { } else if (fileStats.name.endsWith('.html')) {
trs = getTranslationsOther(fullPath); trs = getTranslationsOther(fullPath);

View file

@ -8,11 +8,14 @@ var chokidar = require('chokidar');
var componentIndex = path.join('src', 'component-index.js'); var componentIndex = path.join('src', 'component-index.js');
var componentIndexTmp = componentIndex+".tmp"; var componentIndexTmp = componentIndex+".tmp";
var componentsDir = path.join('src', 'components'); var componentsDir = path.join('src', 'components');
var componentGlob = '**/*.js'; var componentJsGlob = '**/*.js';
var componentTsGlob = '**/*.tsx';
var prevFiles = []; var prevFiles = [];
function reskindex() { function reskindex() {
var files = glob.sync(componentGlob, {cwd: componentsDir}).sort(); var jsFiles = glob.sync(componentJsGlob, {cwd: componentsDir}).sort();
var tsFiles = glob.sync(componentTsGlob, {cwd: componentsDir}).sort();
var files = [...tsFiles, ...jsFiles];
if (!filesHaveChanged(files, prevFiles)) { if (!filesHaveChanged(files, prevFiles)) {
return; return;
} }
@ -36,7 +39,7 @@ function reskindex() {
strm.write("let components = {};\n"); strm.write("let components = {};\n");
for (var i = 0; i < files.length; ++i) { for (var i = 0; i < files.length; ++i) {
var file = files[i].replace('.js', ''); var file = files[i].replace('.js', '').replace('.tsx', '');
var moduleName = (file.replace(/\//g, '.')); var moduleName = (file.replace(/\//g, '.'));
var importName = moduleName.replace(/\./g, "$"); var importName = moduleName.replace(/\./g, "$");
@ -79,7 +82,7 @@ if (!args.w) {
} }
var watchDebouncer = null; var watchDebouncer = null;
chokidar.watch(path.join(componentsDir, componentGlob)).on('all', (event, path) => { chokidar.watch(path.join(componentsDir, componentJsGlob)).on('all', (event, path) => {
if (path === componentIndex) return; if (path === componentIndex) return;
if (watchDebouncer) clearTimeout(watchDebouncer); if (watchDebouncer) clearTimeout(watchDebouncer);
watchDebouncer = setTimeout(reskindex, 1000); watchDebouncer = setTimeout(reskindex, 1000);

View file

@ -260,7 +260,6 @@ class Analytics {
}); });
} catch (e) { } catch (e) {
console.error("Analytics error: ", e); console.error("Analytics error: ", e);
window.err = e;
} }
} }

View file

@ -4,6 +4,7 @@
Copyright 2016 Aviral Dasgupta Copyright 2016 Aviral Dasgupta
Copyright 2016 OpenMarket Ltd Copyright 2016 OpenMarket Ltd
Copyright 2018 New Vector Ltd Copyright 2018 New Vector Ltd
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -18,6 +19,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {MatrixClient} from "matrix-js-sdk";
import dis from './dispatcher'; import dis from './dispatcher';
import BaseEventIndexManager from './indexing/BaseEventIndexManager'; import BaseEventIndexManager from './indexing/BaseEventIndexManager';
@ -162,4 +164,28 @@ export default class BasePlatform {
getEventIndexingManager(): BaseEventIndexManager | null { getEventIndexingManager(): BaseEventIndexManager | null {
return null; return null;
} }
setLanguage(preferredLangs: string[]) {}
getSSOCallbackUrl(hsUrl: string, isUrl: string): URL {
const url = new URL(window.location.href);
// XXX: at this point, the fragment will always be #/login, which is no
// use to anyone. Ideally, we would get the intended fragment from
// MatrixChat.screenAfterLogin so that you could follow #/room links etc
// through an SSO login.
url.hash = "";
url.searchParams.set("homeserver", hsUrl);
url.searchParams.set("identityServer", isUrl);
return url;
}
/**
* Begin Single Sign On flows.
* @param {MatrixClient} mxClient the matrix client using which we should start the flow
* @param {"sso"|"cas"} loginType the type of SSO it is, CAS/SSO.
*/
startSingleSignOn(mxClient: MatrixClient, loginType: "sso"|"cas") {
const callbackUrl = this.getSSOCallbackUrl(mxClient.getHomeserverUrl(), mxClient.getIdentityServerUrl());
window.location.href = mxClient.getSsoLoginUrl(callbackUrl.toString(), loginType); // redirect to SSO
}
} }

View file

@ -64,8 +64,8 @@ import SdkConfig from './SdkConfig';
import { showUnknownDeviceDialogForCalls } from './cryptodevices'; import { showUnknownDeviceDialogForCalls } from './cryptodevices';
import WidgetUtils from './utils/WidgetUtils'; import WidgetUtils from './utils/WidgetUtils';
import WidgetEchoStore from './stores/WidgetEchoStore'; import WidgetEchoStore from './stores/WidgetEchoStore';
import {IntegrationManagers} from "./integrations/IntegrationManagers";
import SettingsStore, { SettingLevel } from './settings/SettingsStore'; import SettingsStore, { SettingLevel } from './settings/SettingsStore';
import {generateHumanReadableId} from "./utils/NamingUtils";
global.mxCalls = { global.mxCalls = {
//room_id: MatrixCall //room_id: MatrixCall
@ -143,7 +143,7 @@ function _setCallListeners(call) {
"if you proceed without verifying them, it will be "+ "if you proceed without verifying them, it will be "+
"possible for someone to eavesdrop on your call.", "possible for someone to eavesdrop on your call.",
), ),
button: _t('Review Devices'), button: _t('Review Sessions'),
onFinished: function(confirmed) { onFinished: function(confirmed) {
if (confirmed) { if (confirmed) {
const room = MatrixClientPeg.get().getRoom(call.roomId); const room = MatrixClientPeg.get().getRoom(call.roomId);
@ -395,32 +395,6 @@ function _onAction(payload) {
} }
async function _startCallApp(roomId, type) { async function _startCallApp(roomId, type) {
// check for a working integration manager. Technically we could put
// the state event in anyway, but the resulting widget would then not
// work for us. Better that the user knows before everyone else in the
// room sees it.
const managers = IntegrationManagers.sharedInstance();
let haveScalar = false;
if (managers.hasManager()) {
try {
const scalarClient = managers.getPrimaryManager().getScalarClient();
await scalarClient.connect();
haveScalar = scalarClient.hasCredentials();
} catch (e) {
// ignore
}
}
if (!haveScalar) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Could not connect to the integration server', '', ErrorDialog, {
title: _t('Could not connect to the integration server'),
description: _t('A conference call could not be started because the integrations server is not available'),
});
return;
}
dis.dispatch({ dis.dispatch({
action: 'appsDrawer', action: 'appsDrawer',
show: true, show: true,
@ -456,31 +430,22 @@ async function _startCallApp(roomId, type) {
return; return;
} }
// This inherits its poor naming from the field of the same name that goes into const confId = `JitsiConference_${generateHumanReadableId()}`;
// the event. It's just a random string to make the Jitsi URLs unique. const jitsiDomain = SdkConfig.get()['jitsi']['preferredDomain'];
const widgetSessionId = Math.random().toString(36).substring(2);
const confId = room.roomId.replace(/[^A-Za-z0-9]/g, '') + widgetSessionId;
// NB. we can't just encodeURICompoent all of these because the $ signs need to be there
// (but currently the only thing that needs encoding is the confId)
const queryString = [
'confId='+encodeURIComponent(confId),
'isAudioConf='+(type === 'voice' ? 'true' : 'false'),
'displayName=$matrix_display_name',
'avatarUrl=$matrix_avatar_url',
'email=$matrix_user_id',
].join('&');
let widgetUrl; let widgetUrl = WidgetUtils.getLocalJitsiWrapperUrl();
if (SdkConfig.get().integrations_jitsi_widget_url) {
// Try this config key. This probably isn't ideal as a way of discovering this
// URL, but this will at least allow the integration manager to not be hardcoded.
widgetUrl = SdkConfig.get().integrations_jitsi_widget_url + '?' + queryString;
} else {
const apiUrl = IntegrationManagers.sharedInstance().getPrimaryManager().apiUrl;
widgetUrl = apiUrl + '/widgets/jitsi.html?' + queryString;
}
const widgetData = { widgetSessionId }; // 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 = ( const widgetId = (
'jitsi_' + 'jitsi_' +

View file

@ -21,6 +21,7 @@ import { deriveKey } from 'matrix-js-sdk/src/crypto/key_passphrase';
import { decodeRecoveryKey } from 'matrix-js-sdk/src/crypto/recoverykey'; import { decodeRecoveryKey } from 'matrix-js-sdk/src/crypto/recoverykey';
import { _t } from './languageHandler'; import { _t } from './languageHandler';
import SettingsStore from './settings/SettingsStore'; import SettingsStore from './settings/SettingsStore';
import {encodeBase64} from "matrix-js-sdk/src/crypto/olmlib";
// This stores the secret storage private keys in memory for the JS SDK. This is // 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 // only meant to act as a cache to avoid prompting the user multiple times
@ -95,6 +96,9 @@ async function getSecretStorageKey({ keys: keyInfos }, ssssItemName) {
{ {
keyInfo: info, keyInfo: info,
checkPrivateKey: async (input) => { checkPrivateKey: async (input) => {
if (!info.pubkey) {
return true;
}
const key = await inputToKey(input); const key = await inputToKey(input);
return MatrixClientPeg.get().checkSecretStoragePrivateKey(key, info.pubkey); return MatrixClientPeg.get().checkSecretStoragePrivateKey(key, info.pubkey);
}, },
@ -125,10 +129,74 @@ async function getSecretStorageKey({ keys: keyInfos }, ssssItemName) {
return [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.startsWith("m.cross_signing")) {
const callbacks = client.getCrossSigningCacheCallbacks();
if (!callbacks.getCrossSigningKeyCache) return;
/* Explicit enumeration here is deliberate never share the master key! */
if (name === "m.cross_signing.self_signing") {
const key = await callbacks.getCrossSigningKeyCache("self_signing");
if (!key) {
console.log(
`self_signing requested by ${deviceId}, but not found in cache`,
);
}
return key && encodeBase64(key);
} else if (name === "m.cross_signing.user_signing") {
const key = await callbacks.getCrossSigningKeyCache("user_signing");
if (!key) {
console.log(
`user_signing 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 = { export const crossSigningCallbacks = {
getSecretStorageKey, 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 * 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 * ensures that secret storage (and also cross-signing since they each depend on
@ -185,6 +253,7 @@ export async function accessSecretStorage(func = async () => { }, force = false)
throw new Error("Cross-signing key upload auth canceled"); throw new Error("Cross-signing key upload auth canceled");
} }
}, },
getBackupPassphrase: promptForBackupPassphrase,
}); });
} }

View file

@ -50,6 +50,7 @@ export default class DeviceListener {
MatrixClientPeg.get().on('crypto.devicesUpdated', this._onDevicesUpdated); MatrixClientPeg.get().on('crypto.devicesUpdated', this._onDevicesUpdated);
MatrixClientPeg.get().on('deviceVerificationChanged', this._onDeviceVerificationChanged); MatrixClientPeg.get().on('deviceVerificationChanged', this._onDeviceVerificationChanged);
MatrixClientPeg.get().on('userTrustStatusChanged', this._onUserTrustStatusChanged); MatrixClientPeg.get().on('userTrustStatusChanged', this._onUserTrustStatusChanged);
MatrixClientPeg.get().on('accountData', this._onAccountData);
this._recheck(); this._recheck();
} }
@ -58,6 +59,7 @@ export default class DeviceListener {
MatrixClientPeg.get().removeListener('crypto.devicesUpdated', this._onDevicesUpdated); MatrixClientPeg.get().removeListener('crypto.devicesUpdated', this._onDevicesUpdated);
MatrixClientPeg.get().removeListener('deviceVerificationChanged', this._onDeviceVerificationChanged); MatrixClientPeg.get().removeListener('deviceVerificationChanged', this._onDeviceVerificationChanged);
MatrixClientPeg.get().removeListener('userTrustStatusChanged', this._onUserTrustStatusChanged); MatrixClientPeg.get().removeListener('userTrustStatusChanged', this._onUserTrustStatusChanged);
MatrixClientPeg.get().removeListener('accountData', this._onAccountData);
} }
this._dismissed.clear(); this._dismissed.clear();
} }
@ -87,6 +89,13 @@ export default class DeviceListener {
this._recheck(); this._recheck();
} }
_onAccountData = (ev) => {
// User may have migrated SSSS to symmetric, in which case we can dismiss that toast
if (ev.getType().startsWith('m.secret_storage.key.')) {
this._recheck();
}
}
// The server doesn't tell us when key backup is set up, so we poll // The server doesn't tell us when key backup is set up, so we poll
// & cache the result // & cache the result
async _getKeyBackupInfo() { async _getKeyBackupInfo() {
@ -99,11 +108,18 @@ export default class DeviceListener {
} }
async _recheck() { async _recheck() {
if (!SettingsStore.isFeatureEnabled("feature_cross_signing")) return;
const cli = MatrixClientPeg.get(); const cli = MatrixClientPeg.get();
if (
!SettingsStore.isFeatureEnabled("feature_cross_signing") ||
!await cli.doesServerSupportUnstableFeature("org.matrix.e2e_cross_signing")
) return;
if (!cli.isCryptoEnabled()) return; if (!cli.isCryptoEnabled()) return;
if (!cli.getCrossSigningId()) {
const crossSigningReady = await cli.isCrossSigningReady();
if (!crossSigningReady) {
if (this._dismissedThisDeviceToast) { if (this._dismissedThisDeviceToast) {
ToastStore.sharedInstance().dismissToast(THIS_DEVICE_TOAST_KEY); ToastStore.sharedInstance().dismissToast(THIS_DEVICE_TOAST_KEY);
return; return;
@ -143,6 +159,19 @@ export default class DeviceListener {
} }
} }
return; return;
} else if (await cli.secretStorageKeyNeedsUpgrade()) {
if (this._dismissedThisDeviceToast) {
ToastStore.sharedInstance().dismissToast(THIS_DEVICE_TOAST_KEY);
return;
}
ToastStore.sharedInstance().addOrReplaceToast({
key: THIS_DEVICE_TOAST_KEY,
title: _t("Encryption upgrade available"),
icon: "verification_warning",
props: {kind: 'upgrade_encryption'},
component: sdk.getComponent("toasts.SetupEncryptionToast"),
});
} else { } else {
ToastStore.sharedInstance().dismissToast(THIS_DEVICE_TOAST_KEY); ToastStore.sharedInstance().dismissToast(THIS_DEVICE_TOAST_KEY);
} }

View file

@ -24,6 +24,8 @@ import {MatrixClientPeg} from "./MatrixClientPeg";
import RoomViewStore from "./stores/RoomViewStore"; import RoomViewStore from "./stores/RoomViewStore";
import {IntegrationManagers} from "./integrations/IntegrationManagers"; import {IntegrationManagers} from "./integrations/IntegrationManagers";
import SettingsStore from "./settings/SettingsStore"; import SettingsStore from "./settings/SettingsStore";
import {Capability, KnownWidgetActions} from "./widgets/WidgetApi";
import SdkConfig from "./SdkConfig";
const WIDGET_API_VERSION = '0.0.2'; // Current API version const WIDGET_API_VERSION = '0.0.2'; // Current API version
const SUPPORTED_WIDGET_API_VERSIONS = [ const SUPPORTED_WIDGET_API_VERSIONS = [
@ -213,11 +215,18 @@ export default class FromWidgetPostMessageApi {
const data = event.data.data; const data = event.data.data;
const val = data.value; const val = data.value;
if (ActiveWidgetStore.widgetHasCapability(widgetId, 'm.always_on_screen')) { if (ActiveWidgetStore.widgetHasCapability(widgetId, Capability.AlwaysOnScreen)) {
ActiveWidgetStore.setWidgetPersistence(widgetId, val); ActiveWidgetStore.setWidgetPersistence(widgetId, val);
} }
} else if (action === 'get_openid') { } else if (action === 'get_openid') {
// Handled by caller // Handled by caller
} else if (action === KnownWidgetActions.GetRiotWebConfig) {
if (ActiveWidgetStore.widgetHasCapability(widgetId, Capability.GetRiotWebConfig)) {
this.sendResponse(event, {
api: INBOUND_API_NAME,
config: SdkConfig.get(),
});
}
} else { } else {
console.warn('Widget postMessage event unhandled'); console.warn('Widget postMessage event unhandled');
this.sendError(event, {message: 'The postMessage was unhandled'}); this.sendError(event, {message: 'The postMessage was unhandled'});

View file

@ -23,7 +23,6 @@ import ReplyThread from "./components/views/elements/ReplyThread";
import React from 'react'; import React from 'react';
import sanitizeHtml from 'sanitize-html'; import sanitizeHtml from 'sanitize-html';
import highlight from 'highlight.js';
import * as linkify from 'linkifyjs'; import * as linkify from 'linkifyjs';
import linkifyMatrix from './linkify-matrix'; import linkifyMatrix from './linkify-matrix';
import _linkifyElement from 'linkifyjs/element'; import _linkifyElement from 'linkifyjs/element';
@ -467,11 +466,12 @@ export function bodyToHtml(content, highlights, opts={}) {
/** /**
* Linkifies the given string. This is a wrapper around 'linkifyjs/string'. * Linkifies the given string. This is a wrapper around 'linkifyjs/string'.
* *
* @param {string} str * @param {string} str string to linkify
* @returns {string} * @param {object} [options] Options for linkifyString. Default: linkifyMatrix.options
* @returns {string} Linkified string
*/ */
export function linkifyString(str) { export function linkifyString(str, options = linkifyMatrix.options) {
return _linkifyString(str); return _linkifyString(str, options);
} }
/** /**
@ -489,10 +489,11 @@ export function linkifyElement(element, options = linkifyMatrix.options) {
* Linkify the given string and sanitize the HTML afterwards. * Linkify the given string and sanitize the HTML afterwards.
* *
* @param {string} dirtyHtml The HTML string to sanitize and linkify * @param {string} dirtyHtml The HTML string to sanitize and linkify
* @param {object} [options] Options for linkifyString. Default: linkifyMatrix.options
* @returns {string} * @returns {string}
*/ */
export function linkifyAndSanitizeHtml(dirtyHtml) { export function linkifyAndSanitizeHtml(dirtyHtml, options = linkifyMatrix.options) {
return sanitizeHtml(linkifyString(dirtyHtml), sanitizeHtmlParams); return sanitizeHtml(linkifyString(dirtyHtml, options), sanitizeHtmlParams);
} }
/** /**

View file

@ -181,24 +181,12 @@ export default class IdentityAuthClient {
} }
async registerForToken(check=true) { async registerForToken(check=true) {
try { const hsOpenIdToken = await MatrixClientPeg.get().getOpenIdToken();
const hsOpenIdToken = await MatrixClientPeg.get().getOpenIdToken(); // XXX: The spec is `token`, but we used `access_token` for a Sydent release.
// XXX: The spec is `token`, but we used `access_token` for a Sydent release. const { access_token: accessToken, token } =
const { access_token: accessToken, token } = await this._matrixClient.registerWithIdentityServer(hsOpenIdToken);
await this._matrixClient.registerWithIdentityServer(hsOpenIdToken); const identityAccessToken = token ? token : accessToken;
const identityAccessToken = token ? token : accessToken; if (check) await this._checkToken(identityAccessToken);
if (check) await this._checkToken(identityAccessToken); return identityAccessToken;
return identityAccessToken;
} catch (e) {
if (e.cors === "rejected" || e.httpStatus === 404) {
// Assume IS only supports deprecated v1 API for now
// TODO: Remove this path once v2 is only supported version
// See https://github.com/vector-im/riot-web/issues/10443
console.warn("IS doesn't support v2 auth");
this.authEnabled = false;
return;
}
throw e;
}
} }
} }

View file

@ -36,10 +36,12 @@ export const Key = {
CONTEXT_MENU: "ContextMenu", CONTEXT_MENU: "ContextMenu",
COMMA: ",", COMMA: ",",
PERIOD: ".",
LESS_THAN: "<", LESS_THAN: "<",
GREATER_THAN: ">", GREATER_THAN: ">",
BACKTICK: "`", BACKTICK: "`",
SPACE: " ", SPACE: " ",
SLASH: "/",
A: "a", A: "a",
B: "b", B: "b",
C: "c", C: "c",
@ -68,8 +70,9 @@ export const Key = {
Z: "z", Z: "z",
}; };
export const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
export function isOnlyCtrlOrCmdKeyEvent(ev) { export function isOnlyCtrlOrCmdKeyEvent(ev) {
const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
if (isMac) { if (isMac) {
return ev.metaKey && !ev.altKey && !ev.ctrlKey && !ev.shiftKey; return ev.metaKey && !ev.altKey && !ev.ctrlKey && !ev.shiftKey;
} else { } else {
@ -78,7 +81,6 @@ export function isOnlyCtrlOrCmdKeyEvent(ev) {
} }
export function isOnlyCtrlOrCmdIgnoreShiftKeyEvent(ev) { export function isOnlyCtrlOrCmdIgnoreShiftKeyEvent(ev) {
const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
if (isMac) { if (isMac) {
return ev.metaKey && !ev.altKey && !ev.ctrlKey; return ev.metaKey && !ev.altKey && !ev.ctrlKey;
} else { } else {

View file

@ -313,7 +313,7 @@ async function _restoreFromLocalStorage(opts) {
} }
} }
function _handleLoadSessionFailure(e) { async function _handleLoadSessionFailure(e) {
console.error("Unable to load session", e); console.error("Unable to load session", e);
const SessionRestoreErrorDialog = const SessionRestoreErrorDialog =
@ -323,16 +323,15 @@ function _handleLoadSessionFailure(e) {
error: e.message, error: e.message,
}); });
return modal.finished.then(([success]) => { const [success] = await modal.finished;
if (success) { if (success) {
// user clicked continue. // user clicked continue.
_clearStorage(); await _clearStorage();
return false; return false;
} }
// try, try again // try, try again
return loadSession(); return loadSession();
});
} }
/** /**

View file

@ -3,6 +3,7 @@ Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd Copyright 2017 Vector Creations Ltd
Copyright 2018 New Vector Ltd Copyright 2018 New Vector Ltd
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -19,8 +20,6 @@ limitations under the License.
import Matrix from "matrix-js-sdk"; import Matrix from "matrix-js-sdk";
import url from 'url';
export default class Login { export default class Login {
constructor(hsUrl, isUrl, fallbackHsUrl, opts) { constructor(hsUrl, isUrl, fallbackHsUrl, opts) {
this._hsUrl = hsUrl; this._hsUrl = hsUrl;
@ -29,6 +28,7 @@ export default class Login {
this._currentFlowIndex = 0; this._currentFlowIndex = 0;
this._flows = []; this._flows = [];
this._defaultDeviceDisplayName = opts.defaultDeviceDisplayName; this._defaultDeviceDisplayName = opts.defaultDeviceDisplayName;
this._tempClient = null; // memoize
} }
getHomeserverUrl() { getHomeserverUrl() {
@ -40,10 +40,12 @@ export default class Login {
} }
setHomeserverUrl(hsUrl) { setHomeserverUrl(hsUrl) {
this._tempClient = null; // clear memoization
this._hsUrl = hsUrl; this._hsUrl = hsUrl;
} }
setIdentityServerUrl(isUrl) { setIdentityServerUrl(isUrl) {
this._tempClient = null; // clear memoization
this._isUrl = isUrl; this._isUrl = isUrl;
} }
@ -52,8 +54,9 @@ export default class Login {
* requests. * requests.
* @returns {MatrixClient} * @returns {MatrixClient}
*/ */
_createTemporaryClient() { createTemporaryClient() {
return Matrix.createClient({ if (this._tempClient) return this._tempClient; // use memoization
return this._tempClient = Matrix.createClient({
baseUrl: this._hsUrl, baseUrl: this._hsUrl,
idBaseUrl: this._isUrl, idBaseUrl: this._isUrl,
}); });
@ -61,7 +64,7 @@ export default class Login {
getFlows() { getFlows() {
const self = this; const self = this;
const client = this._createTemporaryClient(); const client = this.createTemporaryClient();
return client.loginFlows().then(function(result) { return client.loginFlows().then(function(result) {
self._flows = result.flows; self._flows = result.flows;
self._currentFlowIndex = 0; self._currentFlowIndex = 0;
@ -139,21 +142,6 @@ export default class Login {
throw error; throw error;
}); });
} }
getSsoLoginUrl(loginType) {
const client = this._createTemporaryClient();
const parsedUrl = url.parse(window.location.href, true);
// XXX: at this point, the fragment will always be #/login, which is no
// use to anyone. Ideally, we would get the intended fragment from
// MatrixChat.screenAfterLogin so that you could follow #/room links etc
// through an SSO login.
parsedUrl.hash = "";
parsedUrl.query["homeserver"] = client.getHomeserverUrl();
parsedUrl.query["identityServer"] = client.getIdentityServerUrl();
return client.getSsoLoginUrl(url.format(parsedUrl), loginType);
}
} }

View file

@ -39,6 +39,8 @@ export const SAFE_LOCALPART_REGEX = /^[a-z0-9=_\-./]+$/;
* If true, goes to the home page if the user cancels the action * If true, goes to the home page if the user cancels the action
* @param {bool} options.go_welcome_on_cancel * @param {bool} options.go_welcome_on_cancel
* If true, goes to the welcome page if the user cancels the action * If true, goes to the welcome page if the user cancels the action
* @param {bool} options.screen_after
* If present the screen to redirect to after a successful login or register.
*/ */
export async function startAnyRegistrationFlow(options) { export async function startAnyRegistrationFlow(options) {
if (options === undefined) options = {}; if (options === undefined) options = {};
@ -66,13 +68,21 @@ export async function startAnyRegistrationFlow(options) {
// }); // });
//} else { //} else {
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
Modal.createTrackedDialog('Registration required', '', QuestionDialog, { const modal = Modal.createTrackedDialog('Registration required', '', QuestionDialog, {
title: _t("Registration Required"), hasCancelButton: true,
description: _t("You need to register to do this. Would you like to register now?"), quitOnly: true,
button: _t("Register"), title: _t("Sign In or Create Account"),
description: _t("Use your account or create a new one to continue."),
button: _t("Create Account"),
extraButtons: [
<button key="start_login" onClick={() => {
modal.close();
dis.dispatch({action: 'start_login', screenAfterLogin: options.screen_after});
}}>{ _t('Sign In') }</button>,
],
onFinished: (proceed) => { onFinished: (proceed) => {
if (proceed) { if (proceed) {
dis.dispatch({action: 'start_registration'}); dis.dispatch({action: 'start_registration', screenAfterLogin: options.screen_after});
} else if (options.go_home_on_cancel) { } else if (options.go_home_on_cancel) {
dis.dispatch({action: 'view_home_page'}); dis.dispatch({action: 'view_home_page'});
} else if (options.go_welcome_on_cancel) { } else if (options.go_welcome_on_cancel) {
@ -101,4 +111,3 @@ export async function startAnyRegistrationFlow(options) {
// } // }
// throw new Error("Register request succeeded when it should have returned 401!"); // throw new Error("Register request succeeded when it should have returned 401!");
// } // }

View file

@ -26,6 +26,13 @@ export const DEFAULTS: ConfigOptions = {
integrations_rest_url: "https://scalar.vector.im/api", integrations_rest_url: "https://scalar.vector.im/api",
// Where to send bug reports. If not specified, bugs cannot be sent. // Where to send bug reports. If not specified, bugs cannot be sent.
bug_report_endpoint_url: null, bug_report_endpoint_url: null,
// Jitsi conference options
jitsi: {
// Default conference domain
preferredDomain: "jitsi.riot.im",
// Default Jitsi Meet API location
externalApiUrl: "https://jitsi.riot.im/libs/external_api.min.js",
},
}; };
export default class SdkConfig { export default class SdkConfig {

View file

@ -87,6 +87,13 @@ async function localSearch(searchTerm, roomId = undefined) {
searchArgs.room_id = roomId; searchArgs.room_id = roomId;
} }
const emptyResult = {
results: [],
highlights: [],
};
if (searchTerm === "") return emptyResult;
const eventIndex = EventIndexPeg.get(); const eventIndex = EventIndexPeg.get();
const localResult = await eventIndex.search(searchArgs); const localResult = await eventIndex.search(searchArgs);
@ -97,11 +104,6 @@ async function localSearch(searchTerm, roomId = undefined) {
}, },
}; };
const emptyResult = {
results: [],
highlights: [],
};
const result = MatrixClientPeg.get()._processRoomEventsSearch( const result = MatrixClientPeg.get()._processRoomEventsSearch(
emptyResult, response); emptyResult, response);

View file

@ -893,6 +893,26 @@ export const CommandMap = {
}, },
category: CommandCategories.advanced, category: CommandCategories.advanced,
}), }),
whois: new Command({
name: "whois",
description: _td("Displays information about a user"),
args: '<user-id>',
runFn: function(roomId, userId) {
if (!userId || !userId.startsWith("@") || !userId.includes(":")) {
return reject(this.getUsage());
}
const member = MatrixClientPeg.get().getRoom(roomId).getMember(userId);
dis.dispatch({
action: 'view_user',
member: member || {userId},
});
return success();
},
category: CommandCategories.advanced,
}),
}; };
/* eslint-enable babel/no-invalid-this */ /* eslint-enable babel/no-invalid-this */

View file

@ -127,6 +127,13 @@ function textForRoomNameEvent(ev) {
if (!ev.getContent().name || ev.getContent().name.trim().length === 0) { if (!ev.getContent().name || ev.getContent().name.trim().length === 0) {
return _t('%(senderDisplayName)s removed the room name.', {senderDisplayName}); return _t('%(senderDisplayName)s removed the room name.', {senderDisplayName});
} }
if (ev.getPrevContent().name) {
return _t('%(senderDisplayName)s changed the room name from %(oldRoomName)s to %(newRoomName)s.', {
senderDisplayName,
oldRoomName: ev.getPrevContent().name,
newRoomName: ev.getContent().name,
});
}
return _t('%(senderDisplayName)s changed the room name to %(roomName)s.', { return _t('%(senderDisplayName)s changed the room name to %(roomName)s.', {
senderDisplayName, senderDisplayName,
roomName: ev.getContent().name, roomName: ev.getContent().name,
@ -269,85 +276,55 @@ function textForMessageEvent(ev) {
return message; return message;
} }
function textForRoomAliasesEvent(ev) {
// An alternative implementation of this as a first-class event can be found at
// https://github.com/matrix-org/matrix-react-sdk/blob/dc7212ec2bd12e1917233ed7153b3e0ef529a135/src/components/views/messages/RoomAliasesEvent.js
// This feels a bit overkill though, and it's not clear the i18n really needs it
// so instead it's landing as a simple textual event.
const maxShown = 3;
const senderName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
const oldAliases = ev.getPrevContent().aliases || [];
const newAliases = ev.getContent().aliases || [];
const addedAliases = newAliases.filter((x) => !oldAliases.includes(x));
const removedAliases = oldAliases.filter((x) => !newAliases.includes(x));
if (!addedAliases.length && !removedAliases.length) {
return '';
}
if (addedAliases.length && !removedAliases.length) {
if (addedAliases.length > maxShown) {
return _t("%(senderName)s added %(addedAddresses)s and %(count)s other addresses to this room", {
senderName: senderName,
count: addedAliases.length - maxShown,
addedAddresses: addedAliases.slice(0, maxShown).join(', '),
});
}
return _t('%(senderName)s added %(count)s %(addedAddresses)s as addresses for this room.', {
senderName: senderName,
count: addedAliases.length,
addedAddresses: addedAliases.join(', '),
});
} else if (!addedAliases.length && removedAliases.length) {
if (removedAliases.length > maxShown) {
return _t("%(senderName)s removed %(removedAddresses)s and %(count)s other addresses from this room", {
senderName: senderName,
count: removedAliases.length - maxShown,
removedAddresses: removedAliases.slice(0, maxShown).join(', '),
});
}
return _t('%(senderName)s removed %(count)s %(removedAddresses)s as addresses for this room.', {
senderName: senderName,
count: removedAliases.length,
removedAddresses: removedAliases.join(', '),
});
} else {
const combined = addedAliases.length + removedAliases.length;
if (combined > maxShown) {
return _t("%(senderName)s removed %(countRemoved)s and added %(countAdded)s addresses to this room", {
senderName: senderName,
countAdded: addedAliases.length,
countRemoved: removedAliases.length,
});
}
return _t(
'%(senderName)s added %(addedAddresses)s and removed %(removedAddresses)s as addresses for this room.', {
senderName: senderName,
addedAddresses: addedAliases.join(', '),
removedAddresses: removedAliases.join(', '),
},
);
}
}
function textForCanonicalAliasEvent(ev) { function textForCanonicalAliasEvent(ev) {
const senderName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); const senderName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
const oldAlias = ev.getPrevContent().alias; const oldAlias = ev.getPrevContent().alias;
const oldAltAliases = ev.getPrevContent().alt_aliases || [];
const newAlias = ev.getContent().alias; const newAlias = ev.getContent().alias;
const newAltAliases = ev.getContent().alt_aliases || [];
const removedAltAliases = oldAltAliases.filter(alias => !newAltAliases.includes(alias));
const addedAltAliases = newAltAliases.filter(alias => !oldAltAliases.includes(alias));
if (newAlias) { if (!removedAltAliases.length && !addedAltAliases.length) {
return _t('%(senderName)s set the main address for this room to %(address)s.', { if (newAlias) {
senderName: senderName, return _t('%(senderName)s set the main address for this room to %(address)s.', {
address: ev.getContent().alias, senderName: senderName,
}); address: ev.getContent().alias,
} else if (oldAlias) { });
return _t('%(senderName)s removed the main address for this room.', { } else if (oldAlias) {
return _t('%(senderName)s removed the main address for this room.', {
senderName: senderName,
});
}
} else if (newAlias === oldAlias) {
if (addedAltAliases.length && !removedAltAliases.length) {
return _t('%(senderName)s added the alternative addresses %(addresses)s for this room.', {
senderName: senderName,
addresses: addedAltAliases.join(", "),
count: addedAltAliases.length,
});
} if (removedAltAliases.length && !addedAltAliases.length) {
return _t('%(senderName)s removed the alternative addresses %(addresses)s for this room.', {
senderName: senderName,
addresses: removedAltAliases.join(", "),
count: removedAltAliases.length,
});
} if (removedAltAliases.length && addedAltAliases.length) {
return _t('%(senderName)s changed the alternative addresses for this room.', {
senderName: senderName,
});
}
} else {
// both alias and alt_aliases where modified
return _t('%(senderName)s changed the main and alternative addresses for this room.', {
senderName: senderName, senderName: senderName,
}); });
} }
// in case there is no difference between the two events,
// say something as we can't simply hide the tile from here
return _t('%(senderName)s changed the addresses for this room.', {
senderName: senderName,
});
} }
function textForCallAnswerEvent(event) { function textForCallAnswerEvent(event) {
@ -612,7 +589,6 @@ const handlers = {
}; };
const stateHandlers = { const stateHandlers = {
'm.room.aliases': textForRoomAliasesEvent,
'm.room.canonical_alias': textForCanonicalAliasEvent, 'm.room.canonical_alias': textForCanonicalAliasEvent,
'm.room.name': textForRoomNameEvent, 'm.room.name': textForRoomNameEvent,
'm.room.topic': textForTopicEvent, 'm.room.topic': textForTopicEvent,

View file

@ -27,6 +27,7 @@ import {MatrixClientPeg} from "./MatrixClientPeg";
import SettingsStore from "./settings/SettingsStore"; import SettingsStore from "./settings/SettingsStore";
import WidgetOpenIDPermissionsDialog from "./components/views/dialogs/WidgetOpenIDPermissionsDialog"; import WidgetOpenIDPermissionsDialog from "./components/views/dialogs/WidgetOpenIDPermissionsDialog";
import WidgetUtils from "./utils/WidgetUtils"; import WidgetUtils from "./utils/WidgetUtils";
import {KnownWidgetActions} from "./widgets/WidgetApi";
if (!global.mxFromWidgetMessaging) { if (!global.mxFromWidgetMessaging) {
global.mxFromWidgetMessaging = new FromWidgetPostMessageApi(); global.mxFromWidgetMessaging = new FromWidgetPostMessageApi();
@ -75,6 +76,17 @@ export default class WidgetMessaging {
}); });
} }
/**
* 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,
});
}
/** /**
* Request a screenshot from a widget * Request a screenshot from a widget
* @return {Promise} To be resolved with screenshot data when it has been generated * @return {Promise} To be resolved with screenshot data when it has been generated

View file

@ -0,0 +1,355 @@
/*
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 * as React from "react";
import classNames from "classnames";
import * as sdk from "../index";
import Modal from "../Modal";
import { _t, _td } from "../languageHandler";
import {isMac, Key} from "../Keyboard";
// TS: once languageHandler is TS we can probably inline this into the enum
_td("Navigation");
_td("Calls");
_td("Composer");
_td("Room List");
_td("Autocomplete");
export enum Categories {
NAVIGATION = "Navigation",
CALLS = "Calls",
COMPOSER = "Composer",
ROOM_LIST = "Room List",
AUTOCOMPLETE = "Autocomplete",
}
// TS: once languageHandler is TS we can probably inline this into the enum
_td("Alt");
_td("Alt Gr");
_td("Shift");
_td("Super");
_td("Ctrl");
export enum Modifiers {
ALT = "Alt", // Option on Mac and displayed as an Icon
ALT_GR = "Alt Gr",
SHIFT = "Shift",
SUPER = "Super", // should this be "Windows"?
// Instead of using below, consider CMD_OR_CTRL
COMMAND = "Command", // This gets displayed as an Icon
CONTROL = "Ctrl",
}
// Meta-modifier: isMac ? CMD : CONTROL
export const CMD_OR_CTRL = isMac ? Modifiers.COMMAND : Modifiers.CONTROL;
interface IKeybind {
modifiers?: Modifiers[];
key: string; // TS: fix this once Key is an enum
}
interface IShortcut {
keybinds: IKeybind[];
description: string;
}
const shortcuts: Record<Categories, IShortcut[]> = {
[Categories.COMPOSER]: [
{
keybinds: [{
modifiers: [CMD_OR_CTRL],
key: Key.B,
}],
description: _td("Toggle Bold"),
}, {
keybinds: [{
modifiers: [CMD_OR_CTRL],
key: Key.I,
}],
description: _td("Toggle Italics"),
}, {
keybinds: [{
modifiers: [CMD_OR_CTRL],
key: Key.GREATER_THAN,
}],
description: _td("Toggle Quote"),
}, {
keybinds: [{
modifiers: [Modifiers.SHIFT],
key: Key.ENTER,
}],
description: _td("New line"),
}, {
keybinds: [{
key: Key.ARROW_UP,
}, {
key: Key.ARROW_DOWN,
}],
description: _td("Navigate recent messages to edit"),
}, {
keybinds: [{
modifiers: [CMD_OR_CTRL],
key: Key.HOME,
}, {
modifiers: [CMD_OR_CTRL],
key: Key.END,
}],
description: _td("Jump to start/end of the composer"),
}, {
keybinds: [{
modifiers: [Modifiers.CONTROL, Modifiers.ALT],
key: Key.ARROW_UP,
}, {
modifiers: [Modifiers.CONTROL, Modifiers.ALT],
key: Key.ARROW_DOWN,
}],
description: _td("Navigate composer history"),
},
],
[Categories.CALLS]: [
{
keybinds: [{
modifiers: [CMD_OR_CTRL],
key: Key.D,
}],
description: _td("Toggle microphone mute"),
}, {
keybinds: [{
modifiers: [CMD_OR_CTRL],
key: Key.E,
}],
description: _td("Toggle video on/off"),
},
],
[Categories.ROOM_LIST]: [
{
keybinds: [{
modifiers: [CMD_OR_CTRL],
key: Key.K,
}],
description: _td("Jump to room search"),
}, {
keybinds: [{
key: Key.ARROW_UP,
}, {
key: Key.ARROW_DOWN,
}],
description: _td("Navigate up/down in the room list"),
}, {
keybinds: [{
key: Key.ENTER,
}],
description: _td("Select room from the room list"),
}, {
keybinds: [{
key: Key.ARROW_LEFT,
}],
description: _td("Collapse room list section"),
}, {
keybinds: [{
key: Key.ARROW_RIGHT,
}],
description: _td("Expand room list section"),
}, {
keybinds: [{
key: Key.ESCAPE,
}],
description: _td("Clear room list filter field"),
},
],
[Categories.NAVIGATION]: [
{
keybinds: [{
key: Key.PAGE_UP,
}, {
key: Key.PAGE_DOWN,
}],
description: _td("Scroll up/down in the timeline"),
}, {
keybinds: [{
modifiers: [Modifiers.ALT, Modifiers.SHIFT],
key: Key.ARROW_UP,
}, {
modifiers: [Modifiers.ALT, Modifiers.SHIFT],
key: Key.ARROW_DOWN,
}],
description: _td("Previous/next unread room or DM"),
}, {
keybinds: [{
modifiers: [Modifiers.ALT],
key: Key.ARROW_UP,
}, {
modifiers: [Modifiers.ALT],
key: Key.ARROW_DOWN,
}],
description: _td("Previous/next room or DM"),
}, {
keybinds: [{
modifiers: [CMD_OR_CTRL],
key: Key.BACKTICK,
}],
description: _td("Toggle the top left menu"),
}, {
keybinds: [{
key: Key.ESCAPE,
}],
description: _td("Close dialog or context menu"),
}, {
keybinds: [{
key: Key.ENTER,
}, {
key: Key.SPACE,
}],
description: _td("Activate selected button"),
}, {
keybinds: [{
modifiers: [CMD_OR_CTRL],
key: Key.PERIOD,
}],
description: _td("Toggle right panel"),
}, {
keybinds: [{
modifiers: [CMD_OR_CTRL],
key: Key.SLASH,
}],
description: _td("Toggle this dialog"),
},
],
[Categories.AUTOCOMPLETE]: [
{
keybinds: [{
key: Key.ARROW_UP,
}, {
key: Key.ARROW_DOWN,
}],
description: _td("Move autocomplete selection up/down"),
}, {
keybinds: [{
key: Key.ESCAPE,
}],
description: _td("Cancel autocomplete"),
},
],
};
const categoryOrder = [
Categories.COMPOSER,
Categories.CALLS,
Categories.ROOM_LIST,
Categories.AUTOCOMPLETE,
Categories.NAVIGATION,
];
interface IModal {
close: () => void;
finished: Promise<any[]>;
}
const modifierIcon: Record<string, string> = {
[Modifiers.COMMAND]: "⌘",
};
if (isMac) {
modifierIcon[Modifiers.ALT] = "⌥";
}
const alternateKeyName: Record<string, string> = {
[Key.PAGE_UP]: _td("Page Up"),
[Key.PAGE_DOWN]: _td("Page Down"),
[Key.ESCAPE]: _td("Esc"),
[Key.ENTER]: _td("Enter"),
[Key.SPACE]: _td("Space"),
[Key.HOME]: _td("Home"),
[Key.END]: _td("End"),
};
const keyIcon: Record<string, string> = {
[Key.ARROW_UP]: "↑",
[Key.ARROW_DOWN]: "↓",
[Key.ARROW_LEFT]: "←",
[Key.ARROW_RIGHT]: "→",
};
const Shortcut: React.FC<{
shortcut: IShortcut;
}> = ({shortcut}) => {
const classes = classNames({
"mx_KeyboardShortcutsDialog_inline": shortcut.keybinds.every(k => !k.modifiers || k.modifiers.length === 0),
});
return <div className={classes}>
<h5>{ _t(shortcut.description) }</h5>
{ shortcut.keybinds.map(s => {
let text = s.key;
if (alternateKeyName[s.key]) {
text = _t(alternateKeyName[s.key]);
} else if (keyIcon[s.key]) {
text = keyIcon[s.key];
}
return <div key={s.key}>
{ s.modifiers && s.modifiers.map(m => {
return <React.Fragment key={m}>
<kbd>{ modifierIcon[m] || _t(m) }</kbd>+
</React.Fragment>;
}) }
<kbd>{ text }</kbd>
</div>;
}) }
</div>;
};
let activeModal: IModal = null;
export const toggleDialog = () => {
if (activeModal) {
activeModal.close();
activeModal = null;
return;
}
const sections = categoryOrder.map(category => {
const list = shortcuts[category];
return <div className="mx_KeyboardShortcutsDialog_category" key={category}>
<h3>{_t(category)}</h3>
<div>{list.map(shortcut => <Shortcut key={shortcut.description} shortcut={shortcut} />)}</div>
</div>;
});
const InfoDialog = sdk.getComponent('dialogs.InfoDialog');
activeModal = Modal.createTrackedDialog("Keyboard Shortcuts", "", InfoDialog, {
className: "mx_KeyboardShortcutsDialog",
title: _t("Keyboard Shortcuts"),
description: sections,
hasCloseButton: true,
onKeyDown: (ev) => {
if (ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey && ev.key === Key.SLASH) { // Ctrl + /
ev.stopPropagation();
activeModal.close();
}
},
onFinished: () => {
activeModal = null;
},
});
};
export const registerShortcut = (category: Categories, defn: IShortcut) => {
shortcuts[category].push(defn);
};

View file

@ -15,7 +15,7 @@ limitations under the License.
*/ */
import { asyncAction } from './actionCreators'; import { asyncAction } from './actionCreators';
import RoomListStore from '../stores/RoomListStore'; import RoomListStore, {TAG_DM} from '../stores/RoomListStore';
import Modal from '../Modal'; import Modal from '../Modal';
import * as Rooms from '../Rooms'; import * as Rooms from '../Rooms';
import { _t } from '../languageHandler'; import { _t } from '../languageHandler';
@ -73,11 +73,11 @@ RoomListActions.tagRoom = function(matrixClient, room, oldTag, newTag, oldIndex,
const roomId = room.roomId; const roomId = room.roomId;
// Evil hack to get DMs behaving // Evil hack to get DMs behaving
if ((oldTag === undefined && newTag === 'im.vector.fake.direct') || if ((oldTag === undefined && newTag === TAG_DM) ||
(oldTag === 'im.vector.fake.direct' && newTag === undefined) (oldTag === TAG_DM && newTag === undefined)
) { ) {
return Rooms.guessAndSetDMRoom( return Rooms.guessAndSetDMRoom(
room, newTag === 'im.vector.fake.direct', room, newTag === TAG_DM,
).catch((err) => { ).catch((err) => {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
console.error("Failed to set direct chat tag " + err); console.error("Failed to set direct chat tag " + err);
@ -91,10 +91,10 @@ RoomListActions.tagRoom = function(matrixClient, room, oldTag, newTag, oldIndex,
const hasChangedSubLists = oldTag !== newTag; const hasChangedSubLists = oldTag !== newTag;
// More evilness: We will still be dealing with moving to favourites/low prio, // More evilness: We will still be dealing with moving to favourites/low prio,
// but we avoid ever doing a request with 'im.vector.fake.direct`. // but we avoid ever doing a request with TAG_DM.
// //
// if we moved lists, remove the old tag // if we moved lists, remove the old tag
if (oldTag && oldTag !== 'im.vector.fake.direct' && if (oldTag && oldTag !== TAG_DM &&
hasChangedSubLists hasChangedSubLists
) { ) {
const promiseToDelete = matrixClient.deleteRoomTag( const promiseToDelete = matrixClient.deleteRoomTag(
@ -112,7 +112,7 @@ RoomListActions.tagRoom = function(matrixClient, room, oldTag, newTag, oldIndex,
} }
// if we moved lists or the ordering changed, add the new tag // if we moved lists or the ordering changed, add the new tag
if (newTag && newTag !== 'im.vector.fake.direct' && if (newTag && newTag !== TAG_DM &&
(hasChangedSubLists || metaData) (hasChangedSubLists || metaData)
) { ) {
// metaData is the body of the PUT to set the tag, so it must // metaData is the body of the PUT to set the tag, so it must

View file

@ -141,15 +141,17 @@ export default class ManageEventIndexDialog extends React.Component {
let crawlerState; let crawlerState;
if (this.state.currentRoom === null) { if (this.state.currentRoom === null) {
crawlerState = _t("Not currently downloading messages for any room."); crawlerState = _t("Not currently indexing messages for any room.");
} else { } else {
crawlerState = ( crawlerState = (
_t("Downloading mesages for %(currentRoom)s.", { currentRoom: this.state.currentRoom }) _t("Currently indexing: %(currentRoom)s.", { currentRoom: this.state.currentRoom })
); );
} }
const Field = sdk.getComponent('views.elements.Field'); const Field = sdk.getComponent('views.elements.Field');
const doneRooms = Math.max(0, (this.state.roomCount - this.state.crawlingRoomsCount));
const eventIndexingSettings = ( const eventIndexingSettings = (
<div> <div>
{ {
@ -158,13 +160,13 @@ export default class ManageEventIndexDialog extends React.Component {
) )
} }
<div className='mx_SettingsTab_subsectionText'> <div className='mx_SettingsTab_subsectionText'>
{crawlerState}<br />
{_t("Space used:")} {formatBytes(this.state.eventIndexSize, 0)}<br /> {_t("Space used:")} {formatBytes(this.state.eventIndexSize, 0)}<br />
{_t("Indexed messages:")} {formatCountLong(this.state.eventCount)}<br /> {_t("Indexed messages:")} {formatCountLong(this.state.eventCount)}<br />
{_t("Indexed rooms:")} {_t("%(crawlingRooms)s out of %(totalRooms)s", { {_t("Indexed rooms:")} {_t("%(doneRooms)s out of %(totalRooms)s", {
crawlingRooms: formatCountLong(this.state.crawlingRoomsCount), doneRooms: formatCountLong(doneRooms),
totalRooms: formatCountLong(this.state.roomCount), totalRooms: formatCountLong(this.state.roomCount),
})} <br /> })} <br />
{crawlerState}<br />
<Field <Field
id={"crawlerSleepTimeMs"} id={"crawlerSleepTimeMs"}
label={_t('Message downloading sleep time(ms)')} label={_t('Message downloading sleep time(ms)')}

View file

@ -23,6 +23,7 @@ import { scorePassword } from '../../../../utils/PasswordScorer';
import FileSaver from 'file-saver'; import FileSaver from 'file-saver';
import { _t } from '../../../../languageHandler'; import { _t } from '../../../../languageHandler';
import Modal from '../../../../Modal'; import Modal from '../../../../Modal';
import { promptForBackupPassphrase } from '../../../../CrossSigningManager';
const PHASE_LOADING = 0; const PHASE_LOADING = 0;
const PHASE_MIGRATE = 1; const PHASE_MIGRATE = 1;
@ -83,7 +84,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
// does the server offer a UI auth flow with just m.login.password // does the server offer a UI auth flow with just m.login.password
// for /keys/device_signing/upload? // for /keys/device_signing/upload?
canUploadKeysWithPasswordOnly: null, canUploadKeysWithPasswordOnly: null,
accountPassword: props.accountPassword, accountPassword: props.accountPassword || "",
accountPasswordCorrect: null, accountPasswordCorrect: null,
// status of the key backup toggle switch // status of the key backup toggle switch
useKeyBackup: true, useKeyBackup: true,
@ -117,6 +118,11 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
backupInfo, backupInfo,
backupSigStatus, backupSigStatus,
}); });
return {
backupInfo,
backupSigStatus,
};
} }
async _queryKeyUploadAuth() { async _queryKeyUploadAuth() {
@ -238,6 +244,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
createSecretStorageKey: async () => this._keyInfo, createSecretStorageKey: async () => this._keyInfo,
keyBackupInfo: this.state.backupInfo, keyBackupInfo: this.state.backupInfo,
setupNewKeyBackup: !this.state.backupInfo && this.state.useKeyBackup, setupNewKeyBackup: !this.state.backupInfo && this.state.useKeyBackup,
getKeyBackupPassphrase: promptForBackupPassphrase,
}); });
} }
this.setState({ this.setState({
@ -269,13 +276,13 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
const RestoreKeyBackupDialog = sdk.getComponent('dialogs.keybackup.RestoreKeyBackupDialog'); const RestoreKeyBackupDialog = sdk.getComponent('dialogs.keybackup.RestoreKeyBackupDialog');
const { finished } = Modal.createTrackedDialog( const { finished } = Modal.createTrackedDialog(
'Restore Backup', '', RestoreKeyBackupDialog, {showSummary: false}, null, 'Restore Backup', '', RestoreKeyBackupDialog, {showSummary: false}, null,
/* priority = */ false, /* static = */ true, /* priority = */ false, /* static = */ false,
); );
await finished; await finished;
await this._fetchBackupInfo(); const { backupSigStatus } = await this._fetchBackupInfo();
if ( if (
this.state.backupSigStatus.usable && backupSigStatus.usable &&
this.state.canUploadKeysWithPasswordOnly && this.state.canUploadKeysWithPasswordOnly &&
this.state.accountPassword this.state.accountPassword
) { ) {
@ -400,12 +407,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
let authPrompt; let authPrompt;
let nextCaption = _t("Next"); let nextCaption = _t("Next");
if (!this.state.backupSigStatus.usable) { if (this.state.canUploadKeysWithPasswordOnly) {
authPrompt = <div>
<div>{_t("Restore your key backup to upgrade your encryption")}</div>
</div>;
nextCaption = _t("Restore");
} else if (this.state.canUploadKeysWithPasswordOnly) {
authPrompt = <div> authPrompt = <div>
<div>{_t("Enter your account password to confirm the upgrade:")}</div> <div>{_t("Enter your account password to confirm the upgrade:")}</div>
<div><Field <div><Field
@ -418,6 +420,11 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
autoFocus={true} autoFocus={true}
/></div> /></div>
</div>; </div>;
} else if (!this.state.backupSigStatus.usable) {
authPrompt = <div>
<div>{_t("Restore your key backup to upgrade your encryption")}</div>
</div>;
nextCaption = _t("Restore");
} else { } else {
authPrompt = <p> authPrompt = <p>
{_t("You'll need to authenticate with the server to confirm the upgrade.")} {_t("You'll need to authenticate with the server to confirm the upgrade.")}
@ -433,6 +440,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
<div>{authPrompt}</div> <div>{authPrompt}</div>
<DialogButtons <DialogButtons
primaryButton={nextCaption} primaryButton={nextCaption}
onPrimaryButtonClick={this._onMigrateFormSubmit}
hasCancel={false} hasCancel={false}
primaryDisabled={this.state.canUploadKeysWithPasswordOnly && !this.state.accountPassword} primaryDisabled={this.state.canUploadKeysWithPasswordOnly && !this.state.accountPassword}
> >

View file

@ -350,7 +350,7 @@ export const ContextMenuButton = ({ label, isExpanded, children, ...props }) =>
}; };
ContextMenuButton.propTypes = { ContextMenuButton.propTypes = {
...AccessibleButton.propTypes, ...AccessibleButton.propTypes,
label: PropTypes.string.isRequired, label: PropTypes.string,
isExpanded: PropTypes.bool.isRequired, // whether or not the context menu is currently open isExpanded: PropTypes.bool.isRequired, // whether or not the context menu is currently open
}; };
@ -377,7 +377,6 @@ export const MenuGroup = ({children, label, ...props}) => {
</div>; </div>;
}; };
MenuGroup.propTypes = { MenuGroup.propTypes = {
...AccessibleButton.propTypes,
label: PropTypes.string.isRequired, label: PropTypes.string.isRequired,
className: PropTypes.string, // optional className: PropTypes.string, // optional
}; };

View file

@ -23,11 +23,11 @@ import PropTypes from 'prop-types';
import request from 'browser-request'; import request from 'browser-request';
import { _t } from '../../languageHandler'; import { _t } from '../../languageHandler';
import sanitizeHtml from 'sanitize-html'; import sanitizeHtml from 'sanitize-html';
import * as sdk from '../../index';
import dis from '../../dispatcher'; import dis from '../../dispatcher';
import {MatrixClientPeg} from '../../MatrixClientPeg'; import {MatrixClientPeg} from '../../MatrixClientPeg';
import classnames from 'classnames'; import classnames from 'classnames';
import MatrixClientContext from "../../contexts/MatrixClientContext"; import MatrixClientContext from "../../contexts/MatrixClientContext";
import AutoHideScrollbar from "./AutoHideScrollbar";
export default class EmbeddedPage extends React.PureComponent { export default class EmbeddedPage extends React.PureComponent {
static propTypes = { static propTypes = {
@ -117,10 +117,9 @@ export default class EmbeddedPage extends React.PureComponent {
</div>; </div>;
if (this.props.scrollbar) { if (this.props.scrollbar) {
const GeminiScrollbarWrapper = sdk.getComponent("elements.GeminiScrollbarWrapper"); return <AutoHideScrollbar className={classes}>
return <GeminiScrollbarWrapper autoshow={true} className={classes}>
{content} {content}
</GeminiScrollbarWrapper>; </AutoHideScrollbar>;
} else { } else {
return <div className={classes}> return <div className={classes}>
{content} {content}

View file

@ -39,6 +39,7 @@ import {makeGroupPermalink, makeUserPermalink} from "../../utils/permalinks/Perm
import {Group} from "matrix-js-sdk"; import {Group} from "matrix-js-sdk";
import {allSettled, sleep} from "../../utils/promise"; import {allSettled, sleep} from "../../utils/promise";
import RightPanelStore from "../../stores/RightPanelStore"; import RightPanelStore from "../../stores/RightPanelStore";
import AutoHideScrollbar from "./AutoHideScrollbar";
const LONG_DESC_PLACEHOLDER = _td( const LONG_DESC_PLACEHOLDER = _td(
`<h1>HTML for your community's page</h1> `<h1>HTML for your community's page</h1>
@ -423,6 +424,7 @@ export default createReactClass({
membershipBusy: false, membershipBusy: false,
publicityBusy: false, publicityBusy: false,
inviterProfile: null, inviterProfile: null,
showRightPanel: RightPanelStore.getSharedInstance().isOpenForGroup,
}; };
}, },
@ -435,12 +437,18 @@ export default createReactClass({
this._initGroupStore(this.props.groupId, true); this._initGroupStore(this.props.groupId, true);
this._dispatcherRef = dis.register(this._onAction); this._dispatcherRef = dis.register(this._onAction);
this._rightPanelStoreToken = RightPanelStore.getSharedInstance().addListener(this._onRightPanelStoreUpdate);
}, },
componentWillUnmount: function() { componentWillUnmount: function() {
this._unmounted = true; this._unmounted = true;
this._matrixClient.removeListener("Group.myMembership", this._onGroupMyMembership); this._matrixClient.removeListener("Group.myMembership", this._onGroupMyMembership);
dis.unregister(this._dispatcherRef); dis.unregister(this._dispatcherRef);
// Remove RightPanelStore listener
if (this._rightPanelStoreToken) {
this._rightPanelStoreToken.remove();
}
}, },
componentWillReceiveProps: function(newProps) { componentWillReceiveProps: function(newProps) {
@ -454,6 +462,12 @@ export default createReactClass({
} }
}, },
_onRightPanelStoreUpdate: function() {
this.setState({
showRightPanel: RightPanelStore.getSharedInstance().isOpenForGroup,
});
},
_onGroupMyMembership: function(group) { _onGroupMyMembership: function(group) {
if (this._unmounted || group.groupId !== this.props.groupId) return; if (this._unmounted || group.groupId !== this.props.groupId) return;
if (group.myMembership === 'leave') { if (group.myMembership === 'leave') {
@ -481,7 +495,7 @@ export default createReactClass({
group_id: groupId, group_id: groupId,
}, },
}); });
dis.dispatch({action: 'require_registration'}); dis.dispatch({action: 'require_registration', screen_after: {screen: `group/${groupId}`}});
willDoOnboarding = true; willDoOnboarding = true;
} }
if (stateKey === GroupStore.STATE_KEY.Summary) { if (stateKey === GroupStore.STATE_KEY.Summary) {
@ -554,10 +568,6 @@ export default createReactClass({
GROUP_JOINPOLICY_INVITE, GROUP_JOINPOLICY_INVITE,
}, },
}); });
dis.dispatch({
action: 'panel_disable',
sideDisabled: true,
});
}, },
_onShareClick: function() { _onShareClick: function() {
@ -580,10 +590,6 @@ export default createReactClass({
profileForm: null, profileForm: null,
}); });
break; break;
case 'after_right_panel_phase_change':
// We don't keep state on the right panel, so just re-render to update
this.forceUpdate();
break;
default: default:
break; break;
} }
@ -726,7 +732,7 @@ export default createReactClass({
_onJoinClick: async function() { _onJoinClick: async function() {
if (this._matrixClient.isGuest()) { if (this._matrixClient.isGuest()) {
dis.dispatch({action: 'require_registration'}); dis.dispatch({action: 'require_registration', screen_after: {screen: `group/${this.props.groupId}`}});
return; return;
} }
@ -1173,7 +1179,6 @@ export default createReactClass({
render: function() { render: function() {
const GroupAvatar = sdk.getComponent("avatars.GroupAvatar"); const GroupAvatar = sdk.getComponent("avatars.GroupAvatar");
const Spinner = sdk.getComponent("elements.Spinner"); const Spinner = sdk.getComponent("elements.Spinner");
const GeminiScrollbarWrapper = sdk.getComponent("elements.GeminiScrollbarWrapper");
if (this.state.summaryLoading && this.state.error === null || this.state.saving) { if (this.state.summaryLoading && this.state.error === null || this.state.saving) {
return <Spinner />; return <Spinner />;
@ -1299,9 +1304,7 @@ export default createReactClass({
); );
} }
const rightPanel = RightPanelStore.getSharedInstance().isOpenForGroup const rightPanel = this.state.showRightPanel ? <RightPanel groupId={this.props.groupId} /> : undefined;
? <RightPanel groupId={this.props.groupId} />
: undefined;
const headerClasses = { const headerClasses = {
"mx_GroupView_header": true, "mx_GroupView_header": true,
@ -1332,10 +1335,10 @@ export default createReactClass({
<GroupHeaderButtons /> <GroupHeaderButtons />
</div> </div>
<MainSplit panel={rightPanel}> <MainSplit panel={rightPanel}>
<GeminiScrollbarWrapper className="mx_GroupView_body"> <AutoHideScrollbar className="mx_GroupView_body">
{ this._getMembershipSection() } { this._getMembershipSection() }
{ this._getGroupSection() } { this._getGroupSection() }
</GeminiScrollbarWrapper> </AutoHideScrollbar>
</MainSplit> </MainSplit>
</main> </main>
); );

View file

@ -161,6 +161,7 @@ export default createReactClass({
_authStateUpdated: function(stageType, stageState) { _authStateUpdated: function(stageType, stageState) {
const oldStage = this.state.authStage; const oldStage = this.state.authStage;
this.setState({ this.setState({
busy: false,
authStage: stageType, authStage: stageType,
stageState: stageState, stageState: stageState,
errorText: stageState.error, errorText: stageState.error,
@ -184,11 +185,13 @@ export default createReactClass({
errorText: null, errorText: null,
stageErrorText: null, stageErrorText: null,
}); });
} else {
this.setState({
busy: false,
});
} }
// The JS SDK eagerly reports itself as "not busy" right after any
// immediate work has completed, but that's not really what we want at
// the UI layer, so we ignore this signal and show a spinner until
// 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/riot-web/issues/12546
}, },
_setFocus: function() { _setFocus: function() {

View file

@ -39,6 +39,7 @@ import RoomListActions from '../../actions/RoomListActions';
import ResizeHandle from '../views/elements/ResizeHandle'; import ResizeHandle from '../views/elements/ResizeHandle';
import {Resizer, CollapseDistributor} from '../../resizer'; import {Resizer, CollapseDistributor} from '../../resizer';
import MatrixClientContext from "../../contexts/MatrixClientContext"; import MatrixClientContext from "../../contexts/MatrixClientContext";
import * as KeyboardShortcuts from "../../accessibility/KeyboardShortcuts";
// We need to fetch each pinned message individually (if we don't already have it) // We need to fetch each pinned message individually (if we don't already have it)
// so each pinned message may trigger a request. Limit the number per room for sanity. // so each pinned message may trigger a request. Limit the number per room for sanity.
// NB. this is just for server notices rather than pinned messages in general. // NB. this is just for server notices rather than pinned messages in general.
@ -337,13 +338,13 @@ const LoggedInView = createReactClass({
let handled = false; let handled = false;
const ctrlCmdOnly = isOnlyCtrlOrCmdKeyEvent(ev); const ctrlCmdOnly = isOnlyCtrlOrCmdKeyEvent(ev);
const hasModifier = ev.altKey || ev.ctrlKey || ev.metaKey || ev.shiftKey || const hasModifier = ev.altKey || ev.ctrlKey || ev.metaKey || ev.shiftKey;
ev.key === Key.ALT || ev.key === Key.CONTROL || ev.key === Key.META || ev.key === Key.SHIFT; const isModifier = ev.key === Key.ALT || ev.key === Key.CONTROL || ev.key === Key.META || ev.key === Key.SHIFT;
switch (ev.key) { switch (ev.key) {
case Key.PAGE_UP: case Key.PAGE_UP:
case Key.PAGE_DOWN: case Key.PAGE_DOWN:
if (!hasModifier) { if (!hasModifier && !isModifier) {
this._onScrollKeyPressed(ev); this._onScrollKeyPressed(ev);
handled = true; handled = true;
} }
@ -365,8 +366,6 @@ const LoggedInView = createReactClass({
} }
break; break;
case Key.BACKTICK: case Key.BACKTICK:
if (ev.key !== "`") break;
// Ideally this would be CTRL+P for "Profile", but that's // Ideally this would be CTRL+P for "Profile", but that's
// taken by the print dialog. CTRL+I for "Information" // taken by the print dialog. CTRL+I for "Information"
// was previously chosen but conflicted with italics in // was previously chosen but conflicted with italics in
@ -379,12 +378,43 @@ const LoggedInView = createReactClass({
handled = true; handled = true;
} }
break; break;
case Key.SLASH:
if (ctrlCmdOnly) {
KeyboardShortcuts.toggleDialog();
handled = true;
}
break;
case Key.ARROW_UP:
case Key.ARROW_DOWN:
if (ev.altKey && !ev.ctrlKey && !ev.metaKey) {
dis.dispatch({
action: 'view_room_delta',
delta: ev.key === Key.ARROW_UP ? -1 : 1,
unread: ev.shiftKey,
});
handled = true;
}
break;
case Key.PERIOD:
if (ctrlCmdOnly && (this.props.page_type === "room_view" || this.props.page_type === "group_view")) {
dis.dispatch({
action: 'toggle_right_panel',
type: this.props.page_type === "room_view" ? "room" : "group",
});
handled = true;
}
} }
if (handled) { if (handled) {
ev.stopPropagation(); ev.stopPropagation();
ev.preventDefault(); ev.preventDefault();
} else if (!hasModifier) { } else if (!isModifier && !ev.altKey && !ev.ctrlKey && !ev.metaKey) {
// The above condition is crafted to _allow_ characters with Shift
// already pressed (but not the Shift key down itself).
const isClickShortcut = ev.target !== document.body && const isClickShortcut = ev.target !== document.body &&
(ev.key === Key.SPACE || ev.key === Key.ENTER); (ev.key === Key.SPACE || ev.key === Key.ENTER);
@ -585,7 +615,8 @@ const LoggedInView = createReactClass({
limitType={usageLimitEvent.getContent().limit_type} limitType={usageLimitEvent.getContent().limit_type}
/>; />;
} else if (this.props.showCookieBar && } else if (this.props.showCookieBar &&
this.props.config.piwik this.props.config.piwik &&
navigator.doNotTrack !== "1"
) { ) {
const policyUrl = this.props.config.piwik.policyUrl || null; const policyUrl = this.props.config.piwik.policyUrl || null;
topBar = <CookieBar policyUrl={policyUrl} />; topBar = <CookieBar policyUrl={policyUrl} />;

View file

@ -93,14 +93,19 @@ export default class MainSplit extends React.Component {
const bodyView = React.Children.only(this.props.children); const bodyView = React.Children.only(this.props.children);
const panelView = this.props.panel; const panelView = this.props.panel;
if (this.props.collapsedRhs || !panelView) { const hasResizer = !this.props.collapsedRhs && panelView;
return bodyView;
} else { let children;
return <div className="mx_MainSplit" ref={this._setResizeContainerRef}> if (hasResizer) {
{ bodyView } children = <React.Fragment>
<ResizeHandle reverse={true} /> <ResizeHandle reverse={true} />
{ panelView } { panelView }
</div>; </React.Fragment>;
} }
return <div className="mx_MainSplit" ref={hasResizer ? this._setResizeContainerRef : undefined}>
{ bodyView }
{ children }
</div>;
} }
} }

View file

@ -559,13 +559,19 @@ export default createReactClass({
case 'view_user_info': case 'view_user_info':
this._viewUser(payload.userId, payload.subAction); this._viewUser(payload.userId, payload.subAction);
break; break;
case 'view_room': case 'view_room': {
// Takes either a room ID or room alias: if switching to a room the client is already // Takes either a room ID or room alias: if switching to a room the client is already
// known to be in (eg. user clicks on a room in the recents panel), supply the ID // known to be in (eg. user clicks on a room in the recents panel), supply the ID
// If the user is clicking on a room in the context of the alias being presented // If the user is clicking on a room in the context of the alias being presented
// to them, supply the room alias. If both are supplied, the room ID will be ignored. // to them, supply the room alias. If both are supplied, the room ID will be ignored.
this._viewRoom(payload); const promise = this._viewRoom(payload);
if (payload.deferred_action) {
promise.then(() => {
dis.dispatch(payload.deferred_action);
});
}
break; break;
}
case 'view_prev_room': case 'view_prev_room':
this._viewNextRoom(-1); this._viewNextRoom(-1);
break; break;
@ -594,9 +600,8 @@ export default createReactClass({
break; break;
case 'view_room_directory': { case 'view_room_directory': {
const RoomDirectory = sdk.getComponent("structures.RoomDirectory"); const RoomDirectory = sdk.getComponent("structures.RoomDirectory");
Modal.createTrackedDialog('Room directory', '', RoomDirectory, { Modal.createTrackedDialog('Room directory', '', RoomDirectory, {},
config: this.props.config, 'mx_RoomDirectory_dialogWrapper', false, true);
}, 'mx_RoomDirectory_dialogWrapper');
// View the welcome or home page if we need something to look at // View the welcome or home page if we need something to look at
this._viewSomethingBehindModal(); this._viewSomethingBehindModal();
@ -862,7 +867,7 @@ export default createReactClass({
waitFor = this.firstSyncPromise.promise; waitFor = this.firstSyncPromise.promise;
} }
waitFor.then(() => { return waitFor.then(() => {
let presentedId = roomInfo.room_alias || roomInfo.room_id; let presentedId = roomInfo.room_alias || roomInfo.room_id;
const room = MatrixClientPeg.get().getRoom(roomInfo.room_id); const room = MatrixClientPeg.get().getRoom(roomInfo.room_id);
if (room) { if (room) {
@ -885,7 +890,7 @@ export default createReactClass({
presentedId += "/" + roomInfo.event_id; presentedId += "/" + roomInfo.event_id;
} }
newState.ready = true; newState.ready = true;
this.setState(newState, ()=>{ this.setState(newState, () => {
this.notifyNewScreen('room/' + presentedId); this.notifyNewScreen('room/' + presentedId);
}); });
}); });
@ -1008,6 +1013,10 @@ export default createReactClass({
// needs to be reset so that they can revisit /user/.. // (and trigger // needs to be reset so that they can revisit /user/.. // (and trigger
// `_chatCreateOrReuse` again) // `_chatCreateOrReuse` again)
go_welcome_on_cancel: true, go_welcome_on_cancel: true,
screen_after: {
screen: `user/${this.props.config.welcomeUserId}`,
params: { action: 'chat' },
},
}); });
return; return;
} }
@ -1177,7 +1186,15 @@ export default createReactClass({
_onLoggedIn: async function() { _onLoggedIn: async function() {
ThemeController.isLogin = false; ThemeController.isLogin = false;
this.setStateForNewView({ view: VIEWS.LOGGED_IN }); this.setStateForNewView({ view: VIEWS.LOGGED_IN });
if (MatrixClientPeg.currentUserIsJustRegistered()) { // If a specific screen is set to be shown after login, show that above
// all else, as it probably means the user clicked on something already.
if (this._screenAfterLogin && this._screenAfterLogin.screen) {
this.showScreen(
this._screenAfterLogin.screen,
this._screenAfterLogin.params,
);
this._screenAfterLogin = null;
} else if (MatrixClientPeg.currentUserIsJustRegistered()) {
MatrixClientPeg.setJustRegisteredUserId(null); MatrixClientPeg.setJustRegisteredUserId(null);
if (this.props.config.welcomeUserId && getCurrentLanguage().startsWith("en")) { if (this.props.config.welcomeUserId && getCurrentLanguage().startsWith("en")) {
@ -1477,26 +1494,40 @@ export default createReactClass({
} }
}); });
if (SettingsStore.isFeatureEnabled("feature_cross_signing")) { cli.on("crypto.keySignatureUploadFailure", (failures, source, continuation) => {
cli.on("crypto.verification.request", request => { const KeySignatureUploadFailedDialog =
if (request.pending) { sdk.getComponent('views.dialogs.KeySignatureUploadFailedDialog');
ToastStore.sharedInstance().addOrReplaceToast({ Modal.createTrackedDialog(
key: 'verifreq_' + request.channel.transactionId, 'Failed to upload key signatures',
title: _t("Verification Request"), 'Failed to upload key signatures',
icon: "verification", KeySignatureUploadFailedDialog,
props: {request}, { failures, source, continuation });
component: sdk.getComponent("toasts.VerificationRequestToast"), });
});
} cli.on("crypto.verification.request", request => {
}); const isFlagOn = SettingsStore.isFeatureEnabled("feature_cross_signing");
} else {
cli.on("crypto.verification.start", (verifier) => { if (!isFlagOn && !request.channel.deviceId) {
request.cancel({code: "m.invalid_message", reason: "This client has cross-signing disabled"});
return;
}
if (request.verifier) {
const IncomingSasDialog = sdk.getComponent("views.dialogs.IncomingSasDialog"); const IncomingSasDialog = sdk.getComponent("views.dialogs.IncomingSasDialog");
Modal.createTrackedDialog('Incoming Verification', '', IncomingSasDialog, { Modal.createTrackedDialog('Incoming Verification', '', IncomingSasDialog, {
verifier, verifier: request.verifier,
}, null, /* priority = */ false, /* static = */ true); }, null, /* priority = */ false, /* static = */ true);
}); } else if (request.pending) {
} ToastStore.sharedInstance().addOrReplaceToast({
key: 'verifreq_' + request.channel.transactionId,
title: _t("Verification Request"),
icon: "verification",
props: {request},
component: sdk.getComponent("toasts.VerificationRequestToast"),
priority: ToastStore.PRIORITY_REALTIME,
});
}
});
// Fire the tinter right on startup to ensure the default theme is applied // Fire the tinter right on startup to ensure the default theme is applied
// A later sync can/will correct the tint to be the right value for the user // A later sync can/will correct the tint to be the right value for the user
const colorScheme = SettingsStore.getValue("roomColor"); const colorScheme = SettingsStore.getValue("roomColor");
@ -1890,7 +1921,10 @@ export default createReactClass({
// secret storage. // secret storage.
SettingsStore.setFeatureEnabled("feature_cross_signing", true); SettingsStore.setFeatureEnabled("feature_cross_signing", true);
this.setStateForNewView({ view: VIEWS.COMPLETE_SECURITY }); this.setStateForNewView({ view: VIEWS.COMPLETE_SECURITY });
} else if (SettingsStore.isFeatureEnabled("feature_cross_signing")) { } else if (
SettingsStore.isFeatureEnabled("feature_cross_signing") &&
await cli.doesServerSupportUnstableFeature("org.matrix.e2e_cross_signing")
) {
// This will only work if the feature is set to 'enable' in the config, // This will only work if the feature is set to 'enable' in the config,
// since it's too early in the lifecycle for users to have turned the // since it's too early in the lifecycle for users to have turned the
// labs flag on. // labs flag on.

View file

@ -28,6 +28,7 @@ import {MatrixClientPeg} from '../../MatrixClientPeg';
import SettingsStore from '../../settings/SettingsStore'; import SettingsStore from '../../settings/SettingsStore';
import {_t} from "../../languageHandler"; import {_t} from "../../languageHandler";
import {haveTileForEvent} from "../views/rooms/EventTile"; import {haveTileForEvent} from "../views/rooms/EventTile";
import {textForEvent} from "../../TextForEvent";
const CONTINUATION_MAX_INTERVAL = 5 * 60 * 1000; // 5 minutes const CONTINUATION_MAX_INTERVAL = 5 * 60 * 1000; // 5 minutes
const continuedTypes = ['m.sticker', 'm.room.message']; const continuedTypes = ['m.sticker', 'm.room.message'];
@ -523,7 +524,8 @@ export default class MessagePanel extends React.Component {
// if there is a previous event and it has the same sender as this event // if there is a previous event and it has the same sender as this event
// and the types are the same/is in continuedTypes and the time between them is <= CONTINUATION_MAX_INTERVAL // and the types are the same/is in continuedTypes and the time between them is <= CONTINUATION_MAX_INTERVAL
if (prevEvent !== null && prevEvent.sender && mxEv.sender && mxEv.sender.userId === prevEvent.sender.userId && if (prevEvent !== null && prevEvent.sender && mxEv.sender && mxEv.sender.userId === prevEvent.sender.userId &&
(mxEv.getType() === prevEvent.getType() || eventTypeContinues) && // if we don't have tile for previous event then it was shown by showHiddenEvents and has no SenderProfile
haveTileForEvent(prevEvent) && (mxEv.getType() === prevEvent.getType() || eventTypeContinues) &&
(mxEv.getTs() - prevEvent.getTs() <= CONTINUATION_MAX_INTERVAL)) { (mxEv.getTs() - prevEvent.getTs() <= CONTINUATION_MAX_INTERVAL)) {
continuation = true; continuation = true;
} }
@ -879,6 +881,11 @@ class CreationGrouper {
} }
getTiles() { getTiles() {
// If we don't have any events to group, don't even try to group them. The logic
// below assumes that we have a group of events to deal with, but we might not if
// the events we were supposed to group were redacted.
if (!this.events || !this.events.length) return [];
const DateSeparator = sdk.getComponent('messages.DateSeparator'); const DateSeparator = sdk.getComponent('messages.DateSeparator');
const EventListSummary = sdk.getComponent('views.elements.EventListSummary'); const EventListSummary = sdk.getComponent('views.elements.EventListSummary');
@ -956,15 +963,30 @@ class MemberGrouper {
} }
shouldGroup(ev) { shouldGroup(ev) {
if (this.panel._wantsDateSeparator(this.events[0], ev.getDate())) {
return false;
}
return isMembershipChange(ev); return isMembershipChange(ev);
} }
add(ev) { add(ev) {
if (ev.getType() === 'm.room.member') {
// We'll just double check that it's worth our time to do so, through an
// ugly hack. If textForEvent returns something, we should group it for
// rendering but if it doesn't then we'll exclude it.
const renderText = textForEvent(ev);
if (!renderText || renderText.trim().length === 0) return; // quietly ignore
}
this.readMarker = this.readMarker || this.panel._readMarkerForEvent(ev.getId()); this.readMarker = this.readMarker || this.panel._readMarkerForEvent(ev.getId());
this.events.push(ev); this.events.push(ev);
} }
getTiles() { getTiles() {
// If we don't have any events to group, don't even try to group them. The logic
// below assumes that we have a group of events to deal with, but we might not if
// the events we were supposed to group were redacted.
if (!this.events || !this.events.length) return [];
const DateSeparator = sdk.getComponent('messages.DateSeparator'); const DateSeparator = sdk.getComponent('messages.DateSeparator');
const MemberEventListSummary = sdk.getComponent('views.elements.MemberEventListSummary'); const MemberEventListSummary = sdk.getComponent('views.elements.MemberEventListSummary');

View file

@ -22,6 +22,7 @@ import { _t } from '../../languageHandler';
import dis from '../../dispatcher'; import dis from '../../dispatcher';
import AccessibleButton from '../views/elements/AccessibleButton'; import AccessibleButton from '../views/elements/AccessibleButton';
import MatrixClientContext from "../../contexts/MatrixClientContext"; import MatrixClientContext from "../../contexts/MatrixClientContext";
import AutoHideScrollbar from "./AutoHideScrollbar";
export default createReactClass({ export default createReactClass({
displayName: 'MyGroups', displayName: 'MyGroups',
@ -62,8 +63,6 @@ export default createReactClass({
const Loader = sdk.getComponent("elements.Spinner"); const Loader = sdk.getComponent("elements.Spinner");
const SimpleRoomHeader = sdk.getComponent('rooms.SimpleRoomHeader'); const SimpleRoomHeader = sdk.getComponent('rooms.SimpleRoomHeader');
const GroupTile = sdk.getComponent("groups.GroupTile"); const GroupTile = sdk.getComponent("groups.GroupTile");
const GeminiScrollbarWrapper = sdk.getComponent("elements.GeminiScrollbarWrapper");
let content; let content;
let contentHeader; let contentHeader;
@ -74,7 +73,7 @@ export default createReactClass({
}); });
contentHeader = groupNodes.length > 0 ? <h3>{ _t('Your Communities') }</h3> : <div />; contentHeader = groupNodes.length > 0 ? <h3>{ _t('Your Communities') }</h3> : <div />;
content = groupNodes.length > 0 ? content = groupNodes.length > 0 ?
<GeminiScrollbarWrapper> <AutoHideScrollbar className="mx_MyGroups_scrollable">
<div className="mx_MyGroups_microcopy"> <div className="mx_MyGroups_microcopy">
<p> <p>
{ _t( { _t(
@ -93,7 +92,7 @@ export default createReactClass({
<div className="mx_MyGroups_joinedGroups"> <div className="mx_MyGroups_joinedGroups">
{ groupNodes } { groupNodes }
</div> </div>
</GeminiScrollbarWrapper> : </AutoHideScrollbar> :
<div className="mx_MyGroups_placeholder"> <div className="mx_MyGroups_placeholder">
{ _t( { _t(
"You're not currently a member of any communities.", "You're not currently a member of any communities.",

View file

@ -182,6 +182,7 @@ export default class RightPanel extends React.Component {
member: payload.member, member: payload.member,
event: payload.event, event: payload.event,
verificationRequest: payload.verificationRequest, verificationRequest: payload.verificationRequest,
verificationRequestPromise: payload.verificationRequestPromise,
}); });
} }
} }
@ -231,6 +232,7 @@ export default class RightPanel extends React.Component {
onClose={onClose} onClose={onClose}
phase={this.state.phase} phase={this.state.phase}
verificationRequest={this.state.verificationRequest} verificationRequest={this.state.verificationRequest}
verificationRequestPromise={this.state.verificationRequestPromise}
/>; />;
} else { } else {
panel = <MemberInfo panel = <MemberInfo

View file

@ -28,6 +28,7 @@ import { _t } from '../../languageHandler';
import { instanceForInstanceId, protocolNameForInstanceId } from '../../utils/DirectoryUtils'; import { instanceForInstanceId, protocolNameForInstanceId } from '../../utils/DirectoryUtils';
import Analytics from '../../Analytics'; import Analytics from '../../Analytics';
import {getHttpUriForMxc} from "matrix-js-sdk/src/content-repo"; import {getHttpUriForMxc} from "matrix-js-sdk/src/content-repo";
import {ALL_ROOMS} from "../views/directory/NetworkDropdown";
const MAX_NAME_LENGTH = 80; const MAX_NAME_LENGTH = 80;
const MAX_TOPIC_LENGTH = 160; const MAX_TOPIC_LENGTH = 160;
@ -40,25 +41,17 @@ export default createReactClass({
displayName: 'RoomDirectory', displayName: 'RoomDirectory',
propTypes: { propTypes: {
config: PropTypes.object,
onFinished: PropTypes.func.isRequired, onFinished: PropTypes.func.isRequired,
}, },
getDefaultProps: function() {
return {
config: {},
};
},
getInitialState: function() { getInitialState: function() {
return { return {
publicRooms: [], publicRooms: [],
loading: true, loading: true,
protocolsLoading: true, protocolsLoading: true,
error: null, error: null,
instanceId: null, instanceId: undefined,
includeAll: false, roomServer: MatrixClientPeg.getHomeserverName(),
roomServer: null,
filterString: null, filterString: null,
}; };
}, },
@ -98,6 +91,10 @@ export default createReactClass({
}); });
}, },
componentDidMount: function() {
this.refreshRoomList();
},
componentWillUnmount: function() { componentWillUnmount: function() {
if (this.filterTimeout) { if (this.filterTimeout) {
clearTimeout(this.filterTimeout); clearTimeout(this.filterTimeout);
@ -130,10 +127,10 @@ export default createReactClass({
if (my_server != MatrixClientPeg.getHomeserverName()) { if (my_server != MatrixClientPeg.getHomeserverName()) {
opts.server = my_server; opts.server = my_server;
} }
if (this.state.instanceId) { if (this.state.instanceId === ALL_ROOMS) {
opts.third_party_instance_id = this.state.instanceId;
} else if (this.state.includeAll) {
opts.include_all_networks = true; opts.include_all_networks = true;
} else if (this.state.instanceId) {
opts.third_party_instance_id = this.state.instanceId;
} }
if (this.nextBatch) opts.since = this.nextBatch; if (this.nextBatch) opts.since = this.nextBatch;
if (my_filter_string) opts.filter = { generic_search_term: my_filter_string }; if (my_filter_string) opts.filter = { generic_search_term: my_filter_string };
@ -247,7 +244,7 @@ export default createReactClass({
} }
}, },
onOptionChange: function(server, instanceId, includeAll) { onOptionChange: function(server, instanceId) {
// clear next batch so we don't try to load more rooms // clear next batch so we don't try to load more rooms
this.nextBatch = null; this.nextBatch = null;
this.setState({ this.setState({
@ -257,7 +254,6 @@ export default createReactClass({
publicRooms: [], publicRooms: [],
roomServer: server, roomServer: server,
instanceId: instanceId, instanceId: instanceId,
includeAll: includeAll,
error: null, error: null,
}, this.refreshRoomList); }, this.refreshRoomList);
// We also refresh the room list each time even though this // We also refresh the room list each time even though this
@ -305,7 +301,7 @@ export default createReactClass({
onJoinFromSearchClick: function(alias) { onJoinFromSearchClick: function(alias) {
// If we don't have a particular instance id selected, just show that rooms alias // If we don't have a particular instance id selected, just show that rooms alias
if (!this.state.instanceId) { if (!this.state.instanceId || this.state.instanceId === ALL_ROOMS) {
// If the user specified an alias without a domain, add on whichever server is selected // If the user specified an alias without a domain, add on whichever server is selected
// in the dropdown // in the dropdown
if (alias.indexOf(':') == -1) { if (alias.indexOf(':') == -1) {
@ -406,6 +402,12 @@ export default createReactClass({
// would normally decide what the name is. // would normally decide what the name is.
name: room.name || room_alias || _t('Unnamed room'), name: room.name || room_alias || _t('Unnamed room'),
}; };
if (this.state.roomServer) {
payload.opts = {
viaServers: [this.state.roomServer],
};
}
} }
// It's not really possible to join Matrix rooms by ID because the HS has no way to know // It's not really possible to join Matrix rooms by ID because the HS has no way to know
// which servers to start querying. However, there's no other way to join rooms in // which servers to start querying. However, there's no other way to join rooms in
@ -587,7 +589,7 @@ export default createReactClass({
} }
let placeholder = _t('Find a room…'); let placeholder = _t('Find a room…');
if (!this.state.instanceId) { if (!this.state.instanceId || this.state.instanceId === ALL_ROOMS) {
placeholder = _t("Find a room… (e.g. %(exampleRoom)s)", {exampleRoom: "#example:" + this.state.roomServer}); placeholder = _t("Find a room… (e.g. %(exampleRoom)s)", {exampleRoom: "#example:" + this.state.roomServer});
} else if (instance_expected_field_type) { } else if (instance_expected_field_type) {
placeholder = instance_expected_field_type.placeholder; placeholder = instance_expected_field_type.placeholder;
@ -604,10 +606,18 @@ export default createReactClass({
listHeader = <div className="mx_RoomDirectory_listheader"> listHeader = <div className="mx_RoomDirectory_listheader">
<DirectorySearchBox <DirectorySearchBox
className="mx_RoomDirectory_searchbox" className="mx_RoomDirectory_searchbox"
onChange={this.onFilterChange} onClear={this.onFilterClear} onJoinClick={this.onJoinFromSearchClick} onChange={this.onFilterChange}
placeholder={placeholder} showJoinButton={showJoinButton} onClear={this.onFilterClear}
onJoinClick={this.onJoinFromSearchClick}
placeholder={placeholder}
showJoinButton={showJoinButton}
/>
<NetworkDropdown
protocols={this.protocols}
onOptionChange={this.onOptionChange}
selectedServerName={this.state.roomServer}
selectedInstanceId={this.state.instanceId}
/> />
<NetworkDropdown config={this.props.config} protocols={this.protocols} onOptionChange={this.onOptionChange} />
</div>; </div>;
} }
const explanation = const explanation =
@ -628,7 +638,7 @@ export default createReactClass({
title={_t("Explore rooms")} title={_t("Explore rooms")}
> >
<div className="mx_RoomDirectory"> <div className="mx_RoomDirectory">
<p>{explanation}</p> {explanation}
<div className="mx_RoomDirectory_list"> <div className="mx_RoomDirectory_list">
{listHeader} {listHeader}
{content} {content}

View file

@ -46,8 +46,6 @@ export default class RoomSubList extends React.PureComponent {
tagName: PropTypes.string, tagName: PropTypes.string,
addRoomLabel: PropTypes.string, addRoomLabel: PropTypes.string,
order: PropTypes.string.isRequired,
// passed through to RoomTile and used to highlight room with `!` regardless of notifications count // passed through to RoomTile and used to highlight room with `!` regardless of notifications count
isInvite: PropTypes.bool, isInvite: PropTypes.bool,
@ -113,21 +111,30 @@ export default class RoomSubList extends React.PureComponent {
} }
onAction = (payload) => { onAction = (payload) => {
// XXX: Previously RoomList would forceUpdate whenever on_room_read is dispatched, switch (payload.action) {
// but this is no longer true, so we must do it here (and can apply the small case 'on_room_read':
// optimisation of checking that we care about the room being read). // XXX: Previously RoomList would forceUpdate whenever on_room_read is dispatched,
// // but this is no longer true, so we must do it here (and can apply the small
// Ultimately we need to transition to a state pushing flow where something // optimisation of checking that we care about the room being read).
// explicitly notifies the components concerned that the notif count for a room //
// has change (e.g. a Flux store). // Ultimately we need to transition to a state pushing flow where something
if (payload.action === 'on_room_read' && // explicitly notifies the components concerned that the notif count for a room
this.props.list.some((r) => r.roomId === payload.roomId) // has change (e.g. a Flux store).
) { if (this.props.list.some((r) => r.roomId === payload.roomId)) {
this.forceUpdate(); this.forceUpdate();
}
break;
case 'view_room':
if (this.state.hidden && !this.props.forceExpand &&
this.props.list.some((r) => r.roomId === payload.room_id)
) {
this.toggle();
}
} }
}; };
onClick = (ev) => { toggle = () => {
if (this.isCollapsibleOnClick()) { if (this.isCollapsibleOnClick()) {
// The header isCollapsible, so the click is to be interpreted as collapse and truncation logic // The header isCollapsible, so the click is to be interpreted as collapse and truncation logic
const isHidden = !this.state.hidden; const isHidden = !this.state.hidden;
@ -140,6 +147,10 @@ export default class RoomSubList extends React.PureComponent {
} }
}; };
onClick = (ev) => {
this.toggle();
};
onHeaderKeyDown = (ev) => { onHeaderKeyDown = (ev) => {
switch (ev.key) { switch (ev.key) {
case Key.ARROW_LEFT: case Key.ARROW_LEFT:

View file

@ -30,7 +30,6 @@ import classNames from 'classnames';
import { _t } from '../../languageHandler'; import { _t } from '../../languageHandler';
import {RoomPermalinkCreator} from '../../utils/permalinks/Permalinks'; import {RoomPermalinkCreator} from '../../utils/permalinks/Permalinks';
import {MatrixClientPeg} from '../../MatrixClientPeg';
import ContentMessages from '../../ContentMessages'; import ContentMessages from '../../ContentMessages';
import Modal from '../../Modal'; import Modal from '../../Modal';
import * as sdk from '../../index'; import * as sdk from '../../index';
@ -55,6 +54,7 @@ import AccessibleButton from "../views/elements/AccessibleButton";
import RightPanelStore from "../../stores/RightPanelStore"; import RightPanelStore from "../../stores/RightPanelStore";
import {haveTileForEvent} from "../views/rooms/EventTile"; import {haveTileForEvent} from "../views/rooms/EventTile";
import RoomContext from "../../contexts/RoomContext"; import RoomContext from "../../contexts/RoomContext";
import MatrixClientContext from "../../contexts/MatrixClientContext";
const DEBUG = false; const DEBUG = false;
let debuglog = function() {}; let debuglog = function() {};
@ -97,8 +97,12 @@ export default createReactClass({
viaServers: PropTypes.arrayOf(PropTypes.string), viaServers: PropTypes.arrayOf(PropTypes.string),
}, },
statics: {
contextType: MatrixClientContext,
},
getInitialState: function() { getInitialState: function() {
const llMembers = MatrixClientPeg.get().hasLazyLoadMembersEnabled(); const llMembers = this.context.hasLazyLoadMembersEnabled();
return { return {
room: null, room: null,
roomId: null, roomId: null,
@ -131,6 +135,8 @@ export default createReactClass({
isAlone: false, isAlone: false,
isPeeking: false, isPeeking: false,
showingPinned: false, showingPinned: false,
showReadReceipts: true,
showRightPanel: RightPanelStore.getSharedInstance().isOpenForRoom,
// error object, as from the matrix client/server API // error object, as from the matrix client/server API
// If we failed to load information about the room, // If we failed to load information about the room,
@ -163,27 +169,36 @@ export default createReactClass({
componentWillMount: function() { componentWillMount: function() {
this.dispatcherRef = dis.register(this.onAction); this.dispatcherRef = dis.register(this.onAction);
MatrixClientPeg.get().on("Room", this.onRoom); this.context.on("Room", this.onRoom);
MatrixClientPeg.get().on("Room.timeline", this.onRoomTimeline); this.context.on("Room.timeline", this.onRoomTimeline);
MatrixClientPeg.get().on("Room.name", this.onRoomName); this.context.on("Room.name", this.onRoomName);
MatrixClientPeg.get().on("Room.accountData", this.onRoomAccountData); this.context.on("Room.accountData", this.onRoomAccountData);
MatrixClientPeg.get().on("RoomState.events", this.onRoomStateEvents); this.context.on("RoomState.events", this.onRoomStateEvents);
MatrixClientPeg.get().on("RoomState.members", this.onRoomStateMember); this.context.on("RoomState.members", this.onRoomStateMember);
MatrixClientPeg.get().on("Room.myMembership", this.onMyMembership); this.context.on("Room.myMembership", this.onMyMembership);
MatrixClientPeg.get().on("accountData", this.onAccountData); this.context.on("accountData", this.onAccountData);
MatrixClientPeg.get().on("crypto.keyBackupStatus", this.onKeyBackupStatus); this.context.on("crypto.keyBackupStatus", this.onKeyBackupStatus);
MatrixClientPeg.get().on("deviceVerificationChanged", this.onDeviceVerificationChanged); this.context.on("deviceVerificationChanged", this.onDeviceVerificationChanged);
MatrixClientPeg.get().on("userTrustStatusChanged", this.onUserVerificationChanged); this.context.on("userTrustStatusChanged", this.onUserVerificationChanged);
// Start listening for RoomViewStore updates // Start listening for RoomViewStore updates
this._roomStoreToken = RoomViewStore.addListener(this._onRoomViewStoreUpdate); this._roomStoreToken = RoomViewStore.addListener(this._onRoomViewStoreUpdate);
this._rightPanelStoreToken = RightPanelStore.getSharedInstance().addListener(this._onRightPanelStoreUpdate);
this._onRoomViewStoreUpdate(true); this._onRoomViewStoreUpdate(true);
WidgetEchoStore.on('update', this._onWidgetEchoStoreUpdate); WidgetEchoStore.on('update', this._onWidgetEchoStoreUpdate);
this._showReadReceiptsWatchRef = SettingsStore.watchSetting("showReadReceipts", null,
this._onReadReceiptsChange);
this._roomView = createRef(); this._roomView = createRef();
this._searchResultsPanel = createRef(); this._searchResultsPanel = createRef();
}, },
_onReadReceiptsChange: function() {
this.setState({
showReadReceipts: SettingsStore.getValue("showReadReceipts", this.state.roomId),
});
},
_onRoomViewStoreUpdate: function(initial) { _onRoomViewStoreUpdate: function(initial) {
if (this.unmounted) { if (this.unmounted) {
return; return;
@ -204,8 +219,10 @@ export default createReactClass({
return; return;
} }
const roomId = RoomViewStore.getRoomId();
const newState = { const newState = {
roomId: RoomViewStore.getRoomId(), roomId,
roomAlias: RoomViewStore.getRoomAlias(), roomAlias: RoomViewStore.getRoomAlias(),
roomLoading: RoomViewStore.isRoomLoading(), roomLoading: RoomViewStore.isRoomLoading(),
roomLoadError: RoomViewStore.getRoomLoadError(), roomLoadError: RoomViewStore.getRoomLoadError(),
@ -214,7 +231,8 @@ export default createReactClass({
isInitialEventHighlighted: RoomViewStore.isInitialEventHighlighted(), isInitialEventHighlighted: RoomViewStore.isInitialEventHighlighted(),
forwardingEvent: RoomViewStore.getForwardingEvent(), forwardingEvent: RoomViewStore.getForwardingEvent(),
shouldPeek: RoomViewStore.shouldPeek(), shouldPeek: RoomViewStore.shouldPeek(),
showingPinned: SettingsStore.getValue("PinnedEvents.isOpen", RoomViewStore.getRoomId()), showingPinned: SettingsStore.getValue("PinnedEvents.isOpen", roomId),
showReadReceipts: SettingsStore.getValue("showReadReceipts", roomId),
}; };
// Temporary logging to diagnose https://github.com/vector-im/riot-web/issues/4307 // Temporary logging to diagnose https://github.com/vector-im/riot-web/issues/4307
@ -231,7 +249,7 @@ export default createReactClass({
// NB: This does assume that the roomID will not change for the lifetime of // NB: This does assume that the roomID will not change for the lifetime of
// the RoomView instance // the RoomView instance
if (initial) { if (initial) {
newState.room = MatrixClientPeg.get().getRoom(newState.roomId); newState.room = this.context.getRoom(newState.roomId);
if (newState.room) { if (newState.room) {
newState.showApps = this._shouldShowApps(newState.room); newState.showApps = this._shouldShowApps(newState.room);
this._onRoomLoaded(newState.room); this._onRoomLoaded(newState.room);
@ -333,7 +351,7 @@ export default createReactClass({
peekLoading: true, peekLoading: true,
isPeeking: true, // this will change to false if peeking fails isPeeking: true, // this will change to false if peeking fails
}); });
MatrixClientPeg.get().peekInRoom(roomId).then((room) => { this.context.peekInRoom(roomId).then((room) => {
if (this.unmounted) { if (this.unmounted) {
return; return;
} }
@ -342,7 +360,7 @@ export default createReactClass({
peekLoading: false, peekLoading: false,
}); });
this._onRoomLoaded(room); this._onRoomLoaded(room);
}, (err) => { }).catch((err) => {
if (this.unmounted) { if (this.unmounted) {
return; return;
} }
@ -355,7 +373,7 @@ export default createReactClass({
// This won't necessarily be a MatrixError, but we duck-type // This won't necessarily be a MatrixError, but we duck-type
// here and say if it's got an 'errcode' key with the right value, // here and say if it's got an 'errcode' key with the right value,
// it means we can't peek. // it means we can't peek.
if (err.errcode == "M_GUEST_ACCESS_FORBIDDEN") { if (err.errcode === "M_GUEST_ACCESS_FORBIDDEN" || err.errcode === 'M_FORBIDDEN') {
// This is fine: the room just isn't peekable (we assume). // This is fine: the room just isn't peekable (we assume).
this.setState({ this.setState({
peekLoading: false, peekLoading: false,
@ -365,10 +383,8 @@ export default createReactClass({
} }
}); });
} else if (room) { } else if (room) {
//viewing a previously joined room, try to lazy load members
// Stop peeking because we have joined this room previously // Stop peeking because we have joined this room previously
MatrixClientPeg.get().stopPeeking(); this.context.stopPeeking();
this.setState({isPeeking: false}); this.setState({isPeeking: false});
} }
} }
@ -407,21 +423,6 @@ export default createReactClass({
this.onResize(); this.onResize();
document.addEventListener("keydown", this.onKeyDown); document.addEventListener("keydown", this.onKeyDown);
// XXX: EVIL HACK to autofocus inviting on empty rooms.
// We use the setTimeout to avoid racing with focus_composer.
if (this.state.room &&
this.state.room.getJoinedMemberCount() == 1 &&
this.state.room.getLiveTimeline() &&
this.state.room.getLiveTimeline().getEvents() &&
this.state.room.getLiveTimeline().getEvents().length <= 6) {
const inviteBox = document.getElementById("mx_SearchableEntityList_query");
setTimeout(function() {
if (inviteBox) {
inviteBox.focus();
}
}, 50);
}
}, },
shouldComponentUpdate: function(nextProps, nextState) { shouldComponentUpdate: function(nextProps, nextState) {
@ -480,18 +481,18 @@ export default createReactClass({
roomView.removeEventListener('dragend', this.onDragLeaveOrEnd); roomView.removeEventListener('dragend', this.onDragLeaveOrEnd);
} }
dis.unregister(this.dispatcherRef); dis.unregister(this.dispatcherRef);
if (MatrixClientPeg.get()) { if (this.context) {
MatrixClientPeg.get().removeListener("Room", this.onRoom); this.context.removeListener("Room", this.onRoom);
MatrixClientPeg.get().removeListener("Room.timeline", this.onRoomTimeline); this.context.removeListener("Room.timeline", this.onRoomTimeline);
MatrixClientPeg.get().removeListener("Room.name", this.onRoomName); this.context.removeListener("Room.name", this.onRoomName);
MatrixClientPeg.get().removeListener("Room.accountData", this.onRoomAccountData); this.context.removeListener("Room.accountData", this.onRoomAccountData);
MatrixClientPeg.get().removeListener("RoomState.events", this.onRoomStateEvents); this.context.removeListener("RoomState.events", this.onRoomStateEvents);
MatrixClientPeg.get().removeListener("Room.myMembership", this.onMyMembership); this.context.removeListener("Room.myMembership", this.onMyMembership);
MatrixClientPeg.get().removeListener("RoomState.members", this.onRoomStateMember); this.context.removeListener("RoomState.members", this.onRoomStateMember);
MatrixClientPeg.get().removeListener("accountData", this.onAccountData); this.context.removeListener("accountData", this.onAccountData);
MatrixClientPeg.get().removeListener("crypto.keyBackupStatus", this.onKeyBackupStatus); this.context.removeListener("crypto.keyBackupStatus", this.onKeyBackupStatus);
MatrixClientPeg.get().removeListener("deviceVerificationChanged", this.onDeviceVerificationChanged); this.context.removeListener("deviceVerificationChanged", this.onDeviceVerificationChanged);
MatrixClientPeg.get().removeListener("userTrustStatusChanged", this.onUserVerificationChanged); this.context.removeListener("userTrustStatusChanged", this.onUserVerificationChanged);
} }
window.removeEventListener('beforeunload', this.onPageUnload); window.removeEventListener('beforeunload', this.onPageUnload);
@ -505,9 +506,18 @@ export default createReactClass({
if (this._roomStoreToken) { if (this._roomStoreToken) {
this._roomStoreToken.remove(); this._roomStoreToken.remove();
} }
// Remove RightPanelStore listener
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;
}
// cancel any pending calls to the rate_limited_funcs // cancel any pending calls to the rate_limited_funcs
this._updateRoomMembers.cancelPendingCall(); this._updateRoomMembers.cancelPendingCall();
@ -516,6 +526,12 @@ export default createReactClass({
// Tinter.tint(); // reset colourscheme // Tinter.tint(); // reset colourscheme
}, },
_onRightPanelStoreUpdate: function() {
this.setState({
showRightPanel: RightPanelStore.getSharedInstance().isOpenForRoom,
});
},
onPageUnload(event) { onPageUnload(event) {
if (ContentMessages.sharedInstance().getCurrentUploads().length > 0) { if (ContentMessages.sharedInstance().getCurrentUploads().length > 0) {
return event.returnValue = return event.returnValue =
@ -526,7 +542,6 @@ export default createReactClass({
} }
}, },
onKeyDown: function(ev) { onKeyDown: function(ev) {
let handled = false; let handled = false;
const ctrlCmdOnly = isOnlyCtrlOrCmdKeyEvent(ev); const ctrlCmdOnly = isOnlyCtrlOrCmdKeyEvent(ev);
@ -555,10 +570,6 @@ export default createReactClass({
onAction: function(payload) { onAction: function(payload) {
switch (payload.action) { switch (payload.action) {
case 'after_right_panel_phase_change':
// We don't keep state on the right panel, so just re-render to update
this.forceUpdate();
break;
case 'message_send_failed': case 'message_send_failed':
case 'message_sent': case 'message_sent':
this._checkIfAlone(this.state.room); this._checkIfAlone(this.state.room);
@ -570,9 +581,7 @@ export default createReactClass({
payload.data.description || payload.data.name); payload.data.description || payload.data.name);
break; break;
case 'picture_snapshot': case 'picture_snapshot':
ContentMessages.sharedInstance().sendContentListToRoom( ContentMessages.sharedInstance().sendContentListToRoom([payload.file], this.state.room.roomId, this.context);
[payload.file], this.state.room.roomId, MatrixClientPeg.get(),
);
break; break;
case 'notifier_enabled': case 'notifier_enabled':
case 'upload_started': case 'upload_started':
@ -616,6 +625,22 @@ export default createReactClass({
this.onCancelSearchClick(); this.onCancelSearchClick();
} }
break; break;
case 'quote':
if (this.state.searchResults) {
const roomId = payload.event.getRoomId();
if (roomId === this.state.roomId) {
this.onCancelSearchClick();
}
setImmediate(() => {
dis.dispatch({
action: 'view_room',
room_id: roomId,
deferred_action: payload,
});
});
}
break;
} }
}, },
@ -645,7 +670,7 @@ export default createReactClass({
// we'll only be showing a spinner. // we'll only be showing a spinner.
if (this.state.joining) return; if (this.state.joining) return;
if (ev.getSender() !== MatrixClientPeg.get().credentials.userId) { if (ev.getSender() !== this.context.credentials.userId) {
// update unread count when scrolled up // update unread count when scrolled up
if (!this.state.searchResults && this.state.atEndOfLiveTimeline) { if (!this.state.searchResults && this.state.atEndOfLiveTimeline) {
// no change // no change
@ -701,8 +726,7 @@ export default createReactClass({
_loadMembersIfJoined: async function(room) { _loadMembersIfJoined: async function(room) {
// lazy load members if enabled // lazy load members if enabled
const cli = MatrixClientPeg.get(); if (this.context.hasLazyLoadMembersEnabled()) {
if (cli.hasLazyLoadMembersEnabled()) {
if (room && room.getMyMembership() === 'join') { if (room && room.getMyMembership() === 'join') {
try { try {
await room.loadMembersIfNeeded(); await room.loadMembersIfNeeded();
@ -737,7 +761,7 @@ export default createReactClass({
_updatePreviewUrlVisibility: function({roomId}) { _updatePreviewUrlVisibility: function({roomId}) {
// URL Previews in E2EE rooms can be a privacy leak so use a different setting which is per-room explicit // URL Previews in E2EE rooms can be a privacy leak so use a different setting which is per-room explicit
const key = MatrixClientPeg.get().isRoomEncrypted(roomId) ? 'urlPreviewsEnabled_e2ee' : 'urlPreviewsEnabled'; const key = this.context.isRoomEncrypted(roomId) ? 'urlPreviewsEnabled_e2ee' : 'urlPreviewsEnabled';
this.setState({ this.setState({
showUrlPreview: SettingsStore.getValue(key, roomId), showUrlPreview: SettingsStore.getValue(key, roomId),
}); });
@ -771,11 +795,10 @@ export default createReactClass({
}, },
_updateE2EStatus: async function(room) { _updateE2EStatus: async function(room) {
const cli = MatrixClientPeg.get(); if (!this.context.isRoomEncrypted(room.roomId)) {
if (!cli.isRoomEncrypted(room.roomId)) {
return; return;
} }
if (!cli.isCryptoEnabled()) { if (!this.context.isCryptoEnabled()) {
// If crypto is not currently enabled, we aren't tracking devices at all, // If crypto is not currently enabled, we aren't tracking devices at all,
// so we don't know what the answer is. Let's error on the safe side and show // so we don't know what the answer is. Let's error on the safe side and show
// a warning for this case. // a warning for this case.
@ -800,21 +823,21 @@ export default createReactClass({
const verified = []; const verified = [];
const unverified = []; const unverified = [];
e2eMembers.map(({userId}) => userId) e2eMembers.map(({userId}) => userId)
.filter((userId) => userId !== cli.getUserId()) .filter((userId) => userId !== this.context.getUserId())
.forEach((userId) => { .forEach((userId) => {
(cli.checkUserTrust(userId).isCrossSigningVerified() ? (this.context.checkUserTrust(userId).isCrossSigningVerified() ?
verified : unverified).push(userId) verified : unverified).push(userId);
}); });
debuglog("e2e verified", verified, "unverified", unverified); debuglog("e2e verified", verified, "unverified", unverified);
/* Check all verified user devices. */ /* Check all verified user devices. */
/* Don't alarm if no other users are verified */ /* Don't alarm if no other users are verified */
const targets = (verified.length > 0) ? [...verified, cli.getUserId()] : verified; const targets = (verified.length > 0) ? [...verified, this.context.getUserId()] : verified;
for (const userId of targets) { for (const userId of targets) {
const devices = await cli.getStoredDevicesForUser(userId); const devices = await this.context.getStoredDevicesForUser(userId);
const anyDeviceNotVerified = devices.some(({deviceId}) => { const anyDeviceNotVerified = devices.some(({deviceId}) => {
return !cli.checkDeviceTrust(userId, deviceId).isVerified(); return !this.context.checkDeviceTrust(userId, deviceId).isVerified();
}); });
if (anyDeviceNotVerified) { if (anyDeviceNotVerified) {
this.setState({ this.setState({
@ -896,7 +919,7 @@ export default createReactClass({
_updatePermissions: function(room) { _updatePermissions: function(room) {
if (room) { if (room) {
const me = MatrixClientPeg.get().getUserId(); const me = this.context.getUserId();
const canReact = room.getMyMembership() === "join" && room.currentState.maySendEvent("m.reaction", me); const canReact = room.getMyMembership() === "join" && room.currentState.maySendEvent("m.reaction", me);
const canReply = room.maySendMessage(); const canReply = room.maySendMessage();
@ -980,7 +1003,7 @@ export default createReactClass({
if (this.state.searchResults.next_batch) { if (this.state.searchResults.next_batch) {
debuglog("requesting more search results"); debuglog("requesting more search results");
const searchPromise = MatrixClientPeg.get().backPaginateRoomEventsSearch( const searchPromise = this.context.backPaginateRoomEventsSearch(
this.state.searchResults); this.state.searchResults);
return this._handleSearchResult(searchPromise); return this._handleSearchResult(searchPromise);
} else { } else {
@ -1006,10 +1029,8 @@ export default createReactClass({
}, },
onJoinButtonClicked: function(ev) { onJoinButtonClicked: function(ev) {
const cli = MatrixClientPeg.get();
// If the user is a ROU, allow them to transition to a PWLU // If the user is a ROU, allow them to transition to a PWLU
if (cli && cli.isGuest()) { if (this.context && this.context.isGuest()) {
// Join this room once the user has registered and logged in // Join this room once the user has registered and logged in
// (If we failed to peek, we may not have a valid room object.) // (If we failed to peek, we may not have a valid room object.)
dis.dispatch({ dis.dispatch({
@ -1106,7 +1127,7 @@ export default createReactClass({
ev.stopPropagation(); ev.stopPropagation();
ev.preventDefault(); ev.preventDefault();
ContentMessages.sharedInstance().sendContentListToRoom( ContentMessages.sharedInstance().sendContentListToRoom(
ev.dataTransfer.files, this.state.room.roomId, MatrixClientPeg.get(), ev.dataTransfer.files, this.state.room.roomId, this.context,
); );
this.setState({ draggingFile: false }); this.setState({ draggingFile: false });
dis.dispatch({action: 'focus_composer'}); dis.dispatch({action: 'focus_composer'});
@ -1119,12 +1140,12 @@ export default createReactClass({
}, },
injectSticker: function(url, info, text) { injectSticker: function(url, info, text) {
if (MatrixClientPeg.get().isGuest()) { if (this.context.isGuest()) {
dis.dispatch({action: 'require_registration'}); dis.dispatch({action: 'require_registration'});
return; return;
} }
ContentMessages.sharedInstance().sendStickerContentToRoom(url, this.state.room.roomId, info, text, MatrixClientPeg.get()) ContentMessages.sharedInstance().sendStickerContentToRoom(url, this.state.room.roomId, info, text, this.context)
.then(undefined, (error) => { .then(undefined, (error) => {
if (error.name === "UnknownDeviceError") { if (error.name === "UnknownDeviceError") {
// Let the staus bar handle this // Let the staus bar handle this
@ -1215,12 +1236,9 @@ export default createReactClass({
}, },
getSearchResultTiles: function() { getSearchResultTiles: function() {
const EventTile = sdk.getComponent('rooms.EventTile');
const SearchResultTile = sdk.getComponent('rooms.SearchResultTile'); const SearchResultTile = sdk.getComponent('rooms.SearchResultTile');
const Spinner = sdk.getComponent("elements.Spinner"); const Spinner = sdk.getComponent("elements.Spinner");
const cli = MatrixClientPeg.get();
// XXX: todo: merge overlapping results somehow? // XXX: todo: merge overlapping results somehow?
// XXX: why doesn't searching on name work? // XXX: why doesn't searching on name work?
@ -1228,21 +1246,21 @@ export default createReactClass({
if (this.state.searchInProgress) { if (this.state.searchInProgress) {
ret.push(<li key="search-spinner"> ret.push(<li key="search-spinner">
<Spinner /> <Spinner />
</li>); </li>);
} }
if (!this.state.searchResults.next_batch) { if (!this.state.searchResults.next_batch) {
if (this.state.searchResults.results.length == 0) { if (this.state.searchResults.results.length == 0) {
ret.push(<li key="search-top-marker"> ret.push(<li key="search-top-marker">
<h2 className="mx_RoomView_topMarker">{ _t("No results") }</h2> <h2 className="mx_RoomView_topMarker">{ _t("No results") }</h2>
</li>, </li>,
); );
} else { } else {
ret.push(<li key="search-top-marker"> ret.push(<li key="search-top-marker">
<h2 className="mx_RoomView_topMarker">{ _t("No more results") }</h2> <h2 className="mx_RoomView_topMarker">{ _t("No more results") }</h2>
</li>, </li>,
); );
} }
} }
@ -1262,7 +1280,7 @@ export default createReactClass({
const mxEv = result.context.getEvent(); const mxEv = result.context.getEvent();
const roomId = mxEv.getRoomId(); const roomId = mxEv.getRoomId();
const room = cli.getRoom(roomId); const room = this.context.getRoom(roomId);
if (!haveTileForEvent(mxEv)) { if (!haveTileForEvent(mxEv)) {
// XXX: can this ever happen? It will make the result count // XXX: can this ever happen? It will make the result count
@ -1329,7 +1347,7 @@ export default createReactClass({
}, },
onForgetClick: function() { onForgetClick: function() {
MatrixClientPeg.get().forget(this.state.room.roomId).then(function() { this.context.forget(this.state.room.roomId).then(function() {
dis.dispatch({ action: 'view_next_room' }); dis.dispatch({ action: 'view_next_room' });
}, function(err) { }, function(err) {
const errCode = err.errcode || _t("unknown error code"); const errCode = err.errcode || _t("unknown error code");
@ -1346,7 +1364,7 @@ export default createReactClass({
this.setState({ this.setState({
rejecting: true, rejecting: true,
}); });
MatrixClientPeg.get().leave(this.state.roomId).then(function() { this.context.leave(this.state.roomId).then(function() {
dis.dispatch({ action: 'view_next_room' }); dis.dispatch({ action: 'view_next_room' });
self.setState({ self.setState({
rejecting: false, rejecting: false,
@ -1373,15 +1391,14 @@ export default createReactClass({
rejecting: true, rejecting: true,
}); });
const cli = MatrixClientPeg.get();
try { try {
const myMember = this.state.room.getMember(cli.getUserId()); const myMember = this.state.room.getMember(this.context.getUserId());
const inviteEvent = myMember.events.member; const inviteEvent = myMember.events.member;
const ignoredUsers = MatrixClientPeg.get().getIgnoredUsers(); const ignoredUsers = this.context.getIgnoredUsers();
ignoredUsers.push(inviteEvent.getSender()); // de-duped internally in the js-sdk ignoredUsers.push(inviteEvent.getSender()); // de-duped internally in the js-sdk
await cli.setIgnoredUsers(ignoredUsers); await this.context.setIgnoredUsers(ignoredUsers);
await cli.leave(this.state.roomId); await this.context.leave(this.state.roomId);
dis.dispatch({ action: 'view_next_room' }); dis.dispatch({ action: 'view_next_room' });
this.setState({ this.setState({
rejecting: false, rejecting: false,
@ -1600,7 +1617,7 @@ export default createReactClass({
const createEvent = this.state.room.currentState.getStateEvents("m.room.create", ""); const createEvent = this.state.room.currentState.getStateEvents("m.room.create", "");
if (!createEvent || !createEvent.getContent()['predecessor']) return null; if (!createEvent || !createEvent.getContent()['predecessor']) return null;
return MatrixClientPeg.get().getRoom(createEvent.getContent()['predecessor']['room_id']); return this.context.getRoom(createEvent.getContent()['predecessor']['room_id']);
}, },
_getHiddenHighlightCount: function() { _getHiddenHighlightCount: function() {
@ -1694,7 +1711,7 @@ export default createReactClass({
</ErrorBoundary> </ErrorBoundary>
); );
} else { } else {
const myUserId = MatrixClientPeg.get().credentials.userId; const myUserId = this.context.credentials.userId;
const myMember = this.state.room.getMember(myUserId); const myMember = this.state.room.getMember(myUserId);
const inviteEvent = myMember.events.member; const inviteEvent = myMember.events.member;
var inviterName = inviteEvent.sender ? inviteEvent.sender.name : inviteEvent.getSender(); var inviterName = inviteEvent.sender ? inviteEvent.sender.name : inviteEvent.getSender();
@ -1761,13 +1778,13 @@ export default createReactClass({
const showRoomUpgradeBar = ( const showRoomUpgradeBar = (
roomVersionRecommendation && roomVersionRecommendation &&
roomVersionRecommendation.needsUpgrade && roomVersionRecommendation.needsUpgrade &&
this.state.room.userMayUpgradeRoom(MatrixClientPeg.get().credentials.userId) this.state.room.userMayUpgradeRoom(this.context.credentials.userId)
); );
const showRoomRecoveryReminder = ( const showRoomRecoveryReminder = (
SettingsStore.getValue("showRoomRecoveryReminder") && SettingsStore.getValue("showRoomRecoveryReminder") &&
MatrixClientPeg.get().isRoomEncrypted(this.state.room.roomId) && this.context.isRoomEncrypted(this.state.room.roomId) &&
!MatrixClientPeg.get().getKeyBackupEnabled() !this.context.getKeyBackupEnabled()
); );
const hiddenHighlightCount = this._getHiddenHighlightCount(); const hiddenHighlightCount = this._getHiddenHighlightCount();
@ -1838,7 +1855,7 @@ export default createReactClass({
const auxPanel = ( const auxPanel = (
<AuxPanel room={this.state.room} <AuxPanel room={this.state.room}
fullHeight={false} fullHeight={false}
userId={MatrixClientPeg.get().credentials.userId} userId={this.context.credentials.userId}
conferenceHandler={this.props.ConferenceHandler} conferenceHandler={this.props.ConferenceHandler}
draggingFile={this.state.draggingFile} draggingFile={this.state.draggingFile}
displayConfCallNotification={this.state.displayConfCallNotification} displayConfCallNotification={this.state.displayConfCallNotification}
@ -1949,7 +1966,7 @@ export default createReactClass({
<TimelinePanel <TimelinePanel
ref={this._gatherTimelinePanelRef} ref={this._gatherTimelinePanelRef}
timelineSet={this.state.room.getUnfilteredTimelineSet()} timelineSet={this.state.room.getUnfilteredTimelineSet()}
showReadReceipts={SettingsStore.getValue('showReadReceipts')} showReadReceipts={this.state.showReadReceipts}
manageReadReceipts={!this.state.isPeeking} manageReadReceipts={!this.state.isPeeking}
manageReadMarkers={!this.state.isPeeking} manageReadMarkers={!this.state.isPeeking}
hidden={hideMessagePanel} hidden={hideMessagePanel}
@ -1998,12 +2015,15 @@ export default createReactClass({
}, },
); );
const showRightPanel = !forceHideRightPanel && this.state.room const showRightPanel = !forceHideRightPanel && this.state.room && this.state.showRightPanel;
&& RightPanelStore.getSharedInstance().isOpenForRoom;
const rightPanel = showRightPanel const rightPanel = showRightPanel
? <RightPanel roomId={this.state.room.roomId} resizeNotifier={this.props.resizeNotifier} /> ? <RightPanel roomId={this.state.room.roomId} resizeNotifier={this.props.resizeNotifier} />
: null; : null;
const timelineClasses = classNames("mx_RoomView_timeline", {
mx_RoomView_timeline_rr_enabled: this.state.showReadReceipts,
});
return ( return (
<RoomContext.Provider value={this.state}> <RoomContext.Provider value={this.state}>
<main className={"mx_RoomView" + (inCall ? " mx_RoomView_inCall" : "")} ref={this._roomView}> <main className={"mx_RoomView" + (inCall ? " mx_RoomView_inCall" : "")} ref={this._roomView}>
@ -2027,7 +2047,7 @@ export default createReactClass({
> >
<div className={fadableSectionClasses}> <div className={fadableSectionClasses}>
{auxPanel} {auxPanel}
<div className="mx_RoomView_timeline"> <div className={timelineClasses}>
{topUnreadMessagesBar} {topUnreadMessagesBar}
{jumpToBottom} {jumpToBottom}
{messagePanel} {messagePanel}

View file

@ -523,7 +523,7 @@ export default createReactClass({
scrollRelative: function(mult) { scrollRelative: function(mult) {
const scrollNode = this._getScrollNode(); const scrollNode = this._getScrollNode();
const delta = mult * scrollNode.clientHeight * 0.5; const delta = mult * scrollNode.clientHeight * 0.5;
scrollNode.scrollTop = scrollNode.scrollTop + delta; scrollNode.scrollBy(0, delta);
this._saveScrollState(); this._saveScrollState();
}, },
@ -705,17 +705,15 @@ export default createReactClass({
// the currently filled piece of the timeline // the currently filled piece of the timeline
if (trackedNode) { if (trackedNode) {
const oldTop = trackedNode.offsetTop; const oldTop = trackedNode.offsetTop;
// changing the height might change the scrollTop
// if the new height is smaller than the scrollTop.
// We calculate the diff that needs to be applied
// ourselves, so be sure to measure the
// scrollTop before changing the height.
const preexistingScrollTop = sn.scrollTop;
itemlist.style.height = `${newHeight}px`; itemlist.style.height = `${newHeight}px`;
const newTop = trackedNode.offsetTop; const newTop = trackedNode.offsetTop;
const topDiff = newTop - oldTop; const topDiff = newTop - oldTop;
sn.scrollTop = preexistingScrollTop + topDiff; // important to scroll by a relative amount as
debuglog("updateHeight to", {newHeight, topDiff, preexistingScrollTop}); // reading scrollTop and then setting it might
// yield out of date values and cause a jump
// when setting it
sn.scrollBy(0, topDiff);
debuglog("updateHeight to", {newHeight, topDiff});
} }
} }
}, },
@ -767,6 +765,7 @@ export default createReactClass({
}, },
_topFromBottom(node) { _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; return this._itemlist.current.clientHeight - node.offsetTop;
}, },
@ -783,7 +782,7 @@ export default createReactClass({
if (!this._divScroll) { if (!this._divScroll) {
// Likewise, we should have the ref by this point, but if not // Likewise, we should have the ref by this point, but if not
// turn the NPE into something meaningful. // turn the NPE into something meaningful.
throw new Error("ScrollPanel._getScrollNode called before gemini ref collected"); throw new Error("ScrollPanel._getScrollNode called before AutoHideScrollbar ref collected");
} }
return this._divScroll; return this._divScroll;

View file

@ -1,7 +1,7 @@
/* /*
Copyright 2017 Travis Ralston Copyright 2017 Travis Ralston
Copyright 2019 New Vector Ltd Copyright 2019 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"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -18,41 +18,54 @@ limitations under the License.
import * as React from "react"; import * as React from "react";
import {_t} from '../../languageHandler'; import {_t} from '../../languageHandler';
import PropTypes from "prop-types"; import * as PropTypes from "prop-types";
import * as sdk from "../../index"; import * as sdk from "../../index";
import { ReactNode } from "react";
/** /**
* Represents a tab for the TabbedView. * Represents a tab for the TabbedView.
*/ */
export class Tab { export class Tab {
public label: string;
public icon: string;
public body: React.ReactNode;
/** /**
* Creates a new tab. * Creates a new tab.
* @param {string} tabLabel The untranslated tab label. * @param {string} tabLabel The untranslated tab label.
* @param {string} tabIconClass The class for the tab icon. This should be a simple mask. * @param {string} tabIconClass The class for the tab icon. This should be a simple mask.
* @param {string} tabJsx The JSX for the tab container. * @param {React.ReactNode} tabJsx The JSX for the tab container.
*/ */
constructor(tabLabel, tabIconClass, tabJsx) { constructor(tabLabel: string, tabIconClass: string, tabJsx: React.ReactNode) {
this.label = tabLabel; this.label = tabLabel;
this.icon = tabIconClass; this.icon = tabIconClass;
this.body = tabJsx; this.body = tabJsx;
} }
} }
export default class TabbedView extends React.Component { interface IProps {
tabs: Tab[];
}
interface IState {
activeTabIndex: number;
}
export default class TabbedView extends React.Component<IProps, IState> {
static propTypes = { static propTypes = {
// The tabs to show // The tabs to show
tabs: PropTypes.arrayOf(PropTypes.instanceOf(Tab)).isRequired, tabs: PropTypes.arrayOf(PropTypes.instanceOf(Tab)).isRequired,
}; };
constructor() { constructor(props: IProps) {
super(); super(props);
this.state = { this.state = {
activeTabIndex: 0, activeTabIndex: 0,
}; };
} }
_getActiveTabIndex() { private _getActiveTabIndex() {
if (!this.state || !this.state.activeTabIndex) return 0; if (!this.state || !this.state.activeTabIndex) return 0;
return this.state.activeTabIndex; return this.state.activeTabIndex;
} }
@ -62,7 +75,7 @@ export default class TabbedView extends React.Component {
* @param {Tab} tab the tab to show * @param {Tab} tab the tab to show
* @private * @private
*/ */
_setActiveTab(tab) { private _setActiveTab(tab: Tab) {
const idx = this.props.tabs.indexOf(tab); const idx = this.props.tabs.indexOf(tab);
if (idx !== -1) { if (idx !== -1) {
this.setState({activeTabIndex: idx}); this.setState({activeTabIndex: idx});
@ -71,7 +84,7 @@ export default class TabbedView extends React.Component {
} }
} }
_renderTabLabel(tab) { private _renderTabLabel(tab: Tab) {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
let classes = "mx_TabbedView_tabLabel "; let classes = "mx_TabbedView_tabLabel ";
@ -97,7 +110,7 @@ export default class TabbedView extends React.Component {
); );
} }
_renderTabPanel(tab) { private _renderTabPanel(tab: Tab): React.ReactNode {
return ( return (
<div className="mx_TabbedView_tabPanel" key={"mx_tabpanel_" + tab.label}> <div className="mx_TabbedView_tabPanel" key={"mx_tabpanel_" + tab.label}>
<div className='mx_TabbedView_tabPanelContent'> <div className='mx_TabbedView_tabPanelContent'>
@ -107,7 +120,7 @@ export default class TabbedView extends React.Component {
); );
} }
render() { public render(): React.ReactNode {
const labels = this.props.tabs.map(tab => this._renderTabLabel(tab)); const labels = this.props.tabs.map(tab => this._renderTabLabel(tab));
const panel = this._renderTabPanel(this.props.tabs[this._getActiveTabIndex()]); const panel = this._renderTabPanel(this.props.tabs[this._getActiveTabIndex()]);

View file

@ -28,6 +28,7 @@ import { _t } from '../../languageHandler';
import { Droppable } from 'react-beautiful-dnd'; import { Droppable } from 'react-beautiful-dnd';
import classNames from 'classnames'; import classNames from 'classnames';
import MatrixClientContext from "../../contexts/MatrixClientContext"; import MatrixClientContext from "../../contexts/MatrixClientContext";
import AutoHideScrollbar from "./AutoHideScrollbar";
const TagPanel = createReactClass({ const TagPanel = createReactClass({
displayName: 'TagPanel', displayName: 'TagPanel',
@ -106,7 +107,6 @@ const TagPanel = createReactClass({
const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
const ActionButton = sdk.getComponent('elements.ActionButton'); const ActionButton = sdk.getComponent('elements.ActionButton');
const TintableSvg = sdk.getComponent('elements.TintableSvg'); const TintableSvg = sdk.getComponent('elements.TintableSvg');
const GeminiScrollbarWrapper = sdk.getComponent("elements.GeminiScrollbarWrapper");
const tags = this.state.orderedTags.map((tag, index) => { const tags = this.state.orderedTags.map((tag, index) => {
return <DNDTagTile return <DNDTagTile
@ -138,9 +138,8 @@ const TagPanel = createReactClass({
{ clearButton } { clearButton }
</div> </div>
<div className="mx_TagPanel_divider" /> <div className="mx_TagPanel_divider" />
<GeminiScrollbarWrapper <AutoHideScrollbar
className="mx_TagPanel_scroller" className="mx_TagPanel_scroller"
autoshow={true}
// XXX: Use onMouseDown as a workaround for https://github.com/atlassian/react-beautiful-dnd/issues/273 // XXX: Use onMouseDown as a workaround for https://github.com/atlassian/react-beautiful-dnd/issues/273
// instead of onClick. Otherwise we experience https://github.com/vector-im/riot-web/issues/6253 // instead of onClick. Otherwise we experience https://github.com/vector-im/riot-web/issues/6253
onMouseDown={this.onMouseDown} onMouseDown={this.onMouseDown}
@ -166,7 +165,7 @@ const TagPanel = createReactClass({
</div> </div>
) } ) }
</Droppable> </Droppable>
</GeminiScrollbarWrapper> </AutoHideScrollbar>
</div>; </div>;
}, },
}); });

View file

@ -18,13 +18,14 @@ import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import * as sdk from '../../../index'; import * as sdk from '../../../index';
import { MatrixClientPeg } from '../../../MatrixClientPeg'; import {
import { accessSecretStorage, AccessCancelledError } from '../../../CrossSigningManager'; SetupEncryptionStore,
PHASE_INTRO,
const PHASE_INTRO = 0; PHASE_BUSY,
const PHASE_BUSY = 1; PHASE_DONE,
const PHASE_DONE = 2; PHASE_CONFIRM_SKIP,
const PHASE_CONFIRM_SKIP = 3; } from '../../../stores/SetupEncryptionStore';
import SetupEncryptionBody from "./SetupEncryptionBody";
export default class CompleteSecurity extends React.Component { export default class CompleteSecurity extends React.Component {
static propTypes = { static propTypes = {
@ -33,202 +34,42 @@ export default class CompleteSecurity extends React.Component {
constructor() { constructor() {
super(); super();
const store = SetupEncryptionStore.sharedInstance();
this.state = { store.on("update", this._onStoreUpdate);
phase: PHASE_INTRO, store.start();
// this serves dual purpose as the object for the request logic and this.state = {phase: store.phase};
// the presence of it insidicating that we're in 'verify mode'.
// Because of the latter, it lives in the state.
verificationRequest: null,
backupInfo: null,
};
MatrixClientPeg.get().on("crypto.verification.request", this.onVerificationRequest);
} }
_onStoreUpdate = () => {
const store = SetupEncryptionStore.sharedInstance();
this.setState({phase: store.phase});
};
componentWillUnmount() { componentWillUnmount() {
if (this.state.verificationRequest) { const store = SetupEncryptionStore.sharedInstance();
this.state.verificationRequest.off("change", this.onVerificationRequestChange); store.off("update", this._onStoreUpdate);
} store.stop();
if (MatrixClientPeg.get()) {
MatrixClientPeg.get().removeListener("crypto.verification.request", this.onVerificationRequest);
}
}
onStartClick = async () => {
this.setState({
phase: PHASE_BUSY,
});
const cli = MatrixClientPeg.get();
const backupInfo = await cli.getKeyBackupVersion();
this.setState({backupInfo});
try {
await accessSecretStorage(async () => {
await cli.checkOwnCrossSigningTrust();
if (backupInfo) await cli.restoreKeyBackupWithSecretStorage(backupInfo);
});
if (cli.getCrossSigningId()) {
this.setState({
phase: PHASE_DONE,
});
}
} catch (e) {
if (!(e instanceof AccessCancelledError)) {
console.log(e);
}
// this will throw if the user hits cancel, so ignore
this.setState({
phase: PHASE_INTRO,
});
}
}
onVerificationRequest = async (request) => {
if (request.otherUserId !== MatrixClientPeg.get().getUserId()) return;
if (this.state.verificationRequest) {
this.state.verificationRequest.off("change", this.onVerificationRequestChange);
}
await request.accept();
request.on("change", this.onVerificationRequestChange);
this.setState({
verificationRequest: request,
});
}
onVerificationRequestChange = () => {
if (this.state.verificationRequest.cancelled) {
this.state.verificationRequest.off("change", this.onVerificationRequestChange);
this.setState({
verificationRequest: null,
});
}
}
onSkipClick = () => {
this.setState({
phase: PHASE_CONFIRM_SKIP,
});
}
onSkipConfirmClick = () => {
this.props.onFinished();
}
onSkipBackClick = () => {
this.setState({
phase: PHASE_INTRO,
});
}
onDoneClick = () => {
this.props.onFinished();
} }
render() { render() {
const AuthPage = sdk.getComponent("auth.AuthPage"); const AuthPage = sdk.getComponent("auth.AuthPage");
const CompleteSecurityBody = sdk.getComponent("auth.CompleteSecurityBody"); const CompleteSecurityBody = sdk.getComponent("auth.CompleteSecurityBody");
const AccessibleButton = sdk.getComponent("elements.AccessibleButton"); const {phase} = this.state;
const {
phase,
} = this.state;
let icon; let icon;
let title; let title;
let body;
if (this.state.verificationRequest) { if (phase === PHASE_INTRO) {
const EncryptionPanel = sdk.getComponent("views.right_panel.EncryptionPanel");
body = <EncryptionPanel
layout="dialog"
verificationRequest={this.state.verificationRequest}
onClose={this.props.onFinished}
member={MatrixClientPeg.get().getUser(this.state.verificationRequest.otherUserId)}
/>;
} else if (phase === PHASE_INTRO) {
icon = <span className="mx_CompleteSecurity_headerIcon mx_E2EIcon_warning"></span>; icon = <span className="mx_CompleteSecurity_headerIcon mx_E2EIcon_warning"></span>;
title = _t("Complete security"); title = _t("Complete security");
body = (
<div>
<p>{_t(
"Verify this session to grant it access to encrypted messages.",
)}</p>
<div className="mx_CompleteSecurity_actionRow">
<AccessibleButton
kind="danger"
onClick={this.onSkipClick}
>
{_t("Skip")}
</AccessibleButton>
<AccessibleButton
kind="primary"
onClick={this.onStartClick}
>
{_t("Start")}
</AccessibleButton>
</div>
</div>
);
} else if (phase === PHASE_DONE) { } else if (phase === PHASE_DONE) {
icon = <span className="mx_CompleteSecurity_headerIcon mx_E2EIcon_verified"></span>; icon = <span className="mx_CompleteSecurity_headerIcon mx_E2EIcon_verified"></span>;
title = _t("Session verified"); title = _t("Session verified");
let message;
if (this.state.backupInfo) {
message = <p>{_t(
"Your new session is now verified. It has access to your " +
"encrypted messages, and other users will see it as trusted.",
)}</p>;
} else {
message = <p>{_t(
"Your new session is now verified. Other users will see it as trusted.",
)}</p>;
}
body = (
<div>
<div className="mx_CompleteSecurity_heroIcon mx_E2EIcon_verified"></div>
{message}
<div className="mx_CompleteSecurity_actionRow">
<AccessibleButton
kind="primary"
onClick={this.onDoneClick}
>
{_t("Done")}
</AccessibleButton>
</div>
</div>
);
} else if (phase === PHASE_CONFIRM_SKIP) { } else if (phase === PHASE_CONFIRM_SKIP) {
icon = <span className="mx_CompleteSecurity_headerIcon mx_E2EIcon_warning"></span>; icon = <span className="mx_CompleteSecurity_headerIcon mx_E2EIcon_warning"></span>;
title = _t("Are you sure?"); title = _t("Are you sure?");
body = (
<div>
<p>{_t(
"Without completing security on this session, it wont have " +
"access to encrypted messages.",
)}</p>
<div className="mx_CompleteSecurity_actionRow">
<AccessibleButton
className="warning"
kind="secondary"
onClick={this.onSkipConfirmClick}
>
{_t("Skip")}
</AccessibleButton>
<AccessibleButton
kind="danger"
onClick={this.onSkipBackClick}
>
{_t("Go Back")}
</AccessibleButton>
</div>
</div>
);
} else if (phase === PHASE_BUSY) { } else if (phase === PHASE_BUSY) {
const Spinner = sdk.getComponent('views.elements.Spinner');
icon = <span className="mx_CompleteSecurity_headerIcon mx_E2EIcon_warning"></span>; icon = <span className="mx_CompleteSecurity_headerIcon mx_E2EIcon_warning"></span>;
title = _t("Complete security"); title = _t("Complete security");
body = <Spinner />;
} else { } else {
throw new Error(`Unknown phase ${phase}`); throw new Error(`Unknown phase ${phase}`);
} }
@ -241,7 +82,7 @@ export default class CompleteSecurity extends React.Component {
{title} {title}
</h2> </h2>
<div className="mx_CompleteSecurity_body"> <div className="mx_CompleteSecurity_body">
{body} <SetupEncryptionBody onFinished={this.props.onFinished} />
</div> </div>
</CompleteSecurityBody> </CompleteSecurityBody>
</AuthPage> </AuthPage>

View file

@ -27,6 +27,8 @@ import { messageForResourceLimitError } from '../../../utils/ErrorUtils';
import AutoDiscoveryUtils, {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils"; import AutoDiscoveryUtils, {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
import classNames from "classnames"; import classNames from "classnames";
import AuthPage from "../../views/auth/AuthPage"; import AuthPage from "../../views/auth/AuthPage";
import SSOButton from "../../views/elements/SSOButton";
import PlatformPeg from '../../../PlatformPeg';
// For validating phone numbers without country codes // For validating phone numbers without country codes
const PHONE_NUMBER_REGEX = /^[0-9()\-\s]*$/; const PHONE_NUMBER_REGEX = /^[0-9()\-\s]*$/;
@ -120,8 +122,8 @@ export default createReactClass({
'm.login.password': this._renderPasswordStep, 'm.login.password': this._renderPasswordStep,
// CAS and SSO are the same thing, modulo the url we link to // CAS and SSO are the same thing, modulo the url we link to
'm.login.cas': () => this._renderSsoStep(this._loginLogic.getSsoLoginUrl("cas")), 'm.login.cas': () => this._renderSsoStep("cas"),
'm.login.sso': () => this._renderSsoStep(this._loginLogic.getSsoLoginUrl("sso")), 'm.login.sso': () => this._renderSsoStep("sso"),
}; };
this._initLoginLogic(); this._initLoginLogic();
@ -245,6 +247,7 @@ export default createReactClass({
} }
this.setState({ this.setState({
busy: false,
errorText: errorText, errorText: errorText,
// 401 would be the sensible status code for 'incorrect password' // 401 would be the sensible status code for 'incorrect password'
// but the login API gives a 403 https://matrix.org/jira/browse/SYN-744 // but the login API gives a 403 https://matrix.org/jira/browse/SYN-744
@ -252,13 +255,6 @@ export default createReactClass({
// We treat both as an incorrect password // We treat both as an incorrect password
loginIncorrect: error.httpStatus === 401 || error.httpStatus === 403, loginIncorrect: error.httpStatus === 401 || error.httpStatus === 403,
}); });
}).finally(() => {
if (this._unmounted) {
return;
}
this.setState({
busy: false,
});
}); });
}, },
@ -344,6 +340,21 @@ export default createReactClass({
this.props.onRegisterClick(); this.props.onRegisterClick();
}, },
onTryRegisterClick: function(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,
// so intercept the click and instead pretend the user clicked 'Sign in with SSO'.
ev.preventDefault();
ev.stopPropagation();
const ssoKind = step === 'm.login.sso' ? 'sso' : 'cas';
PlatformPeg.get().startSingleSignOn(this._loginLogic.createTemporaryClient(), ssoKind);
} else {
// Don't intercept - just go through to the register page
this.onRegisterClick(ev);
}
},
async onServerDetailsNextPhaseClick() { async onServerDetailsNextPhaseClick() {
this.setState({ this.setState({
phase: PHASE_LOGIN, phase: PHASE_LOGIN,
@ -585,7 +596,7 @@ export default createReactClass({
); );
}, },
_renderSsoStep: function(url) { _renderSsoStep: function(loginType) {
const SignInToText = sdk.getComponent('views.auth.SignInToText'); const SignInToText = sdk.getComponent('views.auth.SignInToText');
let onEditServerDetailsClick = null; let onEditServerDetailsClick = null;
@ -606,7 +617,10 @@ export default createReactClass({
<SignInToText serverConfig={this.props.serverConfig} <SignInToText serverConfig={this.props.serverConfig}
onEditServerDetailsClick={onEditServerDetailsClick} /> onEditServerDetailsClick={onEditServerDetailsClick} />
<a href={url} className="mx_Login_sso_link mx_Login_submit">{ _t('Sign in with single sign-on') }</a> <SSOButton
className="mx_Login_sso_link mx_Login_submit"
matrixClient={this._loginLogic.createTemporaryClient()}
loginType={loginType} />
</div> </div>
); );
}, },
@ -654,7 +668,7 @@ export default createReactClass({
{ serverDeadSection } { serverDeadSection }
{ this.renderServerComponent() } { this.renderServerComponent() }
{ this.renderLoginComponentForStep() } { this.renderLoginComponentForStep() }
<a className="mx_AuthBody_changeFlow" onClick={this.onRegisterClick} href="#"> <a className="mx_AuthBody_changeFlow" onClick={this.onTryRegisterClick} href="#">
{ _t('Create account') } { _t('Create account') }
</a> </a>
</AuthBody> </AuthBody>

View file

@ -31,6 +31,8 @@ import classNames from "classnames";
import * as Lifecycle from '../../../Lifecycle'; import * as Lifecycle from '../../../Lifecycle';
import {MatrixClientPeg} from "../../../MatrixClientPeg"; import {MatrixClientPeg} from "../../../MatrixClientPeg";
import AuthPage from "../../views/auth/AuthPage"; import AuthPage from "../../views/auth/AuthPage";
import Login from "../../../Login";
import dis from "../../../dispatcher";
// Phases // Phases
// Show controls to configure server details // Show controls to configure server details
@ -232,6 +234,13 @@ export default createReactClass({
serverRequiresIdServer, serverRequiresIdServer,
busy: false, busy: false,
}); });
const showGenericError = (e) => {
this.setState({
errorText: _t("Unable to query for supported registration methods."),
// add empty flows array to get rid of spinner
flows: [],
});
};
try { try {
await this._makeRegisterRequest({}); await this._makeRegisterRequest({});
// This should never succeed since we specified an empty // This should never succeed since we specified an empty
@ -243,18 +252,32 @@ export default createReactClass({
flows: e.data.flows, flows: e.data.flows,
}); });
} else if (e.httpStatus === 403 && e.errcode === "M_UNKNOWN") { } else if (e.httpStatus === 403 && e.errcode === "M_UNKNOWN") {
this.setState({ // At this point registration is pretty much disabled, but before we do that let's
errorText: _t("Registration has been disabled on this homeserver."), // quickly check to see if the server supports SSO instead. If it does, we'll send
// add empty flows array to get rid of spinner // the user off to the login page to figure their account out.
flows: [], try {
}); const loginLogic = new Login(hsUrl, isUrl, null, {
defaultDeviceDisplayName: "riot login check", // We shouldn't ever be used
});
const flows = await loginLogic.getFlows();
const hasSsoFlow = flows.find(f => f.type === 'm.login.sso' || f.type === 'm.login.cas');
if (hasSsoFlow) {
// Redirect to login page - server probably expects SSO only
dis.dispatch({action: 'start_login'});
} else {
this.setState({
errorText: _t("Registration has been disabled on this homeserver."),
// add empty flows array to get rid of spinner
flows: [],
});
}
} catch (e) {
console.error("Failed to get login flows to check for SSO support", e);
showGenericError(e);
}
} else { } else {
console.log("Unable to query for supported registration methods.", e); console.log("Unable to query for supported registration methods.", e);
this.setState({ showGenericError(e);
errorText: _t("Unable to query for supported registration methods."),
// add empty flows array to get rid of spinner
flows: [],
});
} }
} }
}, },

View file

@ -0,0 +1,196 @@
/*
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 from 'react';
import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler';
import { MatrixClientPeg } from '../../../MatrixClientPeg';
import * as sdk from '../../../index';
import {
SetupEncryptionStore,
PHASE_INTRO,
PHASE_BUSY,
PHASE_DONE,
PHASE_CONFIRM_SKIP,
PHASE_FINISHED,
} from '../../../stores/SetupEncryptionStore';
export default class SetupEncryptionBody extends React.Component {
static propTypes = {
onFinished: PropTypes.func.isRequired,
};
constructor() {
super();
const store = SetupEncryptionStore.sharedInstance();
store.on("update", this._onStoreUpdate);
store.start();
this.state = {
phase: store.phase,
// this serves dual purpose as the object for the request logic and
// the presence of it indicating that we're in 'verify mode'.
// Because of the latter, it lives in the state.
verificationRequest: store.verificationRequest,
backupInfo: store.backupInfo,
};
}
_onStoreUpdate = () => {
const store = SetupEncryptionStore.sharedInstance();
if (store.phase === PHASE_FINISHED) {
this.props.onFinished();
return;
}
this.setState({
phase: store.phase,
verificationRequest: store.verificationRequest,
backupInfo: store.backupInfo,
});
};
componentWillUnmount() {
const store = SetupEncryptionStore.sharedInstance();
store.off("update", this._onStoreUpdate);
store.stop();
}
_onUsePassphraseClick = async () => {
const store = SetupEncryptionStore.sharedInstance();
store.usePassPhrase();
}
onSkipClick = () => {
const store = SetupEncryptionStore.sharedInstance();
store.skip();
}
onSkipConfirmClick = () => {
const store = SetupEncryptionStore.sharedInstance();
store.skipConfirm();
}
onSkipBackClick = () => {
const store = SetupEncryptionStore.sharedInstance();
store.returnAfterSkip();
}
onDoneClick = () => {
const store = SetupEncryptionStore.sharedInstance();
store.done();
}
render() {
const AccessibleButton = sdk.getComponent("elements.AccessibleButton");
const {
phase,
} = this.state;
if (this.state.verificationRequest) {
const EncryptionPanel = sdk.getComponent("views.right_panel.EncryptionPanel");
return <EncryptionPanel
layout="dialog"
verificationRequest={this.state.verificationRequest}
onClose={this.props.onFinished}
member={MatrixClientPeg.get().getUser(this.state.verificationRequest.otherUserId)}
/>;
} else if (phase === PHASE_INTRO) {
const InlineSpinner = sdk.getComponent('elements.InlineSpinner');
return (
<div>
<p>{_t(
"Open an existing session & use it to verify this one, " +
"granting it access to encrypted messages.",
)}</p>
<p className="mx_CompleteSecurity_waiting"><InlineSpinner />{_t("Waiting…")}</p>
<p>{_t(
"If you cant access one, <button>use your recovery key or passphrase.</button>",
{}, {
button: sub => <AccessibleButton element="span"
className="mx_linkButton"
onClick={this._onUsePassphraseClick}
>
{sub}
</AccessibleButton>,
})}</p>
<div className="mx_CompleteSecurity_actionRow">
<AccessibleButton
kind="danger"
onClick={this.onSkipClick}
>
{_t("Skip")}
</AccessibleButton>
</div>
</div>
);
} else if (phase === PHASE_DONE) {
let message;
if (this.state.backupInfo) {
message = <p>{_t(
"Your new session is now verified. It has access to your " +
"encrypted messages, and other users will see it as trusted.",
)}</p>;
} else {
message = <p>{_t(
"Your new session is now verified. Other users will see it as trusted.",
)}</p>;
}
return (
<div>
<div className="mx_CompleteSecurity_heroIcon mx_E2EIcon_verified"></div>
{message}
<div className="mx_CompleteSecurity_actionRow">
<AccessibleButton
kind="primary"
onClick={this.onDoneClick}
>
{_t("Done")}
</AccessibleButton>
</div>
</div>
);
} else if (phase === PHASE_CONFIRM_SKIP) {
return (
<div>
<p>{_t(
"Without completing security on this session, it wont have " +
"access to encrypted messages.",
)}</p>
<div className="mx_CompleteSecurity_actionRow">
<AccessibleButton
className="warning"
kind="secondary"
onClick={this.onSkipConfirmClick}
>
{_t("Skip")}
</AccessibleButton>
<AccessibleButton
kind="danger"
onClick={this.onSkipBackClick}
>
{_t("Go Back")}
</AccessibleButton>
</div>
</div>
);
} else if (phase === PHASE_BUSY) {
const Spinner = sdk.getComponent('views.elements.Spinner');
return <Spinner />;
} else {
console.log(`SetupEncryptionBody: Unknown phase ${phase}`);
}
}
}

View file

@ -23,8 +23,8 @@ import * as Lifecycle from '../../../Lifecycle';
import Modal from '../../../Modal'; import Modal from '../../../Modal';
import {MatrixClientPeg} from "../../../MatrixClientPeg"; import {MatrixClientPeg} from "../../../MatrixClientPeg";
import {sendLoginRequest} from "../../../Login"; import {sendLoginRequest} from "../../../Login";
import url from 'url';
import AuthPage from "../../views/auth/AuthPage"; import AuthPage from "../../views/auth/AuthPage";
import SSOButton from "../../views/elements/SSOButton";
const LOGIN_VIEW = { const LOGIN_VIEW = {
LOADING: 1, LOADING: 1,
@ -55,7 +55,6 @@ export default class SoftLogout extends React.Component {
this.state = { this.state = {
loginView: LOGIN_VIEW.LOADING, loginView: LOGIN_VIEW.LOADING,
keyBackupNeeded: true, // assume we do while we figure it out (see componentWillMount) keyBackupNeeded: true, // assume we do while we figure it out (see componentWillMount)
ssoUrl: null,
busy: false, busy: false,
password: "", password: "",
@ -105,18 +104,6 @@ export default class SoftLogout extends React.Component {
const chosenView = loginViews.filter(f => !!f)[0] || LOGIN_VIEW.UNSUPPORTED; const chosenView = loginViews.filter(f => !!f)[0] || LOGIN_VIEW.UNSUPPORTED;
this.setState({loginView: chosenView}); this.setState({loginView: chosenView});
if (chosenView === LOGIN_VIEW.CAS || chosenView === LOGIN_VIEW.SSO) {
const client = MatrixClientPeg.get();
const appUrl = url.parse(window.location.href, true);
appUrl.hash = ""; // Clear #/soft_logout off the URL
appUrl.query["homeserver"] = client.getHomeserverUrl();
appUrl.query["identityServer"] = client.getIdentityServerUrl();
const ssoUrl = client.getSsoLoginUrl(url.format(appUrl), chosenView === LOGIN_VIEW.CAS ? "cas" : "sso");
this.setState({ssoUrl});
}
} }
onPasswordChange = (ev) => { onPasswordChange = (ev) => {
@ -195,14 +182,6 @@ export default class SoftLogout extends React.Component {
}); });
} }
onSsoLogin = async (ev) => {
ev.preventDefault();
ev.stopPropagation();
this.setState({busy: true});
window.location.href = this.state.ssoUrl;
};
_renderSignInSection() { _renderSignInSection() {
if (this.state.loginView === LOGIN_VIEW.LOADING) { if (this.state.loginView === LOGIN_VIEW.LOADING) {
const Spinner = sdk.getComponent("elements.Spinner"); const Spinner = sdk.getComponent("elements.Spinner");
@ -257,8 +236,6 @@ export default class SoftLogout extends React.Component {
} }
if (this.state.loginView === LOGIN_VIEW.SSO || this.state.loginView === LOGIN_VIEW.CAS) { if (this.state.loginView === LOGIN_VIEW.SSO || this.state.loginView === LOGIN_VIEW.CAS) {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
if (!introText) { if (!introText) {
introText = _t("Sign in and regain access to your account."); introText = _t("Sign in and regain access to your account.");
} // else we already have a message and should use it (key backup warning) } // else we already have a message and should use it (key backup warning)
@ -266,9 +243,9 @@ export default class SoftLogout extends React.Component {
return ( return (
<div> <div>
<p>{introText}</p> <p>{introText}</p>
<AccessibleButton kind='primary' onClick={this.onSsoLogin}> <SSOButton
{_t('Sign in with single sign-on')} matrixClient={MatrixClientPeg.get()}
</AccessibleButton> loginType={this.state.loginView === LOGIN_VIEW.CAS ? "cas" : "sso"} />
</div> </div>
); );
} }

View file

@ -90,7 +90,8 @@ export default createReactClass({
const room = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId()); const room = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId());
const pinnedEvent = room.currentState.getStateEvents('m.room.pinned_events', ''); const pinnedEvent = room.currentState.getStateEvents('m.room.pinned_events', '');
if (!pinnedEvent) return false; if (!pinnedEvent) return false;
return pinnedEvent.getContent().pinned.includes(this.props.mxEvent.getId()); const content = pinnedEvent.getContent();
return content.pinned && Array.isArray(content.pinned) && content.pinned.includes(this.props.mxEvent.getId());
}, },
onResendClick: function() { onResendClick: function() {

View file

@ -72,7 +72,7 @@ export default createReactClass({
<button onClick={this._onInviteNeverWarnClicked}> <button onClick={this._onInviteNeverWarnClicked}>
{ _t('Invite anyway and never warn me again') } { _t('Invite anyway and never warn me again') }
</button> </button>
<button onClick={this._onInviteClicked} autoFocus="true"> <button onClick={this._onInviteClicked} autoFocus={true}>
{ _t('Invite anyway') } { _t('Invite anyway') }
</button> </button>
</div> </div>

View file

@ -32,6 +32,7 @@ export default createReactClass({
button: PropTypes.string, button: PropTypes.string,
onFinished: PropTypes.func, onFinished: PropTypes.func,
hasCloseButton: PropTypes.bool, hasCloseButton: PropTypes.bool,
onKeyDown: PropTypes.func,
}, },
getDefaultProps: function() { getDefaultProps: function() {
@ -50,10 +51,13 @@ export default createReactClass({
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
return ( return (
<BaseDialog className="mx_InfoDialog" onFinished={this.props.onFinished} <BaseDialog
className="mx_InfoDialog"
onFinished={this.props.onFinished}
title={this.props.title} title={this.props.title}
contentId='mx_Dialog_content' contentId='mx_Dialog_content'
hasCancel={this.props.hasCloseButton} hasCancel={this.props.hasCloseButton}
onKeyDown={this.props.onKeyDown}
> >
<div className={classNames("mx_Dialog_content", this.props.className)} id="mx_Dialog_content"> <div className={classNames("mx_Dialog_content", this.props.className)} id="mx_Dialog_content">
{ this.props.description } { this.props.description }

View file

@ -34,6 +34,8 @@ import {humanizeTime} from "../../../utils/humanize";
import createRoom, {canEncryptToAllUsers} from "../../../createRoom"; import createRoom, {canEncryptToAllUsers} from "../../../createRoom";
import {inviteMultipleToRoom} from "../../../RoomInvite"; import {inviteMultipleToRoom} from "../../../RoomInvite";
import SettingsStore from '../../../settings/SettingsStore'; import SettingsStore from '../../../settings/SettingsStore';
import RoomListStore, {TAG_DM} from "../../../stores/RoomListStore";
import {Key} from "../../../Keyboard";
export const KIND_DM = "dm"; export const KIND_DM = "dm";
export const KIND_INVITE = "invite"; export const KIND_INVITE = "invite";
@ -124,7 +126,7 @@ class ThreepidMember extends Member {
class DMUserTile extends React.PureComponent { class DMUserTile extends React.PureComponent {
static propTypes = { static propTypes = {
member: PropTypes.object.isRequired, // Should be a Member (see interface above) member: PropTypes.object.isRequired, // Should be a Member (see interface above)
onRemove: PropTypes.func.isRequired, // takes 1 argument, the member being removed onRemove: PropTypes.func, // takes 1 argument, the member being removed
}; };
_onRemove = (e) => { _onRemove = (e) => {
@ -155,18 +157,25 @@ class DMUserTile extends React.PureComponent {
width={avatarSize} width={avatarSize}
height={avatarSize} />; height={avatarSize} />;
return ( let closeButton;
<span className='mx_InviteDialog_userTile'> if (this.props.onRemove) {
<span className='mx_InviteDialog_userTile_pill'> closeButton = (
{avatar}
<span className='mx_InviteDialog_userTile_name'>{this.props.member.name}</span>
</span>
<AccessibleButton <AccessibleButton
className='mx_InviteDialog_userTile_remove' className='mx_InviteDialog_userTile_remove'
onClick={this._onRemove} onClick={this._onRemove}
> >
<img src={require("../../../../res/img/icon-pill-remove.svg")} alt={_t('Remove')} width={8} height={8} /> <img src={require("../../../../res/img/icon-pill-remove.svg")} alt={_t('Remove')} width={8} height={8} />
</AccessibleButton> </AccessibleButton>
);
}
return (
<span className='mx_InviteDialog_userTile'>
<span className='mx_InviteDialog_userTile_pill'>
{avatar}
<span className='mx_InviteDialog_userTile_name'>{this.props.member.name}</span>
</span>
{ closeButton }
</span> </span>
); );
} }
@ -218,7 +227,7 @@ class DMRoomTile extends React.PureComponent {
} }
// Push any text we missed (end of text) // Push any text we missed (end of text)
if (i < (str.length - 1)) { if (i < str.length) {
result.push(<span key={i + 'end'}>{str.substring(i)}</span>); result.push(<span key={i + 'end'}>{str.substring(i)}</span>);
} }
@ -332,7 +341,23 @@ export default class InviteDialog extends React.PureComponent {
} }
_buildRecents(excludedTargetIds: Set<string>): {userId: string, user: RoomMember, lastActive: number} { _buildRecents(excludedTargetIds: Set<string>): {userId: string, user: RoomMember, lastActive: number} {
const rooms = DMRoomMap.shared().getUniqueRoomsWithIndividuals(); const rooms = DMRoomMap.shared().getUniqueRoomsWithIndividuals(); // map of userId => js-sdk Room
// Also pull in all the rooms tagged as TAG_DM so we don't miss anything. Sometimes the
// room list doesn't tag the room for the DMRoomMap, but does for the room list.
const taggedRooms = RoomListStore.getRoomLists();
const dmTaggedRooms = taggedRooms[TAG_DM];
const myUserId = MatrixClientPeg.get().getUserId();
for (const dmRoom of dmTaggedRooms) {
const otherMembers = dmRoom.getJoinedMembers().filter(u => u.userId !== myUserId);
for (const member of otherMembers) {
if (rooms[member.userId]) continue; // already have a room
console.warn(`Adding DM room for ${member.userId} as ${dmRoom.roomId} from tag, not DM map`);
rooms[member.userId] = dmRoom;
}
}
const recents = []; const recents = [];
for (const userId in rooms) { for (const userId in rooms) {
// Filter out user IDs that are already in the room / should be excluded // Filter out user IDs that are already in the room / should be excluded
@ -547,7 +572,7 @@ export default class InviteDialog extends React.PureComponent {
return; return;
} }
const createRoomOptions = {}; const createRoomOptions = {inlineErrors: true};
if (SettingsStore.isFeatureEnabled("feature_cross_signing")) { if (SettingsStore.isFeatureEnabled("feature_cross_signing")) {
// Check whether all users have uploaded device keys before. // Check whether all users have uploaded device keys before.
@ -623,11 +648,14 @@ export default class InviteDialog extends React.PureComponent {
}); });
}; };
_cancel = () => { _onKeyDown = (e) => {
// We do not want the user to close the dialog while an action is in progress // when the field is empty and the user hits backspace remove the right-most target
if (this.state.busy) return; if (!e.target.value && !this.state.busy && this.state.targets.length > 0 && e.key === Key.BACKSPACE &&
!e.ctrlKey && !e.shiftKey && !e.metaKey
this.props.onFinished(); ) {
e.preventDefault();
this._removeMember(this.state.targets[this.state.targets.length - 1]);
}
}; };
_updateFilter = (e) => { _updateFilter = (e) => {
@ -872,7 +900,7 @@ export default class InviteDialog extends React.PureComponent {
_onManageSettingsClick = (e) => { _onManageSettingsClick = (e) => {
e.preventDefault(); e.preventDefault();
dis.dispatch({ action: 'view_user_settings' }); dis.dispatch({ action: 'view_user_settings' });
this._cancel(); this.props.onFinished();
}; };
_renderSection(kind: "recents"|"suggestions") { _renderSection(kind: "recents"|"suggestions") {
@ -889,24 +917,24 @@ export default class InviteDialog extends React.PureComponent {
// Mix in the server results if we have any, but only if we're searching. We track the additional // Mix in the server results if we have any, but only if we're searching. We track the additional
// members separately because we want to filter sourceMembers but trust the mixin arrays to have // members separately because we want to filter sourceMembers but trust the mixin arrays to have
// the right members in them. // the right members in them.
let additionalMembers = []; let priorityAdditionalMembers = []; // Shows up before our own suggestions, higher quality
let otherAdditionalMembers = []; // Shows up after our own suggestions, lower quality
const hasMixins = this.state.serverResultsMixin || this.state.threepidResultsMixin; const hasMixins = this.state.serverResultsMixin || this.state.threepidResultsMixin;
if (this.state.filterText && hasMixins && kind === 'suggestions') { if (this.state.filterText && hasMixins && kind === 'suggestions') {
// We don't want to duplicate members though, so just exclude anyone we've already seen. // We don't want to duplicate members though, so just exclude anyone we've already seen.
const notAlreadyExists = (u: Member): boolean => { const notAlreadyExists = (u: Member): boolean => {
return !sourceMembers.some(m => m.userId === u.userId) return !sourceMembers.some(m => m.userId === u.userId)
&& !additionalMembers.some(m => m.userId === u.userId); && !priorityAdditionalMembers.some(m => m.userId === u.userId)
&& !otherAdditionalMembers.some(m => m.userId === u.userId);
}; };
const uniqueServerResults = this.state.serverResultsMixin.filter(notAlreadyExists); otherAdditionalMembers = this.state.serverResultsMixin.filter(notAlreadyExists);
additionalMembers = additionalMembers.concat(...uniqueServerResults); priorityAdditionalMembers = this.state.threepidResultsMixin.filter(notAlreadyExists);
const uniqueThreepidResults = this.state.threepidResultsMixin.filter(notAlreadyExists);
additionalMembers = additionalMembers.concat(...uniqueThreepidResults);
} }
const hasAdditionalMembers = priorityAdditionalMembers.length > 0 || otherAdditionalMembers.length > 0;
// Hide the section if there's nothing to filter by // Hide the section if there's nothing to filter by
if (sourceMembers.length === 0 && additionalMembers.length === 0) return null; if (sourceMembers.length === 0 && !hasAdditionalMembers) return null;
// Do some simple filtering on the input before going much further. If we get no results, say so. // Do some simple filtering on the input before going much further. If we get no results, say so.
if (this.state.filterText) { if (this.state.filterText) {
@ -914,7 +942,7 @@ export default class InviteDialog extends React.PureComponent {
sourceMembers = sourceMembers sourceMembers = sourceMembers
.filter(m => m.user.name.toLowerCase().includes(filterBy) || m.userId.toLowerCase().includes(filterBy)); .filter(m => m.user.name.toLowerCase().includes(filterBy) || m.userId.toLowerCase().includes(filterBy));
if (sourceMembers.length === 0 && additionalMembers.length === 0) { if (sourceMembers.length === 0 && !hasAdditionalMembers) {
return ( return (
<div className='mx_InviteDialog_section'> <div className='mx_InviteDialog_section'>
<h3>{sectionName}</h3> <h3>{sectionName}</h3>
@ -926,7 +954,7 @@ export default class InviteDialog extends React.PureComponent {
// Now we mix in the additional members. Again, we presume these have already been filtered. We // Now we mix in the additional members. Again, we presume these have already been filtered. We
// also assume they are more relevant than our suggestions and prepend them to the list. // also assume they are more relevant than our suggestions and prepend them to the list.
sourceMembers = [...additionalMembers, ...sourceMembers]; sourceMembers = [...priorityAdditionalMembers, ...sourceMembers, ...otherAdditionalMembers];
// If we're going to hide one member behind 'show more', just use up the space of the button // If we're going to hide one member behind 'show more', just use up the space of the button
// with the member's tile instead. // with the member's tile instead.
@ -967,17 +995,18 @@ export default class InviteDialog extends React.PureComponent {
_renderEditor() { _renderEditor() {
const targets = this.state.targets.map(t => ( const targets = this.state.targets.map(t => (
<DMUserTile member={t} onRemove={this._removeMember} key={t.userId} /> <DMUserTile member={t} onRemove={!this.state.busy && this._removeMember} key={t.userId} />
)); ));
const input = ( const input = (
<textarea <textarea
key={"input"}
rows={1} rows={1}
onKeyDown={this._onKeyDown}
onChange={this._updateFilter} onChange={this._updateFilter}
value={this.state.filterText} value={this.state.filterText}
ref={this._editorRef} ref={this._editorRef}
onPaste={this._onPaste} onPaste={this._onPaste}
autoFocus={true} autoFocus={true}
disabled={this.state.busy}
/> />
); );
return ( return (
@ -1043,10 +1072,11 @@ export default class InviteDialog extends React.PureComponent {
title = _t("Direct Messages"); title = _t("Direct Messages");
helpText = _t( helpText = _t(
"If you can't find someone, ask them for their username, share your " + "Start a conversation with someone using their name, username (like <userId/>) or email address.",
"username (%(userId)s) or <a>profile link</a>.", {},
{userId}, {userId: () => {
{a: (sub) => <a href={makeUserPermalink(userId)} rel="noreferrer noopener" target="_blank">{sub}</a>}, return <a href={makeUserPermalink(userId)} rel="noreferrer noopener" target="_blank">{userId}</a>;
}},
); );
buttonText = _t("Go"); buttonText = _t("Go");
goButtonFn = this._startDm; goButtonFn = this._startDm;
@ -1070,7 +1100,7 @@ export default class InviteDialog extends React.PureComponent {
<BaseDialog <BaseDialog
className='mx_InviteDialog' className='mx_InviteDialog'
hasCancel={true} hasCancel={true}
onFinished={this._cancel} onFinished={this.props.onFinished}
title={title} title={title}
> >
<div className='mx_InviteDialog_content'> <div className='mx_InviteDialog_content'>

View file

@ -0,0 +1,108 @@
/*
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, {useState, useCallback, useRef} from 'react';
import * as sdk from '../../../index';
import { _t } from '../../../languageHandler';
export default function KeySignatureUploadFailedDialog({
failures,
source,
continuation,
onFinished,
}) {
const RETRIES = 2;
const BaseDialog = sdk.getComponent('dialogs.BaseDialog');
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
const Spinner = sdk.getComponent('elements.Spinner');
const [retry, setRetry] = useState(RETRIES);
const [cancelled, setCancelled] = useState(false);
const [retrying, setRetrying] = useState(false);
const [success, setSuccess] = useState(false);
const onCancel = useRef(onFinished);
const causes = new Map([
["_afterCrossSigningLocalKeyChange", _t("a new master key signature")],
["checkOwnCrossSigningTrust", _t("a new cross-signing key signature")],
["setDeviceVerification", _t("a device cross-signing signature")],
]);
const defaultCause = _t("a key signature");
const onRetry = useCallback(async () => {
try {
setRetrying(true);
const cancel = new Promise((resolve, reject) => {
onCancel.current = reject;
}).finally(() => {
setCancelled(true);
});
await Promise.race([
continuation(),
cancel,
]);
setSuccess(true);
} catch (e) {
setRetry(r => r-1);
} finally {
onCancel.current = onFinished;
setRetrying(false);
}
}, [continuation, onFinished]);
let body;
if (!success && !cancelled && continuation && retry > 0) {
const reason = causes.get(source) || defaultCause;
body = (<div>
<p>{_t("Riot encountered an error during upload of:")}</p>
<p>{reason}</p>
{retrying && <Spinner />}
<pre>{JSON.stringify(failures, null, 2)}</pre>
<DialogButtons
primaryButton='Retry'
hasCancel={true}
onPrimaryButtonClick={onRetry}
onCancel={onCancel.current}
primaryDisabled={retrying}
/>
</div>);
} else {
body = (<div>
{success ?
<span>{_t("Upload completed")}</span> :
cancelled ?
<span>{_t("Cancelled signature upload")}</span> :
<span>{_t("Unabled to upload")}</span>}
<DialogButtons
primaryButton={_t("OK")}
hasCancel={false}
onPrimaryButtonClick={onFinished}
/>
</div>);
}
return (
<BaseDialog
title={success ?
_t("Signature upload success") :
_t("Signature upload failed")}
fixedWidth={false}
onFinished={() => {}}
>
{body}
</BaseDialog>
);
}

View file

@ -21,7 +21,6 @@ import * as sdk from '../../../index';
import dis from '../../../dispatcher'; import dis from '../../../dispatcher';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import {MatrixClientPeg} from '../../../MatrixClientPeg'; import {MatrixClientPeg} from '../../../MatrixClientPeg';
import SettingsStore from "../../../settings/SettingsStore";
export default class LogoutDialog extends React.Component { export default class LogoutDialog extends React.Component {
defaultProps = { defaultProps = {
@ -36,8 +35,8 @@ export default class LogoutDialog extends React.Component {
this._onSetRecoveryMethodClick = this._onSetRecoveryMethodClick.bind(this); this._onSetRecoveryMethodClick = this._onSetRecoveryMethodClick.bind(this);
this._onLogoutConfirm = this._onLogoutConfirm.bind(this); this._onLogoutConfirm = this._onLogoutConfirm.bind(this);
const lowBandwidth = SettingsStore.getValue("lowBandwidth"); const cli = MatrixClientPeg.get();
const shouldLoadBackupStatus = !lowBandwidth && !MatrixClientPeg.get().getKeyBackupEnabled(); const shouldLoadBackupStatus = cli.isCryptoEnabled() && !cli.getKeyBackupEnabled();
this.state = { this.state = {
shouldLoadBackupStatus: shouldLoadBackupStatus, shouldLoadBackupStatus: shouldLoadBackupStatus,

View file

@ -0,0 +1,86 @@
/*
Copyright 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2019 New Vector Ltd
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
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 * as sdk from '../../../index';
import * as FormattingUtils from '../../../utils/FormattingUtils';
import { _t } from '../../../languageHandler';
export default class ManualDeviceKeyVerificationDialog extends React.Component {
static propTypes = {
userId: PropTypes.string.isRequired,
device: PropTypes.object.isRequired,
onFinished: PropTypes.func.isRequired,
};
_onCancelClick = () => {
this.props.onFinished(false);
}
_onLegacyFinished = (confirm) => {
if (confirm) {
MatrixClientPeg.get().setDeviceVerified(
this.props.userId, this.props.device.deviceId, true,
);
}
this.props.onFinished(confirm);
}
render() {
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
let text;
if (MatrixClientPeg.get().getUserId() === this.props.userId) {
text = _t("Confirm by comparing the following with the User Settings in your other session:");
} else {
text = _t("Confirm this user's session by comparing the following with their User Settings:");
}
const key = FormattingUtils.formatCryptoKey(this.props.device.getFingerprint());
const body = (
<div>
<p>
{ text }
</p>
<div className="mx_DeviceVerifyDialog_cryptoSection">
<ul>
<li><label>{ _t("Session name") }:</label> <span>{ this.props.device.getDisplayName() }</span></li>
<li><label>{ _t("Session ID") }:</label> <span><code>{ this.props.device.deviceId }</code></span></li>
<li><label>{ _t("Session key") }:</label> <span><code><b>{ key }</b></code></span></li>
</ul>
</div>
<p>
{ _t("If they don't match, the security of your communication may be compromised.") }
</p>
</div>
);
return (
<QuestionDialog
title={_t("Verify session")}
description={body}
button={_t("Verify session")}
onFinished={this._onLegacyFinished}
/>
);
}
}

View file

@ -23,6 +23,7 @@ import VerificationRequestDialog from './VerificationRequestDialog';
import BaseDialog from './BaseDialog'; import BaseDialog from './BaseDialog';
import DialogButtons from '../elements/DialogButtons'; import DialogButtons from '../elements/DialogButtons';
import {MatrixClientPeg} from "../../../MatrixClientPeg"; import {MatrixClientPeg} from "../../../MatrixClientPeg";
import * as sdk from '../../../index';
@replaceableComponent("views.dialogs.NewSessionReviewDialog") @replaceableComponent("views.dialogs.NewSessionReviewDialog")
export default class NewSessionReviewDialog extends React.PureComponent { export default class NewSessionReviewDialog extends React.PureComponent {
@ -33,20 +34,38 @@ export default class NewSessionReviewDialog extends React.PureComponent {
} }
onCancelClick = () => { onCancelClick = () => {
this.props.onFinished(false); const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog("Verification failed", "insecure", ErrorDialog, {
headerImage: require("../../../../res/img/e2e/warning.svg"),
title: _t("Your account is not secure"),
description: <div>
{_t("One of the following may be compromised:")}
<ul>
<li>{_t("Your password")}</li>
<li>{_t("Your homeserver")}</li>
<li>{_t("This session, or the other session")}</li>
<li>{_t("The internet connection either session is using")}</li>
</ul>
<div>
{_t("We recommend you change your password and recovery key in Settings immediately")}
</div>
</div>,
onFinished: () => this.props.onFinished(false),
});
} }
onContinueClick = async () => { onContinueClick = () => {
const { userId, device } = this.props; const { userId, device } = this.props;
const cli = MatrixClientPeg.get(); const cli = MatrixClientPeg.get();
const request = await cli.requestVerification( const requestPromise = cli.requestVerification(
userId, userId,
[device.deviceId], [device.deviceId],
); );
this.props.onFinished(true); this.props.onFinished(true);
Modal.createTrackedDialog('New Session Verification', 'Starting dialog', VerificationRequestDialog, { Modal.createTrackedDialog('New Session Verification', 'Starting dialog', VerificationRequestDialog, {
verificationRequest: request, verificationRequestPromise: requestPromise,
member: cli.getUser(userId),
}); });
} }

View file

@ -32,6 +32,8 @@ export default createReactClass({
focus: PropTypes.bool, focus: PropTypes.bool,
onFinished: PropTypes.func.isRequired, onFinished: PropTypes.func.isRequired,
headerImage: PropTypes.string, headerImage: PropTypes.string,
quitOnly: PropTypes.bool, // quitOnly doesn't show the cancel button just the quit [x].
fixedWidth: PropTypes.bool,
}, },
getDefaultProps: function() { getDefaultProps: function() {
@ -42,6 +44,7 @@ export default createReactClass({
focus: true, focus: true,
hasCancelButton: true, hasCancelButton: true,
danger: false, danger: false,
quitOnly: false,
}; };
}, },
@ -61,11 +64,14 @@ export default createReactClass({
primaryButtonClass = "danger"; primaryButtonClass = "danger";
} }
return ( return (
<BaseDialog className="mx_QuestionDialog" onFinished={this.props.onFinished} <BaseDialog
className="mx_QuestionDialog"
onFinished={this.props.onFinished}
title={this.props.title} title={this.props.title}
contentId='mx_Dialog_content' contentId='mx_Dialog_content'
headerImage={this.props.headerImage} headerImage={this.props.headerImage}
hasCancel={this.props.hasCancelButton} hasCancel={this.props.hasCancelButton}
fixedWidth={this.props.fixedWidth}
> >
<div className="mx_Dialog_content" id='mx_Dialog_content'> <div className="mx_Dialog_content" id='mx_Dialog_content'>
{ this.props.description } { this.props.description }
@ -73,7 +79,7 @@ export default createReactClass({
<DialogButtons primaryButton={this.props.button || _t('OK')} <DialogButtons primaryButton={this.props.button || _t('OK')}
primaryButtonClass={primaryButtonClass} primaryButtonClass={primaryButtonClass}
cancelButton={this.props.cancelButton} cancelButton={this.props.cancelButton}
hasCancel={this.props.hasCancelButton} hasCancel={this.props.hasCancelButton && !this.props.quitOnly}
onPrimaryButtonClick={this.onOk} onPrimaryButtonClick={this.onOk}
focus={this.props.focus} focus={this.props.focus}
onCancel={this.onCancel} onCancel={this.onCancel}

View file

@ -0,0 +1,29 @@
/*
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 SetupEncryptionBody from '../../structures/auth/SetupEncryptionBody';
import BaseDialog from './BaseDialog';
import { _t } from '../../../languageHandler';
export default function SetupEncryptionDialog({onFinished}) {
return <BaseDialog
headerImage={require("../../../../res/img/e2e/warning.svg")}
onFinished={onFinished}
title={_t("Verify this session")}
>
<SetupEncryptionBody onFinished={onFinished} />
</BaseDialog>;
}

View file

@ -18,6 +18,7 @@ import React, {createRef} from 'react';
import createReactClass from 'create-react-class'; import createReactClass from 'create-react-class';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import * as sdk from '../../../index'; import * as sdk from '../../../index';
import Field from "../elements/Field";
export default createReactClass({ export default createReactClass({
displayName: 'TextInputDialog', displayName: 'TextInputDialog',
@ -28,9 +29,13 @@ export default createReactClass({
PropTypes.string, PropTypes.string,
]), ]),
value: PropTypes.string, value: PropTypes.string,
placeholder: PropTypes.string,
button: PropTypes.string, button: PropTypes.string,
focus: PropTypes.bool, focus: PropTypes.bool,
onFinished: PropTypes.func.isRequired, onFinished: PropTypes.func.isRequired,
hasCancel: PropTypes.bool,
validator: PropTypes.func, // result of withValidation
fixedWidth: PropTypes.bool,
}, },
getDefaultProps: function() { getDefaultProps: function() {
@ -39,34 +44,70 @@ export default createReactClass({
value: "", value: "",
description: "", description: "",
focus: true, focus: true,
hasCancel: true,
};
},
getInitialState: function() {
return {
value: this.props.value,
valid: false,
}; };
}, },
UNSAFE_componentWillMount: function() { UNSAFE_componentWillMount: function() {
this._textinput = createRef(); this._field = createRef();
}, },
componentDidMount: function() { componentDidMount: function() {
if (this.props.focus) { if (this.props.focus) {
// Set the cursor at the end of the text input // Set the cursor at the end of the text input
this._textinput.current.value = this.props.value; // this._field.current.value = this.props.value;
this._field.current.focus();
} }
}, },
onOk: function() { onOk: async function(ev) {
this.props.onFinished(true, this._textinput.current.value); ev.preventDefault();
if (this.props.validator) {
await this._field.current.validate({ allowEmpty: false });
if (!this._field.current.state.valid) {
this._field.current.focus();
this._field.current.validate({ allowEmpty: false, focused: true });
return;
}
}
this.props.onFinished(true, this.state.value);
}, },
onCancel: function() { onCancel: function() {
this.props.onFinished(false); this.props.onFinished(false);
}, },
onChange: function(ev) {
this.setState({
value: ev.target.value,
});
},
onValidate: async function(fieldState) {
const result = await this.props.validator(fieldState);
this.setState({
valid: result.valid,
});
return result;
},
render: function() { render: function() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
return ( return (
<BaseDialog className="mx_TextInputDialog" onFinished={this.props.onFinished} <BaseDialog
className="mx_TextInputDialog"
onFinished={this.props.onFinished}
title={this.props.title} title={this.props.title}
fixedWidth={this.props.fixedWidth}
> >
<form onSubmit={this.onOk}> <form onSubmit={this.onOk}>
<div className="mx_Dialog_content"> <div className="mx_Dialog_content">
@ -74,19 +115,26 @@ export default createReactClass({
<label htmlFor="textinput"> { this.props.description } </label> <label htmlFor="textinput"> { this.props.description } </label>
</div> </div>
<div> <div>
<input <Field
id="textinput" id="mx_TextInputDialog_field"
ref={this._textinput}
className="mx_TextInputDialog_input" className="mx_TextInputDialog_input"
defaultValue={this.props.value} ref={this._field}
autoFocus={this.props.focus} type="text"
size="64" /> label={this.props.placeholder}
value={this.state.value}
onChange={this.onChange}
onValidate={this.props.validator ? this.onValidate : undefined}
size="64"
/>
</div> </div>
</div> </div>
</form> </form>
<DialogButtons primaryButton={this.props.button} <DialogButtons
primaryButton={this.props.button}
onPrimaryButtonClick={this.onOk} onPrimaryButtonClick={this.onOk}
onCancel={this.onCancel} /> onCancel={this.onCancel}
hasCancel={this.props.hasCancel}
/>
</BaseDialog> </BaseDialog>
); );
}, },

View file

@ -122,7 +122,6 @@ export default createReactClass({
}, },
render: function() { render: function() {
const GeminiScrollbarWrapper = sdk.getComponent("elements.GeminiScrollbarWrapper");
if (this.props.devices === null) { if (this.props.devices === null) {
const Spinner = sdk.getComponent("elements.Spinner"); const Spinner = sdk.getComponent("elements.Spinner");
return <Spinner />; return <Spinner />;
@ -168,7 +167,7 @@ export default createReactClass({
title={_t('Room contains unknown sessions')} title={_t('Room contains unknown sessions')}
contentId='mx_Dialog_content' contentId='mx_Dialog_content'
> >
<GeminiScrollbarWrapper autoshow={false} className="mx_Dialog_content" id='mx_Dialog_content'> <div className="mx_Dialog_content" id='mx_Dialog_content'>
<h4> <h4>
{ _t('"%(RoomName)s" contains sessions that you haven\'t seen before.', {RoomName: this.props.room.name}) } { _t('"%(RoomName)s" contains sessions that you haven\'t seen before.', {RoomName: this.props.room.name}) }
</h4> </h4>
@ -176,7 +175,7 @@ export default createReactClass({
{ _t("Unknown sessions") }: { _t("Unknown sessions") }:
<UnknownDeviceList devices={this.props.devices} /> <UnknownDeviceList devices={this.props.devices} />
</GeminiScrollbarWrapper> </div>
<DialogButtons primaryButton={sendButtonLabel} <DialogButtons primaryButton={sendButtonLabel}
onPrimaryButtonClick={sendButtonOnClick} onPrimaryButtonClick={sendButtonOnClick}
onCancel={this._onDismissClicked} /> onCancel={this._onDismissClicked} />

View file

@ -22,7 +22,8 @@ import { _t } from '../../../languageHandler';
export default class VerificationRequestDialog extends React.Component { export default class VerificationRequestDialog extends React.Component {
static propTypes = { static propTypes = {
verificationRequest: PropTypes.object.isRequired, verificationRequest: PropTypes.object,
verificationRequestPromise: PropTypes.object,
onFinished: PropTypes.func.isRequired, onFinished: PropTypes.func.isRequired,
}; };
@ -34,6 +35,8 @@ export default class VerificationRequestDialog extends React.Component {
render() { render() {
const BaseDialog = sdk.getComponent("views.dialogs.BaseDialog"); const BaseDialog = sdk.getComponent("views.dialogs.BaseDialog");
const EncryptionPanel = sdk.getComponent("views.right_panel.EncryptionPanel"); const EncryptionPanel = sdk.getComponent("views.right_panel.EncryptionPanel");
const member = this.props.member ||
MatrixClientPeg.get().getUser(this.props.verificationRequest.otherUserId);
return <BaseDialog className="mx_InfoDialog" onFinished={this.onFinished} return <BaseDialog className="mx_InfoDialog" onFinished={this.onFinished}
contentId="mx_Dialog_content" contentId="mx_Dialog_content"
title={_t("Verification Request")} title={_t("Verification Request")}
@ -42,14 +45,19 @@ export default class VerificationRequestDialog extends React.Component {
<EncryptionPanel <EncryptionPanel
layout="dialog" layout="dialog"
verificationRequest={this.props.verificationRequest} verificationRequest={this.props.verificationRequest}
verificationRequestPromise={this.props.verificationRequestPromise}
onClose={this.props.onFinished} onClose={this.props.onFinished}
member={MatrixClientPeg.get().getUser(this.props.verificationRequest.otherUserId)} member={member}
/> />
</BaseDialog>; </BaseDialog>;
} }
onFinished() { async onFinished() {
this.props.verificationRequest.cancel();
this.props.onFinished(); this.props.onFinished();
let request = this.props.verificationRequest;
if (!request && this.props.verificationRequestPromise) {
request = await this.props.verificationRequestPromise;
}
request.cancel();
} }
} }

View file

@ -36,6 +36,9 @@ export default class RestoreKeyBackupDialog extends React.PureComponent {
// if false, will close the dialog as soon as the restore completes succesfully // if false, will close the dialog as soon as the restore completes succesfully
// default: true // default: true
showSummary: PropTypes.bool, showSummary: PropTypes.bool,
// If specified, gather the key from the user but then call the function with the backup
// key rather than actually (necessarily) restoring the backup.
keyCallback: PropTypes.func,
}; };
static defaultProps = { static defaultProps = {
@ -103,9 +106,18 @@ export default class RestoreKeyBackupDialog extends React.PureComponent {
restoreType: RESTORE_TYPE_PASSPHRASE, restoreType: RESTORE_TYPE_PASSPHRASE,
}); });
try { try {
// We do still restore the key backup: we must ensure that the key backup key
// is the right one and restoring it is currently the only way we can do this.
const recoverInfo = await MatrixClientPeg.get().restoreKeyBackupWithPassword( const recoverInfo = await MatrixClientPeg.get().restoreKeyBackupWithPassword(
this.state.passPhrase, undefined, undefined, this.state.backupInfo, this.state.passPhrase, undefined, undefined, this.state.backupInfo,
); );
if (this.props.keyCallback) {
const key = await MatrixClientPeg.get().keyBackupKeyFromPassword(
this.state.passPhrase, this.state.backupInfo,
);
this.props.keyCallback(key);
}
if (!this.props.showSummary) { if (!this.props.showSummary) {
this.props.onFinished(true); this.props.onFinished(true);
return; return;
@ -135,6 +147,10 @@ export default class RestoreKeyBackupDialog extends React.PureComponent {
const recoverInfo = await MatrixClientPeg.get().restoreKeyBackupWithRecoveryKey( const recoverInfo = await MatrixClientPeg.get().restoreKeyBackupWithRecoveryKey(
this.state.recoveryKey, undefined, undefined, this.state.backupInfo, this.state.recoveryKey, undefined, undefined, this.state.backupInfo,
); );
if (this.props.keyCallback) {
const key = MatrixClientPeg.get().keyBackupKeyFromRecoveryKey(this.state.recoveryKey);
this.props.keyCallback(key);
}
if (!this.props.showSummary) { if (!this.props.showSummary) {
this.props.onFinished(true); this.props.onFinished(true);
return; return;
@ -184,6 +200,24 @@ export default class RestoreKeyBackupDialog extends React.PureComponent {
} }
} }
async _restoreWithCachedKey(backupInfo) {
if (!backupInfo) return false;
try {
const recoverInfo = await MatrixClientPeg.get().restoreKeyBackupWithCache(
undefined, /* targetRoomId */
undefined, /* targetSessionId */
backupInfo,
);
this.setState({
recoverInfo,
});
return true;
} catch (e) {
console.log("restoreWithCachedKey failed:", e);
return false;
}
}
async _loadBackupStatus() { async _loadBackupStatus() {
this.setState({ this.setState({
loading: true, loading: true,
@ -197,6 +231,15 @@ export default class RestoreKeyBackupDialog extends React.PureComponent {
backupKeyStored, backupKeyStored,
}); });
const gotCache = await this._restoreWithCachedKey(backupInfo);
if (gotCache) {
console.log("RestoreKeyBackupDialog: found cached backup key");
this.setState({
loading: false,
});
return;
}
// If the backup key is stored, we can proceed directly to restore. // If the backup key is stored, we can proceed directly to restore.
if (backupKeyStored) { if (backupKeyStored) {
return this._restoreWithSecretStorage(); return this._restoreWithSecretStorage();

View file

@ -1,6 +1,7 @@
/* /*
Copyright 2016 OpenMarket Ltd Copyright 2016 OpenMarket Ltd
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -15,241 +16,275 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React from 'react'; import React, {useEffect, useState} from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import {MatrixClientPeg} from '../../../MatrixClientPeg'; import {MatrixClientPeg} from '../../../MatrixClientPeg';
import {instanceForInstanceId} from '../../../utils/DirectoryUtils'; import {instanceForInstanceId} from '../../../utils/DirectoryUtils';
import {
ContextMenu,
useContextMenu,
ContextMenuButton,
MenuItemRadio,
MenuItem,
MenuGroup,
} from "../../structures/ContextMenu";
import {_t} from "../../../languageHandler";
import SdkConfig from "../../../SdkConfig";
import {useSettingValue} from "../../../hooks/useSettings";
import * as sdk from "../../../index";
import Modal from "../../../Modal";
import SettingsStore from "../../../settings/SettingsStore";
import withValidation from "../elements/Validation";
const DEFAULT_ICON_URL = require("../../../../res/img/network-matrix.svg"); export const ALL_ROOMS = Symbol("ALL_ROOMS");
export default class NetworkDropdown extends React.Component { const SETTING_NAME = "room_directory_servers";
constructor(props) {
super(props);
this.dropdownRootElement = null; const inPlaceOf = (elementRect) => ({
this.ignoreEvent = null; right: window.innerWidth - elementRect.right,
top: elementRect.top,
chevronOffset: 0,
chevronFace: "none",
});
this.onInputClick = this.onInputClick.bind(this); const validServer = withValidation({
this.onRootClick = this.onRootClick.bind(this); rules: [
this.onDocumentClick = this.onDocumentClick.bind(this); {
this.onMenuOptionClick = this.onMenuOptionClick.bind(this); key: "required",
this.onInputKeyUp = this.onInputKeyUp.bind(this); test: async ({ value }) => !!value,
this.collectRoot = this.collectRoot.bind(this); invalid: () => _t("Enter a server name"),
this.collectInputTextBox = this.collectInputTextBox.bind(this); }, {
key: "available",
final: true,
test: async ({ value }) => {
try {
const opts = {
limit: 1,
server: value,
};
// check if we can successfully load this server's room directory
await MatrixClientPeg.get().publicRooms(opts);
return true;
} catch (e) {
return false;
}
},
valid: () => _t("Looks good"),
invalid: () => _t("Can't find this server or its room list"),
},
],
});
this.inputTextBox = null; // This dropdown sources homeservers from three places:
// + your currently connected homeserver
// + homeservers in config.json["roomDirectory"]
// + homeservers in SettingsStore["room_directory_servers"]
// if a server exists in multiple, only keep the top-most entry.
const server = MatrixClientPeg.getHomeserverName(); const NetworkDropdown = ({onOptionChange, protocols = {}, selectedServerName, selectedInstanceId}) => {
this.state = { const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu();
expanded: false, const _userDefinedServers = useSettingValue(SETTING_NAME);
selectedServer: server, const [userDefinedServers, _setUserDefinedServers] = useState(_userDefinedServers);
selectedInstanceId: null,
includeAllNetworks: false, const handlerFactory = (server, instanceId) => {
return () => {
onOptionChange(server, instanceId);
closeMenu();
}; };
} };
componentWillMount() { const setUserDefinedServers = servers => {
// Listen for all clicks on the document so we can close the _setUserDefinedServers(servers);
// menu when the user clicks somewhere else SettingsStore.setValue(SETTING_NAME, null, "account", servers);
document.addEventListener('click', this.onDocumentClick, false); };
// keep local echo up to date with external changes
useEffect(() => {
_setUserDefinedServers(_userDefinedServers);
}, [_userDefinedServers]);
// fire this now so the defaults can be set up // we either show the button or the dropdown in its place.
const {selectedServer, selectedInstanceId, includeAllNetworks} = this.state; let content;
this.props.onOptionChange(selectedServer, selectedInstanceId, includeAllNetworks); if (menuDisplayed) {
} const config = SdkConfig.get();
const roomDirectory = config.roomDirectory || {};
componentWillUnmount() { const hsName = MatrixClientPeg.getHomeserverName();
document.removeEventListener('click', this.onDocumentClick, false); const configServers = new Set(roomDirectory.servers);
}
componentDidUpdate() { // configured servers take preference over user-defined ones, if one occurs in both ignore the latter one.
if (this.state.expanded && this.inputTextBox) { const removableServers = new Set(userDefinedServers.filter(s => !configServers.has(s) && s !== hsName));
this.inputTextBox.focus(); const servers = [
} // we always show our connected HS, this takes precedence over it being configured or user-defined
} hsName,
...Array.from(configServers).filter(s => s !== hsName).sort(),
onDocumentClick(ev) { ...Array.from(removableServers).sort(),
// Close the dropdown if the user clicks anywhere that isn't ];
// within our root element
if (ev !== this.ignoreEvent) {
this.setState({
expanded: false,
});
}
}
onRootClick(ev) {
// This captures any clicks that happen within our elements,
// such that we can then ignore them when they're seen by the
// click listener on the document handler, ie. not close the
// dropdown immediately after opening it.
// NB. We can't just stopPropagation() because then the event
// doesn't reach the React onClick().
this.ignoreEvent = ev;
}
onInputClick(ev) {
this.setState({
expanded: !this.state.expanded,
});
ev.preventDefault();
}
onMenuOptionClick(server, instance, includeAll) {
this.setState({
expanded: false,
selectedServer: server,
selectedInstanceId: instance ? instance.instance_id : null,
includeAllNetworks: includeAll,
});
this.props.onOptionChange(server, instance ? instance.instance_id : null, includeAll);
}
onInputKeyUp(e) {
if (e.key === 'Enter') {
this.setState({
expanded: false,
selectedServer: e.target.value,
selectedNetwork: null,
includeAllNetworks: false,
});
this.props.onOptionChange(e.target.value, null);
}
}
collectRoot(e) {
if (this.dropdownRootElement) {
this.dropdownRootElement.removeEventListener('click', this.onRootClick, false);
}
if (e) {
e.addEventListener('click', this.onRootClick, false);
}
this.dropdownRootElement = e;
}
collectInputTextBox(e) {
this.inputTextBox = e;
}
_getMenuOptions() {
const options = [];
const roomDirectory = this.props.config.roomDirectory || {};
let servers = [];
if (roomDirectory.servers) {
servers = servers.concat(roomDirectory.servers);
}
if (!servers.includes(MatrixClientPeg.getHomeserverName())) {
servers.unshift(MatrixClientPeg.getHomeserverName());
}
// For our own HS, we can use the instance_ids given in the third party protocols // For our own HS, we can use the instance_ids given in the third party protocols
// response to get the server to filter the room list by network for us. // response to get the server to filter the room list by network for us.
// We can't get thirdparty protocols for remote server yet though, so for those // We can't get thirdparty protocols for remote server yet though, so for those
// we can only show the default room list. // we can only show the default room list.
for (const server of servers) { const options = servers.map(server => {
options.push(this._makeMenuOption(server, null, true)); const serverSelected = server === selectedServerName;
if (server === MatrixClientPeg.getHomeserverName()) { const entries = [];
options.push(this._makeMenuOption(server, null, false));
if (this.props.protocols) {
for (const proto of Object.keys(this.props.protocols)) {
if (!this.props.protocols[proto].instances) continue;
const sortedInstances = this.props.protocols[proto].instances; const protocolsList = server === hsName ? Object.values(protocols) : [];
sortedInstances.sort(function(x, y) { if (protocolsList.length > 0) {
const a = x.desc; // add a fake protocol with the ALL_ROOMS symbol
const b = y.desc; protocolsList.push({
if (a < b) { instances: [{
return -1; instance_id: ALL_ROOMS,
} else if (a > b) { desc: _t("All rooms"),
return 1; }],
} else { });
return 0;
}
});
for (const instance of sortedInstances) {
if (!instance.instance_id) continue;
options.push(this._makeMenuOption(server, instance, false));
}
}
}
} }
}
return options; protocolsList.forEach(({instances=[]}) => {
} [...instances].sort((b, a) => {
return a.desc.localeCompare(b.desc);
}).forEach(({desc, instance_id: instanceId}) => {
entries.push(
<MenuItemRadio
key={String(instanceId)}
active={serverSelected && instanceId === selectedInstanceId}
onClick={handlerFactory(server, instanceId)}
label={desc}
className="mx_NetworkDropdown_server_network"
>
{ desc }
</MenuItemRadio>);
});
});
_makeMenuOption(server, instance, includeAll, handleClicks) { let subtitle;
if (handleClicks === undefined) handleClicks = true; if (server === hsName) {
subtitle = (
<div className="mx_NetworkDropdown_server_subtitle">
{_t("Your server")}
</div>
);
}
let icon; let removeButton;
let name; if (removableServers.has(server)) {
let key; const onClick = async () => {
closeMenu();
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
const {finished} = Modal.createTrackedDialog("Network Dropdown", "Remove server", QuestionDialog, {
title: _t("Are you sure?"),
description: _t("Are you sure you want to remove <b>%(serverName)s</b>", {
serverName: server,
}, {
b: serverName => <b>{ serverName }</b>,
}),
button: _t("Remove"),
fixedWidth: false,
}, "mx_NetworkDropdown_dialog");
if (!instance && includeAll) { const [ok] = await finished;
key = server; if (!ok) return;
name = server;
} else if (!instance) {
key = server + '_all';
name = 'Matrix';
icon = <img src={require("../../../../res/img/network-matrix.svg")} />;
} else {
key = server + '_inst_' + instance.instance_id;
const imgUrl = instance.icon ?
MatrixClientPeg.get().mxcUrlToHttp(instance.icon, 25, 25, 'crop', true) :
DEFAULT_ICON_URL;
icon = <img src={imgUrl} />;
name = instance.desc;
}
const clickHandler = handleClicks ? this.onMenuOptionClick.bind(this, server, instance, includeAll) : null; // delete from setting
setUserDefinedServers(servers.filter(s => s !== server));
return <div key={key} className="mx_NetworkDropdown_networkoption" onClick={clickHandler}> // the selected server is being removed, reset to our HS
{icon} if (serverSelected === server) {
<span className="mx_NetworkDropdown_menu_network">{name}</span> onOptionChange(hsName, undefined);
</div>; }
} };
removeButton = <MenuItem onClick={onClick} label={_t("Remove server")} />;
}
render() { // ARIA: in actual fact the entire menu is one large radio group but for better screen reader support
let currentValue; // we use group to notate server wrongly.
return (
<MenuGroup label={server} className="mx_NetworkDropdown_server" key={server}>
<div className="mx_NetworkDropdown_server_title">
{ server }
{ removeButton }
</div>
{ subtitle }
let menu; <MenuItemRadio
if (this.state.expanded) { active={serverSelected && !selectedInstanceId}
const menuOptions = this._getMenuOptions(); onClick={handlerFactory(server, undefined)}
menu = <div className="mx_NetworkDropdown_menu"> label={_t("Matrix")}
{menuOptions} className="mx_NetworkDropdown_server_network"
</div>; >
currentValue = <input type="text" className="mx_NetworkDropdown_networkoption" {_t("Matrix")}
ref={this.collectInputTextBox} onKeyUp={this.onInputKeyUp} </MenuItemRadio>
placeholder="matrix.org" // 'matrix.org' as an example of an HS name { entries }
/>; </MenuGroup>
} else {
const instance = instanceForInstanceId(this.props.protocols, this.state.selectedInstanceId);
currentValue = this._makeMenuOption(
this.state.selectedServer, instance, this.state.includeAllNetworks, false,
); );
});
const onClick = async () => {
closeMenu();
const TextInputDialog = sdk.getComponent("dialogs.TextInputDialog");
const { finished } = Modal.createTrackedDialog("Network Dropdown", "Add a new server", TextInputDialog, {
title: _t("Add a new server"),
description: _t("Enter the name of a new server you want to explore."),
button: _t("Add"),
hasCancel: false,
placeholder: _t("Server name"),
validator: validServer,
fixedWidth: false,
}, "mx_NetworkDropdown_dialog");
const [ok, newServer] = await finished;
if (!ok) return;
if (!userDefinedServers.includes(newServer)) {
setUserDefinedServers([...userDefinedServers, newServer]);
}
onOptionChange(newServer); // change filter to the new server
};
const buttonRect = handle.current.getBoundingClientRect();
content = <ContextMenu {...inPlaceOf(buttonRect)} onFinished={closeMenu}>
<div className="mx_NetworkDropdown_menu">
{options}
<MenuItem className="mx_NetworkDropdown_server_add" label={undefined} onClick={onClick}>
{_t("Add a new server...")}
</MenuItem>
</div>
</ContextMenu>;
} else {
let currentValue;
if (selectedInstanceId === ALL_ROOMS) {
currentValue = _t("All rooms");
} else if (selectedInstanceId) {
const instance = instanceForInstanceId(protocols, selectedInstanceId);
currentValue = _t("%(networkName)s rooms", {
networkName: instance.desc,
});
} else {
currentValue = _t("Matrix rooms");
} }
return <div className="mx_NetworkDropdown" ref={this.collectRoot}> content = <ContextMenuButton
<div className="mx_NetworkDropdown_input mx_no_textinput" onClick={this.onInputClick}> className="mx_NetworkDropdown_handle"
onClick={openMenu}
isExpanded={menuDisplayed}
>
<span>
{currentValue} {currentValue}
<span className="mx_NetworkDropdown_arrow" /> </span> <span className="mx_NetworkDropdown_handle_server">
{menu} ({selectedServerName})
</div> </span>
</div>; </ContextMenuButton>;
} }
}
return <div className="mx_NetworkDropdown" ref={handle}>
{content}
</div>;
};
NetworkDropdown.propTypes = { NetworkDropdown.propTypes = {
onOptionChange: PropTypes.func.isRequired, onOptionChange: PropTypes.func.isRequired,
protocols: PropTypes.object, protocols: PropTypes.object,
// The room directory config. May have a 'servers' key that is a list of server names to include in the dropdown
config: PropTypes.object,
}; };
NetworkDropdown.defaultProps = { export default NetworkDropdown;
protocols: {},
config: {},
};

View file

@ -419,6 +419,12 @@ export default class AppTile extends React.Component {
if (this.props.onCapabilityRequest) { if (this.props.onCapabilityRequest) {
this.props.onCapabilityRequest(requestedCapabilities); 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 (this.props.type === 'jitsi') {
widgetMessaging.flagReadyToContinue();
}
}).catch((err) => { }).catch((err) => {
console.log(`Failed to get capabilities for widget type ${this.props.type}`, this.props.id, err); console.log(`Failed to get capabilities for widget type ${this.props.type}`, this.props.id, err);
}); });
@ -520,7 +526,13 @@ export default class AppTile extends React.Component {
parsedWidgetUrl.query.react_perf = true; parsedWidgetUrl.query.react_perf = true;
} }
let safeWidgetUrl = ''; let safeWidgetUrl = '';
if (ALLOWED_APP_URL_SCHEMES.indexOf(parsedWidgetUrl.protocol) !== -1) { if (ALLOWED_APP_URL_SCHEMES.includes(parsedWidgetUrl.protocol) || (
// Check if the widget URL is a Jitsi widget in Electron
parsedWidgetUrl.protocol === 'vector:'
&& parsedWidgetUrl.host === 'vector'
&& parsedWidgetUrl.pathname === '/webapp/jitsi.html'
&& this.props.type === 'jitsi'
)) {
safeWidgetUrl = url.format(parsedWidgetUrl); safeWidgetUrl = url.format(parsedWidgetUrl);
} }
return safeWidgetUrl; return safeWidgetUrl;

View file

@ -78,8 +78,7 @@ export class EditableItem extends React.Component {
return ( return (
<div className="mx_EditableItem"> <div className="mx_EditableItem">
<img src={require("../../../../res/img/feather-customised/cancel.svg")} width={14} height={14} <div onClick={this._onRemove} className="mx_EditableItem_delete" title={_t("Remove")} role="button" />
onClick={this._onRemove} className="mx_EditableItem_delete" alt={_t("Remove")} />
<span className="mx_EditableItem_item">{this.props.value}</span> <span className="mx_EditableItem_item">{this.props.value}</span>
</div> </div>
); );
@ -123,8 +122,9 @@ export default class EditableItemList extends React.Component {
<form onSubmit={this._onItemAdded} autoComplete="off" <form onSubmit={this._onItemAdded} autoComplete="off"
noValidate={true} className="mx_EditableItemList_newItem"> noValidate={true} className="mx_EditableItemList_newItem">
<Field id={`mx_EditableItemList_new_${this.props.id}`} label={this.props.placeholder} type="text" <Field id={`mx_EditableItemList_new_${this.props.id}`} label={this.props.placeholder} type="text"
autoComplete="off" value={this.props.newItem || ""} onChange={this._onNewItemChanged} /> autoComplete="off" value={this.props.newItem || ""} onChange={this._onNewItemChanged}
<AccessibleButton onClick={this._onItemAdded} kind="primary"> list={this.props.suggestionsListId} />
<AccessibleButton onClick={this._onItemAdded} kind="primary" type="submit">
{_t("Add")} {_t("Add")}
</AccessibleButton> </AccessibleButton>
</form> </form>

View file

@ -32,6 +32,8 @@ export default class Field extends React.PureComponent {
element: PropTypes.oneOf(["input", "select", "textarea"]), element: PropTypes.oneOf(["input", "select", "textarea"]),
// The field's type (when used as an <input>). Defaults to "text". // The field's type (when used as an <input>). Defaults to "text".
type: PropTypes.string, type: PropTypes.string,
// id of a <datalist> element for suggestions
list: PropTypes.string,
// The field's label string. // The field's label string.
label: PropTypes.string, label: PropTypes.string,
// The field's placeholder string. Defaults to the label. // The field's placeholder string. Defaults to the label.
@ -157,7 +159,7 @@ export default class Field extends React.PureComponent {
render() { render() {
const { const {
element, prefix, postfix, className, onValidate, children, element, prefix, postfix, className, onValidate, children,
tooltipContent, flagInvalid, tooltipClassName, ...inputProps} = this.props; tooltipContent, flagInvalid, tooltipClassName, list, ...inputProps} = this.props;
const inputElement = element || "input"; const inputElement = element || "input";
@ -169,6 +171,7 @@ export default class Field extends React.PureComponent {
inputProps.onFocus = this.onFocus; inputProps.onFocus = this.onFocus;
inputProps.onChange = this.onChange; inputProps.onChange = this.onChange;
inputProps.onBlur = this.onBlur; inputProps.onBlur = this.onBlur;
inputProps.list = list;
const fieldInput = React.createElement(inputElement, inputProps, children); const fieldInput = React.createElement(inputElement, inputProps, children);

View file

@ -1,35 +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 GeminiScrollbar from 'react-gemini-scrollbar';
function GeminiScrollbarWrapper(props) {
const {wrappedRef, ...wrappedProps} = props;
// Enable forceGemini so that gemini is always enabled. This is
// to avoid future issues where a feature is implemented without
// doing QA on every OS/browser combination.
//
// By default GeminiScrollbar allows native scrollbars to be used
// on macOS. Use forceGemini to enable Gemini's non-native
// scrollbars on all OSs.
return <GeminiScrollbar ref={wrappedRef} forceGemini={true} {...wrappedProps}>
{ props.children }
</GeminiScrollbar>;
}
export default GeminiScrollbarWrapper;

View file

@ -216,7 +216,7 @@ export default class ImageView extends React.Component {
{ this.getName() } { this.getName() }
</div> </div>
{ eventMeta } { eventMeta }
<a className="mx_ImageView_link" href={ this.props.src } download={ this.props.name } rel="noreferrer noopener"> <a className="mx_ImageView_link" href={ this.props.src } download={ this.props.name } target="_blank" rel="noopener">
<div className="mx_ImageView_download"> <div className="mx_ImageView_download">
{ _t('Download this file') }<br /> { _t('Download this file') }<br />
<span className="mx_ImageView_size">{ sizeRes }</span> <span className="mx_ImageView_size">{ sizeRes }</span>

View file

@ -92,6 +92,7 @@ export default class RoomAliasField extends React.PureComponent {
invalid: () => _t("Please provide a room alias"), invalid: () => _t("Please provide a room alias"),
}, { }, {
key: "taken", key: "taken",
final: true,
test: async ({value}) => { test: async ({value}) => {
if (!value) { if (!value) {
return true; return true;

View file

@ -0,0 +1,41 @@
/*
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 from 'react';
import PropTypes from 'prop-types';
import PlatformPeg from "../../../PlatformPeg";
import AccessibleButton from "./AccessibleButton";
import {_t} from "../../../languageHandler";
const SSOButton = ({matrixClient, loginType, ...props}) => {
const onClick = () => {
PlatformPeg.get().startSingleSignOn(matrixClient, loginType);
};
return (
<AccessibleButton {...props} kind="primary" onClick={onClick}>
{_t("Sign in with single sign-on")}
</AccessibleButton>
);
};
SSOButton.propTypes = {
matrixClient: PropTypes.object.isRequired, // does not use context as may use a temporary client
loginType: PropTypes.oneOf(["sso", "cas"]), // defaults to "sso" in base-apis
};
export default SSOButton;

Some files were not shown because too many files have changed in this diff Show more