diff --git a/.buildkite/pipeline.yaml b/.buildkite/pipeline.yaml deleted file mode 100644 index 4bc69a76bd..0000000000 --- a/.buildkite/pipeline.yaml +++ /dev/null @@ -1,119 +0,0 @@ -steps: - - label: ":eslint: JS Lint" - command: - # We fetch the develop js-sdk to get our latest eslint rules - - "echo '--- Install js-sdk'" - - "./scripts/ci/install-deps.sh --ignore-scripts" - - "echo '+++ Lint'" - - "yarn lint:js" - plugins: - - docker#v3.0.1: - image: "node:12" - - - label: ":eslint: TS Lint" - command: - - "echo '--- Install'" - - "yarn install --ignore-scripts" - - "echo '+++ Lint'" - - "yarn lint:ts" - plugins: - - docker#v3.0.1: - image: "node:12" - - - label: ":eslint: Types Lint" - command: - - "echo '--- Install'" - - "yarn install --ignore-scripts" - - "echo '+++ Lint'" - - "yarn lint:types" - plugins: - - docker#v3.0.1: - image: "node:12" - - label: ":stylelint: Style Lint" - command: - - "echo '--- Install'" - - "yarn install --ignore-scripts" - - "yarn lint:style" - plugins: - - docker#v3.0.1: - image: "node:12" - - - label: ":jest: Tests" - agents: - # We use a medium sized instance instead of the normal small ones because - # webpack loves to gorge itself on resources. - queue: "medium" - command: - - "echo '--- Install js-sdk'" - # We don't use the babel-ed output for anything so we can --ignore-scripts - # to save transpiling the files. We run the transpile step explicitly in - # the 'build' job. - - "./scripts/ci/install-deps.sh --ignore-scripts" - - "yarn run reskindex" - - "echo '+++ Running Tests'" - - "yarn test" - plugins: - - docker#v3.0.1: - image: "node:12" - - - label: "🛠 Build" - command: - - "echo '+++ Install & Build'" - - "yarn install" - plugins: - - docker#v3.0.1: - image: "node:12" - - - label: ":chains: End-to-End Tests" - agents: - # We use a xlarge sized instance instead of the normal small ones because - # e2e tests otherwise take +-8min - queue: "xlarge" - command: - - "echo '--- Install js-sdk'" - - "./scripts/ci/install-deps.sh --ignore-scripts" - - "echo '+++ Running Tests'" - - "./scripts/ci/end-to-end-tests.sh" - plugins: - - docker#v3.0.1: - image: "matrixdotorg/riotweb-ci-e2etests-env:latest" - propagate-environment: true - workdir: "/workdir/matrix-react-sdk" - retry: - automatic: - - exit_status: 1 # retry end-to-end tests once as Puppeteer sometimes fails - limit: 1 - - - label: "🔧 Riot Tests" - agents: - # We use a medium sized instance instead of the normal small ones because - # webpack loves to gorge itself on resources. - queue: "medium" - command: - - "echo '+++ Running Tests'" - - "./scripts/ci/riot-unit-tests.sh" - plugins: - - docker#v3.0.1: - image: "node:10" - propagate-environment: true - workdir: "/workdir/matrix-react-sdk" - - - label: "🌐 i18n" - command: - - "echo '--- Fetching Dependencies'" - - "yarn install --ignore-scripts" - - "echo '+++ Testing i18n output'" - - "yarn diff-i18n" - plugins: - - docker#v3.0.1: - image: "node:10" - - - wait - - - label: "🐴 Trigger riot-web" - trigger: "riot-web" - branches: "develop" - build: - branch: "develop" - message: "[react-sdk] ${BUILDKITE_MESSAGE}" - async: true diff --git a/.eslintignore b/.eslintignore index c4c7fe5067..c4f7298047 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1 +1,4 @@ src/component-index.js +test/end-to-end-tests/node_modules/ +test/end-to-end-tests/riot/ +test/end-to-end-tests/synapse/ diff --git a/.eslintignore.errorfiles b/.eslintignore.errorfiles index 36b03b121c..ffd398cb14 100644 --- a/.eslintignore.errorfiles +++ b/.eslintignore.errorfiles @@ -61,3 +61,7 @@ test/mock-clock.js test/notifications/ContentRules-test.js test/notifications/PushRuleVectorState-test.js test/stores/RoomViewStore-test.js +src/component-index.js +test/end-to-end-tests/node_modules/ +test/end-to-end-tests/riot/ +test/end-to-end-tests/synapse/ diff --git a/CHANGELOG.md b/CHANGELOG.md index fa02dc1ae3..07e478fa02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) =================================================================================================== [Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v2.1.0...v2.1.1) diff --git a/README.md b/README.md index 0fbed22030..d6fd6db1b7 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ All code lands on the `develop` branch - `master` is only used for stable releas **Please file PRs against `develop`!!** 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: https://github.com/matrix-org/matrix-react-sdk/blob/master/code_style.md diff --git a/docs/scrolling.md b/docs/scrolling.md new file mode 100644 index 0000000000..71329e5c32 --- /dev/null +++ b/docs/scrolling.md @@ -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. diff --git a/package.json b/package.json index 09393f052a..8cda349e03 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-react-sdk", - "version": "2.1.1", + "version": "2.2.3", "description": "SDK for matrix.org using React", "author": "matrix.org", "repository": { @@ -40,15 +40,15 @@ "rethemendex": "res/css/rethemendex.sh", "clean": "rimraf lib", "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:types": "tsc --emitDeclarationOnly", + "build:compile": "yarn reskindex && babel -d lib --verbose --extensions \".ts,.js,.tsx\" src", + "build:types": "tsc --emitDeclarationOnly --jsx react", "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: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:js": "eslint --max-warnings 0 --ignore-path .eslintignore.errorfiles src test", "lint:ts": "tslint --project ./tsconfig.json -t stylish", - "lint:types": "tsc --noEmit", + "lint:types": "tsc --noEmit --jsx react", "lint:style": "stylelint 'res/css/**/*.scss'", "test": "jest", "test:e2e": "./test/end-to-end-tests/run.sh --riot-url http://localhost:8080" @@ -85,6 +85,7 @@ "pako": "^1.0.5", "png-chunks-extract": "^1.0.0", "prop-types": "^15.5.8", + "qrcode": "^1.4.4", "qrcode-react": "^0.1.16", "qs": "^6.6.0", "react": "^16.9.0", @@ -117,6 +118,7 @@ "@babel/preset-typescript": "^7.7.4", "@babel/register": "^7.7.4", "@peculiar/webcrypto": "^1.0.22", + "@types/react": "16.9", "babel-eslint": "^10.0.3", "babel-jest": "^24.9.0", "chokidar": "^3.3.1", diff --git a/release.sh b/release.sh index 3c28084bb7..23b8822041 100755 --- a/release.sh +++ b/release.sh @@ -1,4 +1,4 @@ -#!/bin/sh +#!/bin/bash # # Script to perform a release of matrix-react-sdk. # @@ -11,6 +11,7 @@ cd `dirname $0` for i in matrix-js-sdk do + echo "Checking version of $i..." depver=`cat package.json | jq -r .dependencies[\"$i\"]` latestver=`yarn info -s $i dist-tags.next` if [ "$depver" != "$latestver" ] diff --git a/res/css/_common.scss b/res/css/_common.scss index e062e0bd73..a4ef603242 100644 --- a/res/css/_common.scss +++ b/res/css/_common.scss @@ -42,10 +42,15 @@ pre, code { font-size: 100% !important; } -.error, .warning { +.error, .warning, +.text-error, .text-warning { color: $warning-color; } +.text-success { + color: $accent-color; +} + b { // On Firefox, the default weight for `` is `bolder` which results in no bold // effect since we only have specific weights of our fonts available. diff --git a/res/css/structures/auth/_CompleteSecurity.scss b/res/css/structures/auth/_CompleteSecurity.scss index 2bf51d9574..601492d43c 100644 --- a/res/css/structures/auth/_CompleteSecurity.scss +++ b/res/css/structures/auth/_CompleteSecurity.scss @@ -37,6 +37,10 @@ limitations under the License. font-size: 15px; } +.mx_CompleteSecurity_waiting { + color: $notice-secondary-color; +} + .mx_CompleteSecurity_actionRow { display: flex; justify-content: flex-end; diff --git a/res/css/views/dialogs/_DevtoolsDialog.scss b/res/css/views/dialogs/_DevtoolsDialog.scss index 9d58c999c3..500c46b5fd 100644 --- a/res/css/views/dialogs/_DevtoolsDialog.scss +++ b/res/css/views/dialogs/_DevtoolsDialog.scss @@ -189,3 +189,37 @@ limitations under the License. } } } + +.mx_DevTools_VerificationRequest { + border: 1px solid #cccccc; + border-radius: 3px; + padding: 1px 5px; + margin-bottom: 6px; + font-family: $monospace-font-family; + + dl { + display: grid; + grid-template-columns: max-content auto; + margin: 0; + } + + dd { + grid-column-start: 2; + } + + dd:empty { + color: #666666; + &::after { + content: "(empty)"; + } + } + + dt { + font-weight: bold; + grid-column-start: 1; + } + + dt::after { + content: ":"; + } +} diff --git a/res/css/views/elements/_EditableItemList.scss b/res/css/views/elements/_EditableItemList.scss index 51fa4c4423..ef60f006cc 100644 --- a/res/css/views/elements/_EditableItemList.scss +++ b/res/css/views/elements/_EditableItemList.scss @@ -20,14 +20,21 @@ limitations under the License. } .mx_EditableItem { + display: flex; margin-bottom: 5px; - margin-left: 15px; } .mx_EditableItem_delete { + order: 3; margin-right: 5px; cursor: pointer; 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 { @@ -36,12 +43,19 @@ limitations under the License. .mx_EditableItem_promptText { margin-right: 10px; + order: 2; } .mx_EditableItem_confirmBtn { margin-right: 5px; } +.mx_EditableItem_item { + flex: auto 1 0; + order: 1; +} + .mx_EditableItemList_label { margin-bottom: 5px; } + diff --git a/res/css/views/elements/_RichText.scss b/res/css/views/elements/_RichText.scss index 73f0be291f..5066ee10f3 100644 --- a/res/css/views/elements/_RichText.scss +++ b/res/css/views/elements/_RichText.scss @@ -13,6 +13,11 @@ padding-left: 5px; } +a.mx_Pill { + word-break: break-all; + display: inline; +} + /* More specific to override `.markdown-body a` text-decoration */ .mx_EventTile_content .markdown-body a.mx_Pill { text-decoration: none; diff --git a/res/css/views/right_panel/_UserInfo.scss b/res/css/views/right_panel/_UserInfo.scss index 46d5e99d64..0e4b1bda9e 100644 --- a/res/css/views/right_panel/_UserInfo.scss +++ b/res/css/views/right_panel/_UserInfo.scss @@ -137,12 +137,19 @@ limitations under the License. font-size: 18px; line-height: 25px; flex: 1; - overflow-x: auto; - max-height: 50px; - display: flex; justify-content: 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 { margin: 5px; } diff --git a/res/css/views/room_settings/_AliasSettings.scss b/res/css/views/room_settings/_AliasSettings.scss index d4ae58e5b0..f8d92e7828 100644 --- a/res/css/views/room_settings/_AliasSettings.scss +++ b/res/css/views/room_settings/_AliasSettings.scss @@ -26,3 +26,21 @@ limitations under the License. outline: 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; + } +} diff --git a/res/css/views/rooms/_InviteOnlyIcon.scss b/res/css/views/rooms/_InviteOnlyIcon.scss index 6943d1797b..b71fd6348d 100644 --- a/res/css/views/rooms/_InviteOnlyIcon.scss +++ b/res/css/views/rooms/_InviteOnlyIcon.scss @@ -14,27 +14,45 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_InviteOnlyIcon { +@define-mixin mx_InviteOnlyIcon { width: 12px; height: 12px; position: relative; display: block !important; - // Align the padlock with unencrypted room names +} + +@define-mixin mx_InviteOnlyIcon_padlock { + background-color: $roomtile-name-color; + mask-image: url("$(res)/img/feather-customised/lock-solid.svg"); + mask-position: center; + mask-repeat: no-repeat; + mask-size: contain; + content: ""; + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; +} + +.mx_InviteOnlyIcon_large { + @mixin mx_InviteOnlyIcon; margin: 0 4px; &::before { - background-color: $roomtile-name-color; - mask-image: url('$(res)/img/feather-customised/lock-solid.svg'); - mask-position: center; - mask-repeat: no-repeat; - mask-size: contain; - content: ''; - position: absolute; - top: 0; - bottom: 0; - left: 0; - right: 0; + @mixin mx_InviteOnlyIcon_padlock; width: 12px; height: 12px; } } + +.mx_InviteOnlyIcon_small { + @mixin mx_InviteOnlyIcon; + left: -2px; + + &::before { + @mixin mx_InviteOnlyIcon_padlock; + width: 10px; + height: 10px; + } +} diff --git a/res/css/views/rooms/_RoomPreviewBar.scss b/res/css/views/rooms/_RoomPreviewBar.scss index 85b6916226..b3f6a12103 100644 --- a/res/css/views/rooms/_RoomPreviewBar.scss +++ b/res/css/views/rooms/_RoomPreviewBar.scss @@ -25,6 +25,9 @@ limitations under the License. h3 { font-size: 18px; font-weight: 600; + // break-word, with fallback to break-all, which is wider supported + word-break: break-all; + word-break: break-word; &.mx_RoomPreviewBar_spinnerTitle { display: flex; diff --git a/res/css/views/rooms/_TopUnreadMessagesBar.scss b/res/css/views/rooms/_TopUnreadMessagesBar.scss index a3916f321a..28eddf1fa2 100644 --- a/res/css/views/rooms/_TopUnreadMessagesBar.scss +++ b/res/css/views/rooms/_TopUnreadMessagesBar.scss @@ -51,8 +51,30 @@ limitations under the License. position: absolute; width: 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-position: 9px 13px; 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; +} diff --git a/res/css/views/settings/tabs/_SettingsTab.scss b/res/css/views/settings/tabs/_SettingsTab.scss index 794c8106be..01a1d94956 100644 --- a/res/css/views/settings/tabs/_SettingsTab.scss +++ b/res/css/views/settings/tabs/_SettingsTab.scss @@ -45,9 +45,17 @@ limitations under the License. margin: 10px 100px 10px 0; // Align with the rest of the view } -.mx_SettingsTab_section .mx_SettingsFlag { - margin-right: 100px; - margin-bottom: 10px; +.mx_SettingsTab_section { + margin-bottom: 24px; + + .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 { diff --git a/res/css/views/settings/tabs/user/_PreferencesUserSettingsTab.scss b/res/css/views/settings/tabs/user/_PreferencesUserSettingsTab.scss index d003e175d9..be0af9123b 100644 --- a/res/css/views/settings/tabs/user/_PreferencesUserSettingsTab.scss +++ b/res/css/views/settings/tabs/user/_PreferencesUserSettingsTab.scss @@ -14,6 +14,12 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_PreferencesUserSettingsTab .mx_Field { - @mixin mx_Settings_fullWidthField; +.mx_PreferencesUserSettingsTab { + .mx_Field { + @mixin mx_Settings_fullWidthField; + } + + .mx_SettingsTab_section { + margin-bottom: 30px; + } } diff --git a/res/themes/light/css/_light.scss b/res/themes/light/css/_light.scss index c868c81549..626ccb2e13 100644 --- a/res/themes/light/css/_light.scss +++ b/res/themes/light/css/_light.scss @@ -5,9 +5,12 @@ Arial empirically gets it right, hence prioritising Arial here. */ /* We fall through to Twemoji for emoji rather than falling through 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 // try to use these colors when possible diff --git a/scripts/gen-i18n.js b/scripts/gen-i18n.js index 3d3d5af116..a4d53aea2f 100755 --- a/scripts/gen-i18n.js +++ b/scripts/gen-i18n.js @@ -237,7 +237,7 @@ const walkOpts = { const fullPath = path.join(root, fileStats.name); let trs; - if (fileStats.name.endsWith('.js')) { + if (fileStats.name.endsWith('.js') || fileStats.name.endsWith('.tsx')) { trs = getTranslationsJs(fullPath); } else if (fileStats.name.endsWith('.html')) { trs = getTranslationsOther(fullPath); diff --git a/scripts/generate-eslint-error-ignore-file b/scripts/generate-eslint-error-ignore-file index 3a635f5a7d..54aacfc9fa 100755 --- a/scripts/generate-eslint-error-ignore-file +++ b/scripts/generate-eslint-error-ignore-file @@ -14,8 +14,10 @@ echo "generating $out" # autogenerated file: run scripts/generate-eslint-error-ignore-file to update. EOF - - ./node_modules/.bin/eslint --no-ignore -f json src test | + + ./node_modules/.bin/eslint -f json src test | jq -r '.[] | select((.errorCount + .warningCount) > 0) | .filePath' | sed -e 's/.*matrix-react-sdk\///'; } > "$out" +# also append rules from eslintignore file +cat .eslintignore >> $out diff --git a/scripts/reskindex.js b/scripts/reskindex.js index 81ab111f46..9fb0e1a7c0 100755 --- a/scripts/reskindex.js +++ b/scripts/reskindex.js @@ -8,11 +8,14 @@ var chokidar = require('chokidar'); var componentIndex = path.join('src', 'component-index.js'); var componentIndexTmp = componentIndex+".tmp"; var componentsDir = path.join('src', 'components'); -var componentGlob = '**/*.js'; +var componentJsGlob = '**/*.js'; +var componentTsGlob = '**/*.tsx'; var prevFiles = []; 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)) { return; } @@ -36,7 +39,7 @@ function reskindex() { strm.write("let components = {};\n"); 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 importName = moduleName.replace(/\./g, "$"); @@ -79,7 +82,7 @@ if (!args.w) { } 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 (watchDebouncer) clearTimeout(watchDebouncer); watchDebouncer = setTimeout(reskindex, 1000); diff --git a/src/Analytics.js b/src/Analytics.js index 8eea47ea89..c96cfdefee 100644 --- a/src/Analytics.js +++ b/src/Analytics.js @@ -260,7 +260,6 @@ class Analytics { }); } catch (e) { console.error("Analytics error: ", e); - window.err = e; } } diff --git a/src/Avatar.js b/src/Avatar.js index 5a330c31e9..217b196348 100644 --- a/src/Avatar.js +++ b/src/Avatar.js @@ -102,6 +102,8 @@ export function getInitialLetter(name) { } export function avatarUrlForRoom(room, width, height, resizeMethod) { + if (!room) return null; // null-guard + const explicitRoomAvatar = room.getAvatarUrl( MatrixClientPeg.get().getHomeserverUrl(), width, diff --git a/src/BasePlatform.js b/src/BasePlatform.js index 14e34a1f40..5d809eb28f 100644 --- a/src/BasePlatform.js +++ b/src/BasePlatform.js @@ -4,6 +4,7 @@ Copyright 2016 Aviral Dasgupta Copyright 2016 OpenMarket 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"); 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. */ +import {MatrixClient} from "matrix-js-sdk"; import dis from './dispatcher'; import BaseEventIndexManager from './indexing/BaseEventIndexManager'; @@ -162,4 +164,28 @@ export default class BasePlatform { getEventIndexingManager(): BaseEventIndexManager | 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 + } } diff --git a/src/CallHandler.js b/src/CallHandler.js index 1551b57313..2988e90f40 100644 --- a/src/CallHandler.js +++ b/src/CallHandler.js @@ -143,7 +143,7 @@ function _setCallListeners(call) { "if you proceed without verifying them, it will be "+ "possible for someone to eavesdrop on your call.", ), - button: _t('Review Devices'), + button: _t('Review Sessions'), onFinished: function(confirmed) { if (confirmed) { const room = MatrixClientPeg.get().getRoom(call.roomId); diff --git a/src/CrossSigningManager.js b/src/CrossSigningManager.js index f19be03574..2184eaf347 100644 --- a/src/CrossSigningManager.js +++ b/src/CrossSigningManager.js @@ -21,6 +21,7 @@ import { deriveKey } from 'matrix-js-sdk/src/crypto/key_passphrase'; import { decodeRecoveryKey } from 'matrix-js-sdk/src/crypto/recoverykey'; import { _t } from './languageHandler'; 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 // only meant to act as a cache to avoid prompting the user multiple times @@ -125,8 +126,37 @@ async function getSecretStorageKey({ keys: keyInfos }, ssssItemName) { 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; + } + const callbacks = client.getCrossSigningCacheCallbacks(); + if (!callbacks.getCrossSigningKeyCache) return; + if (name === "m.cross_signing.self_signing") { + const key = await callbacks.getCrossSigningKeyCache("self_signing"); + return key && encodeBase64(key); + } else if (name === "m.cross_signing.user_signing") { + const key = await callbacks.getCrossSigningKeyCache("user_signing"); + return key && encodeBase64(key); + } + console.warn("onSecretRequested didn't recognise the secret named ", name); +}; + export const crossSigningCallbacks = { getSecretStorageKey, + onSecretRequested, }; /** diff --git a/src/DeviceListener.js b/src/DeviceListener.js index 4e7bc8470d..6a506db496 100644 --- a/src/DeviceListener.js +++ b/src/DeviceListener.js @@ -99,9 +99,13 @@ export default class DeviceListener { } async _recheck() { - if (!SettingsStore.isFeatureEnabled("feature_cross_signing")) return; 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.getCrossSigningId()) { if (this._dismissedThisDeviceToast) { diff --git a/src/HtmlUtils.js b/src/HtmlUtils.js index 236aa0157e..a58ea25c8a 100644 --- a/src/HtmlUtils.js +++ b/src/HtmlUtils.js @@ -23,7 +23,6 @@ import ReplyThread from "./components/views/elements/ReplyThread"; import React from 'react'; import sanitizeHtml from 'sanitize-html'; -import highlight from 'highlight.js'; import * as linkify from 'linkifyjs'; import linkifyMatrix from './linkify-matrix'; import _linkifyElement from 'linkifyjs/element'; @@ -160,7 +159,7 @@ const transformTags = { // custom to matrix delete attribs.target; } } - attribs.rel = 'noopener'; // https://mathiasbynens.github.io/rel-noopener/ + attribs.rel = 'noreferrer noopener'; // https://mathiasbynens.github.io/rel-noopener/ return { tagName, attribs }; }, 'img': function(tagName, attribs) { @@ -467,11 +466,12 @@ export function bodyToHtml(content, highlights, opts={}) { /** * Linkifies the given string. This is a wrapper around 'linkifyjs/string'. * - * @param {string} str - * @returns {string} + * @param {string} str string to linkify + * @param {object} [options] Options for linkifyString. Default: linkifyMatrix.options + * @returns {string} Linkified string */ -export function linkifyString(str) { - return _linkifyString(str); +export function linkifyString(str, options = linkifyMatrix.options) { + return _linkifyString(str, options); } /** @@ -489,10 +489,11 @@ export function linkifyElement(element, options = linkifyMatrix.options) { * Linkify the given string and sanitize the HTML afterwards. * * @param {string} dirtyHtml The HTML string to sanitize and linkify + * @param {object} [options] Options for linkifyString. Default: linkifyMatrix.options * @returns {string} */ -export function linkifyAndSanitizeHtml(dirtyHtml) { - return sanitizeHtml(linkifyString(dirtyHtml), sanitizeHtmlParams); +export function linkifyAndSanitizeHtml(dirtyHtml, options = linkifyMatrix.options) { + return sanitizeHtml(linkifyString(dirtyHtml, options), sanitizeHtmlParams); } /** diff --git a/src/IdentityAuthClient.js b/src/IdentityAuthClient.js index 72432b9a44..4a830d6506 100644 --- a/src/IdentityAuthClient.js +++ b/src/IdentityAuthClient.js @@ -181,24 +181,12 @@ export default class IdentityAuthClient { } async registerForToken(check=true) { - try { - const hsOpenIdToken = await MatrixClientPeg.get().getOpenIdToken(); - // XXX: The spec is `token`, but we used `access_token` for a Sydent release. - const { access_token: accessToken, token } = - await this._matrixClient.registerWithIdentityServer(hsOpenIdToken); - const identityAccessToken = token ? token : accessToken; - if (check) await this._checkToken(identityAccessToken); - return identityAccessToken; - } catch (e) { - if (e.cors === "rejected" || e.httpStatus === 404) { - // Assume IS only supports deprecated v1 API for now - // TODO: Remove this path once v2 is only supported version - // See https://github.com/vector-im/riot-web/issues/10443 - console.warn("IS doesn't support v2 auth"); - this.authEnabled = false; - return; - } - throw e; - } + const hsOpenIdToken = await MatrixClientPeg.get().getOpenIdToken(); + // XXX: The spec is `token`, but we used `access_token` for a Sydent release. + const { access_token: accessToken, token } = + await this._matrixClient.registerWithIdentityServer(hsOpenIdToken); + const identityAccessToken = token ? token : accessToken; + if (check) await this._checkToken(identityAccessToken); + return identityAccessToken; } } diff --git a/src/Login.js b/src/Login.js index d9ce8adaaa..1590e5ac28 100644 --- a/src/Login.js +++ b/src/Login.js @@ -3,6 +3,7 @@ Copyright 2015, 2016 OpenMarket Ltd Copyright 2017 Vector Creations Ltd Copyright 2018 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. @@ -19,8 +20,6 @@ limitations under the License. import Matrix from "matrix-js-sdk"; -import url from 'url'; - export default class Login { constructor(hsUrl, isUrl, fallbackHsUrl, opts) { this._hsUrl = hsUrl; @@ -29,6 +28,7 @@ export default class Login { this._currentFlowIndex = 0; this._flows = []; this._defaultDeviceDisplayName = opts.defaultDeviceDisplayName; + this._tempClient = null; // memoize } getHomeserverUrl() { @@ -40,10 +40,12 @@ export default class Login { } setHomeserverUrl(hsUrl) { + this._tempClient = null; // clear memoization this._hsUrl = hsUrl; } setIdentityServerUrl(isUrl) { + this._tempClient = null; // clear memoization this._isUrl = isUrl; } @@ -52,8 +54,9 @@ export default class Login { * requests. * @returns {MatrixClient} */ - _createTemporaryClient() { - return Matrix.createClient({ + createTemporaryClient() { + if (this._tempClient) return this._tempClient; // use memoization + return this._tempClient = Matrix.createClient({ baseUrl: this._hsUrl, idBaseUrl: this._isUrl, }); @@ -61,7 +64,7 @@ export default class Login { getFlows() { const self = this; - const client = this._createTemporaryClient(); + const client = this.createTemporaryClient(); return client.loginFlows().then(function(result) { self._flows = result.flows; self._currentFlowIndex = 0; @@ -139,21 +142,6 @@ export default class Login { 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); - } } diff --git a/src/Markdown.js b/src/Markdown.js index 437ceec88b..fb1f8bf0ea 100644 --- a/src/Markdown.js +++ b/src/Markdown.js @@ -136,7 +136,7 @@ export default class Markdown { // thus opening in a new tab. if (externalLinks) { attrs.push(['target', '_blank']); - attrs.push(['rel', 'noopener']); + attrs.push(['rel', 'noreferrer noopener']); } this.tag('a', attrs); } else { diff --git a/src/Registration.js b/src/Registration.js index ac8baa3cca..ca162bac03 100644 --- a/src/Registration.js +++ b/src/Registration.js @@ -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 * @param {bool} options.go_welcome_on_cancel * 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) { if (options === undefined) options = {}; @@ -66,13 +68,21 @@ export async function startAnyRegistrationFlow(options) { // }); //} else { const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); - Modal.createTrackedDialog('Registration required', '', QuestionDialog, { - title: _t("Registration Required"), - description: _t("You need to register to do this. Would you like to register now?"), - button: _t("Register"), + const modal = Modal.createTrackedDialog('Registration required', '', QuestionDialog, { + hasCancelButton: true, + quitOnly: true, + title: _t("Sign In or Create Account"), + description: _t("Use your account or create a new one to continue."), + button: _t("Create Account"), + extraButtons: [ + , + ], onFinished: (proceed) => { if (proceed) { - dis.dispatch({action: 'start_registration'}); + dis.dispatch({action: 'start_registration', screenAfterLogin: options.screen_after}); } else if (options.go_home_on_cancel) { dis.dispatch({action: 'view_home_page'}); } else if (options.go_welcome_on_cancel) { @@ -101,4 +111,3 @@ export async function startAnyRegistrationFlow(options) { // } // throw new Error("Register request succeeded when it should have returned 401!"); // } - diff --git a/src/Rooms.js b/src/Rooms.js index f65e0ff218..218e970f35 100644 --- a/src/Rooms.js +++ b/src/Rooms.js @@ -23,7 +23,7 @@ import {MatrixClientPeg} from './MatrixClientPeg'; * of aliases. Otherwise return null; */ export function getDisplayAliasForRoom(room) { - return room.getCanonicalAlias() || room.getAliases()[0]; + return room.getCanonicalAlias() || room.getAltAliases()[0]; } /** diff --git a/src/SlashCommands.js b/src/SlashCommands.js index b39b8fb9ac..d306978f78 100644 --- a/src/SlashCommands.js +++ b/src/SlashCommands.js @@ -893,6 +893,26 @@ export const CommandMap = { }, category: CommandCategories.advanced, }), + + whois: new Command({ + name: "whois", + description: _td("Displays information about a user"), + args: '', + 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 */ diff --git a/src/TextForEvent.js b/src/TextForEvent.js index d4003058c8..6b1c1dcd2d 100644 --- a/src/TextForEvent.js +++ b/src/TextForEvent.js @@ -127,6 +127,13 @@ function textForRoomNameEvent(ev) { if (!ev.getContent().name || ev.getContent().name.trim().length === 0) { 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.', { senderDisplayName, roomName: ev.getContent().name, @@ -269,85 +276,55 @@ function textForMessageEvent(ev) { 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) { const senderName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); const oldAlias = ev.getPrevContent().alias; + const oldAltAliases = ev.getPrevContent().alt_aliases || []; 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) { - return _t('%(senderName)s set the main address for this room to %(address)s.', { - senderName: senderName, - address: ev.getContent().alias, - }); - } else if (oldAlias) { - return _t('%(senderName)s removed the main address for this room.', { + if (!removedAltAliases.length && !addedAltAliases.length) { + if (newAlias) { + return _t('%(senderName)s set the main address for this room to %(address)s.', { + senderName: senderName, + address: ev.getContent().alias, + }); + } 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, }); } + // 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) { @@ -612,7 +589,6 @@ const handlers = { }; const stateHandlers = { - 'm.room.aliases': textForRoomAliasesEvent, 'm.room.canonical_alias': textForCanonicalAliasEvent, 'm.room.name': textForRoomNameEvent, 'm.room.topic': textForTopicEvent, diff --git a/src/actions/RoomListActions.js b/src/actions/RoomListActions.js index d534fe5d1d..10a3848dda 100644 --- a/src/actions/RoomListActions.js +++ b/src/actions/RoomListActions.js @@ -15,7 +15,7 @@ limitations under the License. */ import { asyncAction } from './actionCreators'; -import RoomListStore from '../stores/RoomListStore'; +import RoomListStore, {TAG_DM} from '../stores/RoomListStore'; import Modal from '../Modal'; import * as Rooms from '../Rooms'; import { _t } from '../languageHandler'; @@ -73,11 +73,11 @@ RoomListActions.tagRoom = function(matrixClient, room, oldTag, newTag, oldIndex, const roomId = room.roomId; // Evil hack to get DMs behaving - if ((oldTag === undefined && newTag === 'im.vector.fake.direct') || - (oldTag === 'im.vector.fake.direct' && newTag === undefined) + if ((oldTag === undefined && newTag === TAG_DM) || + (oldTag === TAG_DM && newTag === undefined) ) { return Rooms.guessAndSetDMRoom( - room, newTag === 'im.vector.fake.direct', + room, newTag === TAG_DM, ).catch((err) => { const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); 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; // 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 (oldTag && oldTag !== 'im.vector.fake.direct' && + if (oldTag && oldTag !== TAG_DM && hasChangedSubLists ) { 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 (newTag && newTag !== 'im.vector.fake.direct' && + if (newTag && newTag !== TAG_DM && (hasChangedSubLists || metaData) ) { // metaData is the body of the PUT to set the tag, so it must diff --git a/src/async-components/views/dialogs/eventindex/ManageEventIndexDialog.js b/src/async-components/views/dialogs/eventindex/ManageEventIndexDialog.js index f3ea3beb1c..371fdcaf64 100644 --- a/src/async-components/views/dialogs/eventindex/ManageEventIndexDialog.js +++ b/src/async-components/views/dialogs/eventindex/ManageEventIndexDialog.js @@ -141,15 +141,17 @@ export default class ManageEventIndexDialog extends React.Component { let crawlerState; if (this.state.currentRoom === null) { - crawlerState = _t("Not currently downloading messages for any room."); + crawlerState = _t("Not currently indexing messages for any room."); } else { 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 doneRooms = Math.max(0, (this.state.roomCount - this.state.crawlingRoomsCount)); + const eventIndexingSettings = (
{ @@ -158,13 +160,13 @@ export default class ManageEventIndexDialog extends React.Component { ) }
+ {crawlerState}
{_t("Space used:")} {formatBytes(this.state.eventIndexSize, 0)}
{_t("Indexed messages:")} {formatCountLong(this.state.eventCount)}
- {_t("Indexed rooms:")} {_t("%(crawlingRooms)s out of %(totalRooms)s", { - crawlingRooms: formatCountLong(this.state.crawlingRoomsCount), + {_t("Indexed rooms:")} {_t("%(doneRooms)s out of %(totalRooms)s", { + doneRooms: formatCountLong(doneRooms), totalRooms: formatCountLong(this.state.roomCount), })}
- {crawlerState}
-
{_t("Restore your key backup to upgrade your encryption")}
-
; - nextCaption = _t("Restore"); - } else if (this.state.canUploadKeysWithPasswordOnly) { + if (this.state.canUploadKeysWithPasswordOnly) { authPrompt =
{_t("Enter your account password to confirm the upgrade:")}
; + } else if (!this.state.backupSigStatus.usable) { + authPrompt =
+
{_t("Restore your key backup to upgrade your encryption")}
+
; + nextCaption = _t("Restore"); } else { authPrompt =

{_t("You'll need to authenticate with the server to confirm the upgrade.")} @@ -433,6 +438,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent {

{authPrompt}
diff --git a/src/autocomplete/RoomProvider.js b/src/autocomplete/RoomProvider.js index fccf1e3524..a0f670e769 100644 --- a/src/autocomplete/RoomProvider.js +++ b/src/autocomplete/RoomProvider.js @@ -23,7 +23,6 @@ import AutocompleteProvider from './AutocompleteProvider'; import {MatrixClientPeg} from '../MatrixClientPeg'; import QueryMatcher from './QueryMatcher'; import {PillCompletion} from './Components'; -import {getDisplayAliasForRoom} from '../Rooms'; import * as sdk from '../index'; import _sortBy from 'lodash/sortBy'; import {makeRoomPermalink} from "../utils/permalinks/Permalinks"; @@ -40,11 +39,19 @@ function score(query, space) { } } +function matcherObject(room, displayedAlias, matchName = "") { + return { + room, + matchName, + displayedAlias, + }; +} + export default class RoomProvider extends AutocompleteProvider { constructor() { super(ROOM_REGEX); this.matcher = new QueryMatcher([], { - keys: ['displayedAlias', 'name'], + keys: ['displayedAlias', 'matchName'], }); } @@ -56,16 +63,16 @@ export default class RoomProvider extends AutocompleteProvider { const {command, range} = this.getCurrentCommand(query, selection, force); if (command) { // the only reason we need to do this is because Fuse only matches on properties - let matcherObjects = client.getVisibleRooms().filter( - (room) => !!room && !!getDisplayAliasForRoom(room), - ).map((room) => { - return { - room: room, - name: room.name, - displayedAlias: getDisplayAliasForRoom(room), - }; - }); - + let matcherObjects = client.getVisibleRooms().reduce((aliases, room) => { + if (room.getCanonicalAlias()) { + aliases = aliases.concat(matcherObject(room, room.getCanonicalAlias(), room.name)); + } + if (room.getAltAliases().length) { + const altAliases = room.getAltAliases().map(alias => matcherObject(room, alias)); + aliases = aliases.concat(altAliases); + } + return aliases; + }, []); // Filter out any matches where the user will have also autocompleted new rooms matcherObjects = matcherObjects.filter((r) => { const tombstone = r.room.currentState.getStateEvents("m.room.tombstone", ""); @@ -84,16 +91,16 @@ export default class RoomProvider extends AutocompleteProvider { completions = _sortBy(completions, [ (c) => score(matchedString, c.displayedAlias), (c) => c.displayedAlias.length, - ]).map((room) => { - const displayAlias = getDisplayAliasForRoom(room.room) || room.roomId; + ]); + completions = completions.map((room) => { return { - completion: displayAlias, - completionId: displayAlias, + completion: room.displayedAlias, + completionId: room.room.roomId, type: "room", suffix: ' ', - href: makeRoomPermalink(displayAlias), + href: makeRoomPermalink(room.displayedAlias), component: ( - } title={room.name} description={displayAlias} /> + } title={room.room.name} description={room.displayedAlias} /> ), range, }; diff --git a/src/components/structures/ContextMenu.js b/src/components/structures/ContextMenu.js index b4b1b80163..898991f4f2 100644 --- a/src/components/structures/ContextMenu.js +++ b/src/components/structures/ContextMenu.js @@ -255,7 +255,7 @@ export class ContextMenu extends React.Component { if (chevronFace === 'top' || chevronFace === 'bottom') { chevronOffset.left = props.chevronOffset; - } else { + } else if (position.top !== undefined) { const target = position.top; // By default, no adjustment is made diff --git a/src/components/structures/GroupView.js b/src/components/structures/GroupView.js index 5ae0699a2f..af90fbbe83 100644 --- a/src/components/structures/GroupView.js +++ b/src/components/structures/GroupView.js @@ -481,7 +481,7 @@ export default createReactClass({ group_id: groupId, }, }); - dis.dispatch({action: 'require_registration'}); + dis.dispatch({action: 'require_registration', screen_after: {screen: `group/${groupId}`}}); willDoOnboarding = true; } if (stateKey === GroupStore.STATE_KEY.Summary) { @@ -726,7 +726,7 @@ export default createReactClass({ _onJoinClick: async function() { if (this._matrixClient.isGuest()) { - dis.dispatch({action: 'require_registration'}); + dis.dispatch({action: 'require_registration', screen_after: {screen: `group/${this.props.groupId}`}}); return; } @@ -821,10 +821,10 @@ export default createReactClass({ {_t( "Want more than a community? Get your own server", {}, { - a: sub => {sub}, + a: sub => {sub}, }, )} - +
; diff --git a/src/components/structures/InteractiveAuth.js b/src/components/structures/InteractiveAuth.js index 3d63029b06..f4adb5751f 100644 --- a/src/components/structures/InteractiveAuth.js +++ b/src/components/structures/InteractiveAuth.js @@ -161,6 +161,7 @@ export default createReactClass({ _authStateUpdated: function(stageType, stageState) { const oldStage = this.state.authStage; this.setState({ + busy: false, authStage: stageType, stageState: stageState, errorText: stageState.error, @@ -184,11 +185,13 @@ export default createReactClass({ errorText: 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() { diff --git a/src/components/structures/LoggedInView.js b/src/components/structures/LoggedInView.js index 9597f99cd2..d643c82120 100644 --- a/src/components/structures/LoggedInView.js +++ b/src/components/structures/LoggedInView.js @@ -337,13 +337,13 @@ const LoggedInView = createReactClass({ let handled = false; const ctrlCmdOnly = isOnlyCtrlOrCmdKeyEvent(ev); - 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 hasModifier = ev.altKey || ev.ctrlKey || ev.metaKey || ev.shiftKey; + const isModifier = ev.key === Key.ALT || ev.key === Key.CONTROL || ev.key === Key.META || ev.key === Key.SHIFT; switch (ev.key) { case Key.PAGE_UP: case Key.PAGE_DOWN: - if (!hasModifier) { + if (!hasModifier && !isModifier) { this._onScrollKeyPressed(ev); handled = true; } @@ -384,7 +384,10 @@ const LoggedInView = createReactClass({ if (handled) { ev.stopPropagation(); 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 && (ev.key === Key.SPACE || ev.key === Key.ENTER); @@ -585,7 +588,8 @@ const LoggedInView = createReactClass({ limitType={usageLimitEvent.getContent().limit_type} />; } else if (this.props.showCookieBar && - this.props.config.piwik + this.props.config.piwik && + navigator.doNotTrack !== "1" ) { const policyUrl = this.props.config.piwik.policyUrl || null; topBar = ; diff --git a/src/components/structures/MainSplit.js b/src/components/structures/MainSplit.js index 772be358cf..7c66f21a04 100644 --- a/src/components/structures/MainSplit.js +++ b/src/components/structures/MainSplit.js @@ -93,14 +93,19 @@ export default class MainSplit extends React.Component { const bodyView = React.Children.only(this.props.children); const panelView = this.props.panel; - if (this.props.collapsedRhs || !panelView) { - return bodyView; - } else { - return
- { bodyView } + const hasResizer = !this.props.collapsedRhs && panelView; + + let children; + if (hasResizer) { + children = { panelView } -
; + ; } + + return
+ { bodyView } + { children } +
; } } diff --git a/src/components/structures/MatrixChat.js b/src/components/structures/MatrixChat.js index 229c741310..f1a5a372be 100644 --- a/src/components/structures/MatrixChat.js +++ b/src/components/structures/MatrixChat.js @@ -559,13 +559,19 @@ export default createReactClass({ case 'view_user_info': this._viewUser(payload.userId, payload.subAction); break; - case 'view_room': + case 'view_room': { // 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 // 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. - this._viewRoom(payload); + const promise = this._viewRoom(payload); + if (payload.deferred_action) { + promise.then(() => { + dis.dispatch(payload.deferred_action); + }); + } break; + } case 'view_prev_room': this._viewNextRoom(-1); break; @@ -862,7 +868,7 @@ export default createReactClass({ waitFor = this.firstSyncPromise.promise; } - waitFor.then(() => { + return waitFor.then(() => { let presentedId = roomInfo.room_alias || roomInfo.room_id; const room = MatrixClientPeg.get().getRoom(roomInfo.room_id); if (room) { @@ -885,7 +891,7 @@ export default createReactClass({ presentedId += "/" + roomInfo.event_id; } newState.ready = true; - this.setState(newState, ()=>{ + this.setState(newState, () => { this.notifyNewScreen('room/' + presentedId); }); }); @@ -1008,6 +1014,10 @@ export default createReactClass({ // needs to be reset so that they can revisit /user/.. // (and trigger // `_chatCreateOrReuse` again) go_welcome_on_cancel: true, + screen_after: { + screen: `user/${this.props.config.welcomeUserId}`, + params: { action: 'chat' }, + }, }); return; } @@ -1175,8 +1185,17 @@ export default createReactClass({ * Called when a new logged in session has started */ _onLoggedIn: async function() { + ThemeController.isLogin = false; 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); if (this.props.config.welcomeUserId && getCurrentLanguage().startsWith("en")) { @@ -1374,7 +1393,8 @@ export default createReactClass({ cancelButton: _t('Dismiss'), onFinished: (confirmed) => { if (confirmed) { - window.open(consentUri, '_blank'); + const wnd = window.open(consentUri, '_blank'); + wnd.opener = null; } }, }, null, true); @@ -1475,26 +1495,29 @@ export default createReactClass({ } }); - if (SettingsStore.isFeatureEnabled("feature_cross_signing")) { - cli.on("crypto.verification.request", request => { - if (request.pending) { - ToastStore.sharedInstance().addOrReplaceToast({ - key: 'verifreq_' + request.channel.transactionId, - title: _t("Verification Request"), - icon: "verification", - props: {request}, - component: sdk.getComponent("toasts.VerificationRequestToast"), - }); - } - }); - } else { - cli.on("crypto.verification.start", (verifier) => { + cli.on("crypto.verification.request", request => { + const isFlagOn = SettingsStore.isFeatureEnabled("feature_cross_signing"); + + 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"); Modal.createTrackedDialog('Incoming Verification', '', IncomingSasDialog, { - verifier, + verifier: request.verifier, }, 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"), + }); + } + }); // 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 const colorScheme = SettingsStore.getValue("roomColor"); @@ -1888,7 +1911,10 @@ export default createReactClass({ // secret storage. SettingsStore.setFeatureEnabled("feature_cross_signing", true); 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, // since it's too early in the lifecycle for users to have turned the // labs flag on. diff --git a/src/components/structures/MessagePanel.js b/src/components/structures/MessagePanel.js index b8b11fbb31..a2ac93d282 100644 --- a/src/components/structures/MessagePanel.js +++ b/src/components/structures/MessagePanel.js @@ -28,6 +28,7 @@ import {MatrixClientPeg} from '../../MatrixClientPeg'; import SettingsStore from '../../settings/SettingsStore'; import {_t} from "../../languageHandler"; import {haveTileForEvent} from "../views/rooms/EventTile"; +import {textForEvent} from "../../TextForEvent"; const CONTINUATION_MAX_INTERVAL = 5 * 60 * 1000; // 5 minutes const continuedTypes = ['m.sticker', 'm.room.message']; @@ -873,6 +874,11 @@ class CreationGrouper { } 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 EventListSummary = sdk.getComponent('views.elements.EventListSummary'); @@ -950,15 +956,30 @@ class MemberGrouper { } shouldGroup(ev) { + if (this.panel._wantsDateSeparator(this.events[0], ev.getDate())) { + return false; + } return isMembershipChange(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.events.push(ev); } 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 MemberEventListSummary = sdk.getComponent('views.elements.MemberEventListSummary'); diff --git a/src/components/structures/RightPanel.js b/src/components/structures/RightPanel.js index 20df323c10..8d25116827 100644 --- a/src/components/structures/RightPanel.js +++ b/src/components/structures/RightPanel.js @@ -182,6 +182,7 @@ export default class RightPanel extends React.Component { member: payload.member, event: payload.event, verificationRequest: payload.verificationRequest, + verificationRequestPromise: payload.verificationRequestPromise, }); } } @@ -231,6 +232,7 @@ export default class RightPanel extends React.Component { onClose={onClose} phase={this.state.phase} verificationRequest={this.state.verificationRequest} + verificationRequestPromise={this.state.verificationRequestPromise} />; } else { panel = { + }).catch((err) => { if (this.unmounted) { return; } @@ -355,7 +355,7 @@ export default createReactClass({ // 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, // 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.setState({ peekLoading: false, @@ -365,8 +365,6 @@ export default createReactClass({ } }); } else if (room) { - //viewing a previously joined room, try to lazy load members - // Stop peeking because we have joined this room previously MatrixClientPeg.get().stopPeeking(); this.setState({isPeeking: false}); @@ -460,8 +458,6 @@ export default createReactClass({ // (We could use isMounted, but facebook have deprecated that.) this.unmounted = true; - SettingsStore.unwatchSetting(this._ciderWatcherRef); - // update the scroll map before we get unmounted if (this.state.roomId) { RoomScrollStateStore.setScrollState(this.state.roomId, this._getScrollState()); @@ -618,6 +614,22 @@ export default createReactClass({ this.onCancelSearchClick(); } 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; } }, diff --git a/src/components/structures/ScrollPanel.js b/src/components/structures/ScrollPanel.js index 5121dd3f9d..b81b3ebede 100644 --- a/src/components/structures/ScrollPanel.js +++ b/src/components/structures/ScrollPanel.js @@ -523,7 +523,7 @@ export default createReactClass({ scrollRelative: function(mult) { const scrollNode = this._getScrollNode(); const delta = mult * scrollNode.clientHeight * 0.5; - scrollNode.scrollTop = scrollNode.scrollTop + delta; + scrollNode.scrollBy(0, delta); this._saveScrollState(); }, @@ -705,17 +705,15 @@ export default createReactClass({ // the currently filled piece of the timeline if (trackedNode) { const oldTop = trackedNode.offsetTop; - // changing the height might change the scrollTop - // if the new height is smaller than the scrollTop. - // We calculate the diff that needs to be applied - // ourselves, so be sure to measure the - // scrollTop before changing the height. - const preexistingScrollTop = sn.scrollTop; itemlist.style.height = `${newHeight}px`; const newTop = trackedNode.offsetTop; const topDiff = newTop - oldTop; - sn.scrollTop = preexistingScrollTop + topDiff; - debuglog("updateHeight to", {newHeight, topDiff, preexistingScrollTop}); + // important to scroll by a relative amount as + // 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) { + // current capped height - distance from top = distance from bottom of container to top of tracked element return this._itemlist.current.clientHeight - node.offsetTop; }, diff --git a/src/components/structures/TabbedView.js b/src/components/structures/TabbedView.tsx similarity index 80% rename from src/components/structures/TabbedView.js rename to src/components/structures/TabbedView.tsx index 20af183af8..ea485acc1a 100644 --- a/src/components/structures/TabbedView.js +++ b/src/components/structures/TabbedView.tsx @@ -1,7 +1,7 @@ /* Copyright 2017 Travis Ralston 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"); 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 {_t} from '../../languageHandler'; -import PropTypes from "prop-types"; +import * as PropTypes from "prop-types"; import * as sdk from "../../index"; +import { ReactNode } from "react"; /** * Represents a tab for the TabbedView. */ export class Tab { + public label: string; + public icon: string; + public body: React.ReactNode; + /** * Creates a new tab. * @param {string} tabLabel The untranslated tab label. * @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.icon = tabIconClass; 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 { static propTypes = { // The tabs to show tabs: PropTypes.arrayOf(PropTypes.instanceOf(Tab)).isRequired, }; - constructor() { - super(); + constructor(props: IProps) { + super(props); this.state = { activeTabIndex: 0, }; } - _getActiveTabIndex() { + private _getActiveTabIndex() { if (!this.state || !this.state.activeTabIndex) return 0; return this.state.activeTabIndex; } @@ -62,7 +75,7 @@ export default class TabbedView extends React.Component { * @param {Tab} tab the tab to show * @private */ - _setActiveTab(tab) { + private _setActiveTab(tab: Tab) { const idx = this.props.tabs.indexOf(tab); if (idx !== -1) { 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'); 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 (
@@ -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 panel = this._renderTabPanel(this.props.tabs[this._getActiveTabIndex()]); diff --git a/src/components/structures/auth/CompleteSecurity.js b/src/components/structures/auth/CompleteSecurity.js index 6bf3e7f07c..3154564cd3 100644 --- a/src/components/structures/auth/CompleteSecurity.js +++ b/src/components/structures/auth/CompleteSecurity.js @@ -54,17 +54,39 @@ export default class CompleteSecurity extends React.Component { } } - onStartClick = async () => { + _onUsePassphraseClick = 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); + const backupInfo = await cli.getKeyBackupVersion(); + this.setState({backupInfo}); + + // The control flow is fairly twisted here... + // For the purposes of completing security, we only wait on getting + // as far as the trust check and then show a green shield. + // We also begin the key backup restore as well, which we're + // awaiting inside `accessSecretStorage` only so that it keeps your + // passphase cached for that work. This dialog itself will only wait + // on the first trust check, and the key backup restore will happen + // in the background. + await new Promise((resolve, reject) => { + try { + accessSecretStorage(async () => { + await cli.checkOwnCrossSigningTrust(); + resolve(); + if (backupInfo) { + // A complete restore can take many minutes for large + // accounts / slow servers, so we allow the dialog + // to advance before this. + await cli.restoreKeyBackupWithSecretStorage(backupInfo); + } + }); + } catch (e) { + console.error(e); + reject(e); + } }); if (cli.getCrossSigningId()) { @@ -147,13 +169,27 @@ export default class CompleteSecurity extends React.Component { member={MatrixClientPeg.get().getUser(this.state.verificationRequest.otherUserId)} />; } else if (phase === PHASE_INTRO) { + const InlineSpinner = sdk.getComponent('elements.InlineSpinner'); + icon = ; title = _t("Complete security"); body = (

{_t( - "Verify this session to grant it access to encrypted messages.", + "Open an existing session & use it to verify this one, " + + "granting it access to encrypted messages.", )}

+

{_t("Waiting…")}

+

{_t( + "If you can’t access one, ", + {}, { + button: sub => + {sub} + , + })}

{_t("Skip")} - - {_t("Start")} -
); diff --git a/src/components/structures/auth/Login.js b/src/components/structures/auth/Login.js index c8b2a1ea9c..bfabc34a62 100644 --- a/src/components/structures/auth/Login.js +++ b/src/components/structures/auth/Login.js @@ -27,6 +27,8 @@ import { messageForResourceLimitError } from '../../../utils/ErrorUtils'; import AutoDiscoveryUtils, {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils"; import classNames from "classnames"; import AuthPage from "../../views/auth/AuthPage"; +import SSOButton from "../../views/elements/SSOButton"; +import PlatformPeg from '../../../PlatformPeg'; // For validating phone numbers without country codes const PHONE_NUMBER_REGEX = /^[0-9()\-\s]*$/; @@ -120,8 +122,8 @@ export default createReactClass({ 'm.login.password': this._renderPasswordStep, // CAS and SSO are the same thing, modulo the url we link to - 'm.login.cas': () => this._renderSsoStep(this._loginLogic.getSsoLoginUrl("cas")), - 'm.login.sso': () => this._renderSsoStep(this._loginLogic.getSsoLoginUrl("sso")), + 'm.login.cas': () => this._renderSsoStep("cas"), + 'm.login.sso': () => this._renderSsoStep("sso"), }; this._initLoginLogic(); @@ -245,6 +247,7 @@ export default createReactClass({ } this.setState({ + busy: false, errorText: errorText, // 401 would be the sensible status code for 'incorrect password' // 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 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(); }, + 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() { this.setState({ phase: PHASE_LOGIN, @@ -481,7 +492,7 @@ export default createReactClass({ "Either use HTTPS or enable unsafe scripts.", {}, { 'a': (sub) => { - return { sub } @@ -496,11 +507,10 @@ export default createReactClass({ "homeserver's SSL certificate is trusted, and that a browser extension " + "is not blocking requests.", {}, { - 'a': (sub) => { - return + 'a': (sub) => + { sub } - ; - }, + , }, ) } ; @@ -586,7 +596,7 @@ export default createReactClass({ ); }, - _renderSsoStep: function(url) { + _renderSsoStep: function(loginType) { const SignInToText = sdk.getComponent('views.auth.SignInToText'); let onEditServerDetailsClick = null; @@ -607,7 +617,10 @@ export default createReactClass({ - { _t('Sign in with single sign-on') } +
); }, @@ -655,7 +668,7 @@ export default createReactClass({ { serverDeadSection } { this.renderServerComponent() } { this.renderLoginComponentForStep() } - + { _t('Create account') } diff --git a/src/components/structures/auth/Registration.js b/src/components/structures/auth/Registration.js index 8593a4b1e2..7c6a3ea56f 100644 --- a/src/components/structures/auth/Registration.js +++ b/src/components/structures/auth/Registration.js @@ -31,6 +31,8 @@ import classNames from "classnames"; import * as Lifecycle from '../../../Lifecycle'; import {MatrixClientPeg} from "../../../MatrixClientPeg"; import AuthPage from "../../views/auth/AuthPage"; +import Login from "../../../Login"; +import dis from "../../../dispatcher"; // Phases // Show controls to configure server details @@ -232,6 +234,13 @@ export default createReactClass({ serverRequiresIdServer, 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 { await this._makeRegisterRequest({}); // This should never succeed since we specified an empty @@ -243,18 +252,32 @@ export default createReactClass({ flows: e.data.flows, }); } else if (e.httpStatus === 403 && e.errcode === "M_UNKNOWN") { - this.setState({ - errorText: _t("Registration has been disabled on this homeserver."), - // add empty flows array to get rid of spinner - flows: [], - }); + // At this point registration is pretty much disabled, but before we do that let's + // quickly check to see if the server supports SSO instead. If it does, we'll send + // the user off to the login page to figure their account out. + 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 { console.log("Unable to query for supported registration methods.", e); - this.setState({ - errorText: _t("Unable to query for supported registration methods."), - // add empty flows array to get rid of spinner - flows: [], - }); + showGenericError(e); } } }, diff --git a/src/components/structures/auth/SoftLogout.js b/src/components/structures/auth/SoftLogout.js index 8481b3fc43..d38fcf3883 100644 --- a/src/components/structures/auth/SoftLogout.js +++ b/src/components/structures/auth/SoftLogout.js @@ -23,8 +23,8 @@ import * as Lifecycle from '../../../Lifecycle'; import Modal from '../../../Modal'; import {MatrixClientPeg} from "../../../MatrixClientPeg"; import {sendLoginRequest} from "../../../Login"; -import url from 'url'; import AuthPage from "../../views/auth/AuthPage"; +import SSOButton from "../../views/elements/SSOButton"; const LOGIN_VIEW = { LOADING: 1, @@ -55,7 +55,6 @@ export default class SoftLogout extends React.Component { this.state = { loginView: LOGIN_VIEW.LOADING, keyBackupNeeded: true, // assume we do while we figure it out (see componentWillMount) - ssoUrl: null, busy: false, password: "", @@ -105,18 +104,6 @@ export default class SoftLogout extends React.Component { const chosenView = loginViews.filter(f => !!f)[0] || LOGIN_VIEW.UNSUPPORTED; this.setState({loginView: chosenView}); - - if (chosenView === LOGIN_VIEW.CAS || chosenView === LOGIN_VIEW.SSO) { - const client = MatrixClientPeg.get(); - - const appUrl = url.parse(window.location.href, true); - appUrl.hash = ""; // Clear #/soft_logout off the URL - appUrl.query["homeserver"] = client.getHomeserverUrl(); - appUrl.query["identityServer"] = client.getIdentityServerUrl(); - - const ssoUrl = client.getSsoLoginUrl(url.format(appUrl), chosenView === LOGIN_VIEW.CAS ? "cas" : "sso"); - this.setState({ssoUrl}); - } } onPasswordChange = (ev) => { @@ -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() { if (this.state.loginView === LOGIN_VIEW.LOADING) { 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) { - const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); - if (!introText) { introText = _t("Sign in and regain access to your account."); } // else we already have a message and should use it (key backup warning) @@ -266,9 +243,9 @@ export default class SoftLogout extends React.Component { return (

{introText}

- - {_t('Sign in with single sign-on')} - +
); } diff --git a/src/components/views/auth/AuthFooter.js b/src/components/views/auth/AuthFooter.js index 4076141606..1309800772 100644 --- a/src/components/views/auth/AuthFooter.js +++ b/src/components/views/auth/AuthFooter.js @@ -26,7 +26,7 @@ export default createReactClass({ render: function() { return ( ); }, diff --git a/src/components/views/auth/InteractiveAuthEntryComponents.js b/src/components/views/auth/InteractiveAuthEntryComponents.js index 6f6eb7e2a1..aaf8c88440 100644 --- a/src/components/views/auth/InteractiveAuthEntryComponents.js +++ b/src/components/views/auth/InteractiveAuthEntryComponents.js @@ -331,7 +331,7 @@ export const TermsAuthEntry = createReactClass({ checkboxes.push( , ); } @@ -604,6 +604,7 @@ export const FallbackAuthEntry = createReactClass({ this.props.authSessionId, ); this._popupWindow = window.open(url); + this._popupWindow.opener = null; }, _onReceiveMessage: function(event) { diff --git a/src/components/views/auth/ModularServerConfig.js b/src/components/views/auth/ModularServerConfig.js index 32418d3462..d8ce145e20 100644 --- a/src/components/views/auth/ModularServerConfig.js +++ b/src/components/views/auth/ModularServerConfig.js @@ -99,7 +99,7 @@ export default class ModularServerConfig extends ServerConfig { "Enter the location of your Modular homeserver. It may use your own " + "domain name or be a subdomain of modular.im.", {}, { - a: sub => + a: sub => {sub} , }, diff --git a/src/components/views/auth/ServerTypeSelector.js b/src/components/views/auth/ServerTypeSelector.js index 341f81c546..fe29b7f76c 100644 --- a/src/components/views/auth/ServerTypeSelector.js +++ b/src/components/views/auth/ServerTypeSelector.js @@ -46,7 +46,7 @@ export const TYPES = { label: () => _t('Premium'), logo: () => , description: () => _t('Premium hosting for organisations Learn more', {}, { - a: sub => + a: sub => {sub} , }), diff --git a/src/components/views/context_menus/MessageContextMenu.js b/src/components/views/context_menus/MessageContextMenu.js index ea5623fe48..4fc6dd58cc 100644 --- a/src/components/views/context_menus/MessageContextMenu.js +++ b/src/components/views/context_menus/MessageContextMenu.js @@ -90,7 +90,8 @@ export default createReactClass({ const room = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId()); const pinnedEvent = room.currentState.getStateEvents('m.room.pinned_events', ''); 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() { @@ -420,7 +421,7 @@ export default createReactClass({ onClick={this.onPermalinkClick} href={permalink} target="_blank" - rel="noopener" + rel="noreferrer noopener" > { mxEvent.isRedacted() || mxEvent.getType() !== 'm.room.message' ? _t('Share Permalink') : _t('Share Message') } @@ -445,7 +446,7 @@ export default createReactClass({ element="a" className="mx_MessageContextMenu_field" target="_blank" - rel="noopener" + rel="noreferrer noopener" onClick={this.closeMenu} href={mxEvent.event.content.external_url} > diff --git a/src/components/views/context_menus/TopLeftMenu.js b/src/components/views/context_menus/TopLeftMenu.js index 51ec202b90..f1309cac2d 100644 --- a/src/components/views/context_menus/TopLeftMenu.js +++ b/src/components/views/context_menus/TopLeftMenu.js @@ -68,10 +68,11 @@ export default class TopLeftMenu extends React.Component { {_t( "Upgrade to your own domain", {}, { - a: sub => {sub}, + a: sub => + {sub}, }, )} - +
; diff --git a/src/components/views/dialogs/AskInviteAnywayDialog.js b/src/components/views/dialogs/AskInviteAnywayDialog.js index 7fa6069478..120ad8deca 100644 --- a/src/components/views/dialogs/AskInviteAnywayDialog.js +++ b/src/components/views/dialogs/AskInviteAnywayDialog.js @@ -72,7 +72,7 @@ export default createReactClass({ - diff --git a/src/components/views/dialogs/ChangelogDialog.js b/src/components/views/dialogs/ChangelogDialog.js index e58f56a639..ab284cdb2e 100644 --- a/src/components/views/dialogs/ChangelogDialog.js +++ b/src/components/views/dialogs/ChangelogDialog.js @@ -52,7 +52,7 @@ export default class ChangelogDialog extends React.Component { _elementsForCommit(commit) { return (
  • - + {commit.commit.message.split('\n')[0]}
  • diff --git a/src/components/views/dialogs/DevtoolsDialog.js b/src/components/views/dialogs/DevtoolsDialog.js index 34b2f5a52b..348965582b 100644 --- a/src/components/views/dialogs/DevtoolsDialog.js +++ b/src/components/views/dialogs/DevtoolsDialog.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from 'react'; +import React, {useState, useEffect} from 'react'; import PropTypes from 'prop-types'; import * as sdk from '../../../index'; import SyntaxHighlight from '../elements/SyntaxHighlight'; @@ -22,6 +22,16 @@ import { _t } from '../../../languageHandler'; import { Room } from "matrix-js-sdk"; import Field from "../elements/Field"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; +import {useEventEmitter} from "../../../hooks/useEventEmitter"; + +import { + PHASE_UNSENT, + PHASE_REQUESTED, + PHASE_READY, + PHASE_DONE, + PHASE_STARTED, + PHASE_CANCELLED, +} from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest"; class GenericEditor extends React.PureComponent { // static propTypes = {onBack: PropTypes.func.isRequired}; @@ -605,12 +615,97 @@ class ServersInRoomList extends React.PureComponent { } } +const PHASE_MAP = { + [PHASE_UNSENT]: "unsent", + [PHASE_REQUESTED]: "requested", + [PHASE_READY]: "ready", + [PHASE_DONE]: "done", + [PHASE_STARTED]: "started", + [PHASE_CANCELLED]: "cancelled", +}; + +function VerificationRequest({txnId, request}) { + const [, updateState] = useState(); + const [timeout, setRequestTimeout] = useState(request.timeout); + + /* Re-render if something changes state */ + useEventEmitter(request, "change", updateState); + + /* Keep re-rendering if there's a timeout */ + useEffect(() => { + if (request.timeout == 0) return; + + /* Note that request.timeout is a getter, so its value changes */ + const id = setInterval(() => { + setRequestTimeout(request.timeout); + }, 500); + + return () => { clearInterval(id); }; + }, [request]); + + return (
    +
    +
    Transaction
    +
    {txnId}
    +
    Phase
    +
    {PHASE_MAP[request.phase] || request.phase}
    +
    Timeout
    +
    {Math.floor(timeout / 1000)}
    +
    Methods
    +
    {request.methods && request.methods.join(", ")}
    +
    requestingUserId
    +
    {request.requestingUserId}
    +
    observeOnly
    +
    {JSON.stringify(request.observeOnly)}
    +
    +
    ); +} + +class VerificationExplorer extends React.Component { + static getLabel() { + return _t("Verification Requests"); + } + + /* Ensure this.context is the cli */ + static contextType = MatrixClientContext; + + onNewRequest = () => { + this.forceUpdate(); + } + + componentDidMount() { + const cli = this.context; + cli.on("crypto.verification.request", this.onNewRequest); + } + + componentWillUnmount() { + const cli = this.context; + cli.off("crypto.verification.request", this.onNewRequest); + } + + render() { + const cli = this.context; + const room = this.props.room; + const inRoomChannel = cli._crypto._inRoomVerificationRequests; + const inRoomRequests = (inRoomChannel._requestsByRoomId || new Map()).get(room.roomId) || new Map(); + + return (
    +
    + {Array.from(inRoomRequests.entries()).reverse().map(([txnId, request]) => + , + )} +
    +
    ); + } +} + const Entries = [ SendCustomEvent, RoomStateExplorer, SendAccountData, AccountDataExplorer, ServersInRoomList, + VerificationExplorer, ]; export default class DevtoolsDialog extends React.PureComponent { diff --git a/src/components/views/dialogs/InviteDialog.js b/src/components/views/dialogs/InviteDialog.js index 8c9c5f75ef..d27a66165e 100644 --- a/src/components/views/dialogs/InviteDialog.js +++ b/src/components/views/dialogs/InviteDialog.js @@ -34,6 +34,7 @@ import {humanizeTime} from "../../../utils/humanize"; import createRoom, {canEncryptToAllUsers} from "../../../createRoom"; import {inviteMultipleToRoom} from "../../../RoomInvite"; import SettingsStore from '../../../settings/SettingsStore'; +import RoomListStore, {TAG_DM} from "../../../stores/RoomListStore"; export const KIND_DM = "dm"; export const KIND_INVITE = "invite"; @@ -218,7 +219,7 @@ class DMRoomTile extends React.PureComponent { } // Push any text we missed (end of text) - if (i < (str.length - 1)) { + if (i < str.length) { result.push({str.substring(i)}); } @@ -332,7 +333,23 @@ export default class InviteDialog extends React.PureComponent { } _buildRecents(excludedTargetIds: Set): {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 = []; for (const userId in rooms) { // Filter out user IDs that are already in the room / should be excluded @@ -512,9 +529,27 @@ export default class InviteDialog extends React.PureComponent { return false; } + _convertFilter(): Member[] { + // Check to see if there's anything to convert first + if (!this.state.filterText || !this.state.filterText.includes('@')) return this.state.targets || []; + + let newMember: Member; + if (this.state.filterText.startsWith('@')) { + // Assume mxid + newMember = new DirectoryMember({user_id: this.state.filterText, display_name: null, avatar_url: null}); + } else { + // Assume email + newMember = new ThreepidMember(this.state.filterText); + } + const newTargets = [...(this.state.targets || []), newMember]; + this.setState({targets: newTargets, filterText: ''}); + return newTargets; + } + _startDm = async () => { this.setState({busy: true}); - const targetIds = this.state.targets.map(t => t.userId); + const targets = this._convertFilter(); + const targetIds = targets.map(t => t.userId); // Check if there is already a DM with these people and reuse it if possible. const existingRoom = DMRoomMap.shared().getDMRoomForIdentifiers(targetIds); @@ -529,7 +564,7 @@ export default class InviteDialog extends React.PureComponent { return; } - const createRoomOptions = {}; + const createRoomOptions = {inlineErrors: true}; if (SettingsStore.isFeatureEnabled("feature_cross_signing")) { // Check whether all users have uploaded device keys before. @@ -544,9 +579,12 @@ export default class InviteDialog extends React.PureComponent { // Check if it's a traditional DM and create the room if required. // TODO: [Canonical DMs] Remove this check and instead just create the multi-person DM let createRoomPromise = Promise.resolve(); - if (targetIds.length === 1) { + const isSelf = targetIds.length === 1 && targetIds[0] === MatrixClientPeg.get().getUserId(); + if (targetIds.length === 1 && !isSelf) { createRoomOptions.dmUserId = targetIds[0]; createRoomPromise = createRoom(createRoomOptions); + } else if (isSelf) { + createRoomPromise = createRoom(createRoomOptions); } else { // Create a boring room and try to invite the targets manually. createRoomPromise = createRoom(createRoomOptions).then(roomId => { @@ -573,7 +611,9 @@ export default class InviteDialog extends React.PureComponent { _inviteUsers = () => { this.setState({busy: true}); - const targetIds = this.state.targets.map(t => t.userId); + this._convertFilter(); + const targets = this._convertFilter(); + const targetIds = targets.map(t => t.userId); const room = MatrixClientPeg.get().getRoom(this.props.roomId); if (!room) { @@ -630,13 +670,14 @@ export default class InviteDialog extends React.PureComponent { // While we're here, try and autocomplete a search result for the mxid itself // if there's no matches (and the input looks like a mxid). - if (term[0] === '@' && term.indexOf(':') > 1 && r.results.length === 0) { + if (term[0] === '@' && term.indexOf(':') > 1) { try { const profile = await MatrixClientPeg.get().getProfileInfo(term); if (profile) { // If we have a profile, we have enough information to assume that - // the mxid can be invited - add it to the list - r.results.push({ + // the mxid can be invited - add it to the list. We stick it at the + // top so it is most obviously presented to the user. + r.results.splice(0, 0, { user_id: term, display_name: profile['displayname'], avatar_url: profile['avatar_url'], @@ -645,6 +686,14 @@ export default class InviteDialog extends React.PureComponent { } catch (e) { console.warn("Non-fatal error trying to make an invite for a user ID"); console.warn(e); + + // Add a result anyways, just without a profile. We stick it at the + // top so it is most obviously presented to the user. + r.results.splice(0, 0, { + user_id: term, + display_name: term, + avatar_url: null, + }); } } @@ -769,7 +818,7 @@ export default class InviteDialog extends React.PureComponent { ]; const toAdd = []; const failed = []; - const potentialAddresses = text.split(/[\s,]+/); + const potentialAddresses = text.split(/[\s,]+/).map(p => p.trim()).filter(p => !!p); // filter empty strings for (const address of potentialAddresses) { const member = possibleMembers.find(m => m.userId === address); if (member) { @@ -857,24 +906,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 // members separately because we want to filter sourceMembers but trust the mixin arrays to have // 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; if (this.state.filterText && hasMixins && kind === 'suggestions') { // We don't want to duplicate members though, so just exclude anyone we've already seen. const notAlreadyExists = (u: Member): boolean => { 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); - additionalMembers = additionalMembers.concat(...uniqueServerResults); - - const uniqueThreepidResults = this.state.threepidResultsMixin.filter(notAlreadyExists); - additionalMembers = additionalMembers.concat(...uniqueThreepidResults); + otherAdditionalMembers = this.state.serverResultsMixin.filter(notAlreadyExists); + priorityAdditionalMembers = this.state.threepidResultsMixin.filter(notAlreadyExists); } + const hasAdditionalMembers = priorityAdditionalMembers.length > 0 || otherAdditionalMembers.length > 0; // 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. if (this.state.filterText) { @@ -882,7 +931,7 @@ export default class InviteDialog extends React.PureComponent { sourceMembers = sourceMembers .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 (

    {sectionName}

    @@ -894,7 +943,7 @@ export default class InviteDialog extends React.PureComponent { // 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. - 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 // with the member's tile instead. @@ -1014,7 +1063,7 @@ export default class InviteDialog extends React.PureComponent { "If you can't find someone, ask them for their username, share your " + "username (%(userId)s) or profile link.", {userId}, - {a: (sub) => {sub}}, + {a: (sub) => {sub}}, ); buttonText = _t("Go"); goButtonFn = this._startDm; @@ -1023,12 +1072,17 @@ export default class InviteDialog extends React.PureComponent { helpText = _t( "If you can't find someone, ask them for their username (e.g. @user:server.com) or " + "share this room.", {}, - {a: (sub) => {sub}}, + { + a: (sub) => + {sub}, + }, ); buttonText = _t("Invite"); goButtonFn = this._inviteUsers; } + const hasSelection = this.state.targets.length > 0 + || (this.state.filterText && this.state.filterText.includes('@')); return ( {buttonText} diff --git a/src/components/views/dialogs/NewSessionReviewDialog.js b/src/components/views/dialogs/NewSessionReviewDialog.js index 0019e0644f..2613dcc6e6 100644 --- a/src/components/views/dialogs/NewSessionReviewDialog.js +++ b/src/components/views/dialogs/NewSessionReviewDialog.js @@ -23,6 +23,7 @@ import VerificationRequestDialog from './VerificationRequestDialog'; import BaseDialog from './BaseDialog'; import DialogButtons from '../elements/DialogButtons'; import {MatrixClientPeg} from "../../../MatrixClientPeg"; +import * as sdk from '../../../index'; @replaceableComponent("views.dialogs.NewSessionReviewDialog") export default class NewSessionReviewDialog extends React.PureComponent { @@ -33,20 +34,38 @@ export default class NewSessionReviewDialog extends React.PureComponent { } 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:
    + {_t("One of the following may be compromised:")} +
      +
    • {_t("Your password")}
    • +
    • {_t("Your homeserver")}
    • +
    • {_t("This session, or the other session")}
    • +
    • {_t("The internet connection either session is using")}
    • +
    +
    + {_t("We recommend you change your password and recovery key in Settings immediately")} +
    +
    , + onFinished: () => this.props.onFinished(false), + }); } - onContinueClick = async () => { + onContinueClick = () => { const { userId, device } = this.props; const cli = MatrixClientPeg.get(); - const request = await cli.requestVerification( + const requestPromise = cli.requestVerification( userId, [device.deviceId], ); this.props.onFinished(true); Modal.createTrackedDialog('New Session Verification', 'Starting dialog', VerificationRequestDialog, { - verificationRequest: request, + verificationRequestPromise: requestPromise, + member: cli.getUser(userId), }); } diff --git a/src/components/views/dialogs/QuestionDialog.js b/src/components/views/dialogs/QuestionDialog.js index 3eec497b44..8cb16dd88f 100644 --- a/src/components/views/dialogs/QuestionDialog.js +++ b/src/components/views/dialogs/QuestionDialog.js @@ -32,6 +32,7 @@ export default createReactClass({ focus: PropTypes.bool, onFinished: PropTypes.func.isRequired, headerImage: PropTypes.string, + quitOnly: PropTypes.bool, // quitOnly doesn't show the cancel button just the quit [x]. }, getDefaultProps: function() { @@ -42,6 +43,7 @@ export default createReactClass({ focus: true, hasCancelButton: true, danger: false, + quitOnly: false, }; }, @@ -73,7 +75,7 @@ export default createReactClass({
    { - socials.map((social) => {serviceName} {summary} - {termDoc[termsLang].name} + {termDoc[termsLang].name} ; } - onFinished() { - this.props.verificationRequest.cancel(); + async onFinished() { this.props.onFinished(); + let request = this.props.verificationRequest; + if (!request && this.props.verificationRequestPromise) { + request = await this.props.verificationRequestPromise; + } + request.cancel(); } } diff --git a/src/components/views/elements/AppTile.js b/src/components/views/elements/AppTile.js index b12ace708d..20d98f5e23 100644 --- a/src/components/views/elements/AppTile.js +++ b/src/components/views/elements/AppTile.js @@ -552,7 +552,7 @@ export default class AppTile extends React.Component { // Using Object.assign workaround as the following opens in a new window instead of a new tab. // window.open(this._getSafeUrl(), '_blank', 'noopener=yes'); Object.assign(document.createElement('a'), - { target: '_blank', href: this._getSafeUrl(), rel: 'noopener'}).click(); + { target: '_blank', href: this._getSafeUrl(), rel: 'noreferrer noopener'}).click(); } _onReloadWidgetClick() { diff --git a/src/components/views/elements/EditableItemList.js b/src/components/views/elements/EditableItemList.js index c74fd6a4ee..6e649e777a 100644 --- a/src/components/views/elements/EditableItemList.js +++ b/src/components/views/elements/EditableItemList.js @@ -78,8 +78,7 @@ export class EditableItem extends React.Component { return (
    - {_t("Remove")} +
    {this.props.value}
    ); @@ -123,8 +122,9 @@ export default class EditableItemList extends React.Component {
    - + autoComplete="off" value={this.props.newItem || ""} onChange={this._onNewItemChanged} + list={this.props.suggestionsListId} /> + {_t("Add")} diff --git a/src/components/views/elements/Field.js b/src/components/views/elements/Field.js index 7d5ccbb72d..8583c91a01 100644 --- a/src/components/views/elements/Field.js +++ b/src/components/views/elements/Field.js @@ -32,6 +32,8 @@ export default class Field extends React.PureComponent { element: PropTypes.oneOf(["input", "select", "textarea"]), // The field's type (when used as an ). Defaults to "text". type: PropTypes.string, + // id of a element for suggestions + list: PropTypes.string, // The field's label string. label: PropTypes.string, // The field's placeholder string. Defaults to the label. @@ -157,7 +159,7 @@ export default class Field extends React.PureComponent { render() { const { element, prefix, postfix, className, onValidate, children, - tooltipContent, flagInvalid, tooltipClassName, ...inputProps} = this.props; + tooltipContent, flagInvalid, tooltipClassName, list, ...inputProps} = this.props; const inputElement = element || "input"; @@ -169,6 +171,7 @@ export default class Field extends React.PureComponent { inputProps.onFocus = this.onFocus; inputProps.onChange = this.onChange; inputProps.onBlur = this.onBlur; + inputProps.list = list; const fieldInput = React.createElement(inputElement, inputProps, children); diff --git a/src/components/views/elements/ImageView.js b/src/components/views/elements/ImageView.js index 7cc2741df7..e675e6b73f 100644 --- a/src/components/views/elements/ImageView.js +++ b/src/components/views/elements/ImageView.js @@ -91,7 +91,7 @@ export default class ImageView extends React.Component { getName() { let name = this.props.name; if (name && this.props.link) { - name = { name }; + name = { name }; } return name; } diff --git a/src/components/views/elements/Pill.js b/src/components/views/elements/Pill.js index cd7277cdeb..5f143a06a6 100644 --- a/src/components/views/elements/Pill.js +++ b/src/components/views/elements/Pill.js @@ -23,7 +23,6 @@ import classNames from 'classnames'; import { Room, RoomMember } from 'matrix-js-sdk'; import PropTypes from 'prop-types'; import {MatrixClientPeg} from '../../../MatrixClientPeg'; -import { getDisplayAliasForRoom } from '../../../Rooms'; import FlairStore from "../../../stores/FlairStore"; import {getPrimaryPermalinkEntity} from "../../../utils/permalinks/Permalinks"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; @@ -129,7 +128,7 @@ const Pill = createReactClass({ const localRoom = resourceId[0] === '#' ? MatrixClientPeg.get().getRooms().find((r) => { return r.getCanonicalAlias() === resourceId || - r.getAliases().includes(resourceId); + r.getAltAliases().includes(resourceId); }) : MatrixClientPeg.get().getRoom(resourceId); room = localRoom; if (!localRoom) { @@ -237,12 +236,12 @@ const Pill = createReactClass({ case Pill.TYPE_ROOM_MENTION: { const room = this.state.room; if (room) { - linkText = (room ? getDisplayAliasForRoom(room) : null) || resource; + linkText = resource; if (this.props.shouldShowPillAvatar) { avatar =
    ; }; -const EncryptionInfo = ({pending, member, onStartVerification}) => { +const EncryptionInfo = ({waitingForOtherParty, waitingForNetwork, member, onStartVerification}) => { let content; - if (pending) { - const text = _t("Waiting for %(displayName)s to accept…", { - displayName: member.displayName || member.name || member.userId, - }); + if (waitingForOtherParty || waitingForNetwork) { + let text; + if (waitingForOtherParty) { + text = _t("Waiting for %(displayName)s to accept…", { + displayName: member.displayName || member.name || member.userId, + }); + } else { + text = _t("Accepting…"); + } content = ; } else { const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); diff --git a/src/components/views/right_panel/EncryptionPanel.js b/src/components/views/right_panel/EncryptionPanel.js index a5685e0617..a14d4a2b7d 100644 --- a/src/components/views/right_panel/EncryptionPanel.js +++ b/src/components/views/right_panel/EncryptionPanel.js @@ -30,13 +30,32 @@ import {_t} from "../../../languageHandler"; // cancellation codes which constitute a key mismatch const MISMATCHES = ["m.key_mismatch", "m.user_error", "m.mismatched_sas"]; -const EncryptionPanel = ({verificationRequest, member, onClose, layout}) => { +const EncryptionPanel = ({verificationRequest, verificationRequestPromise, member, onClose, layout}) => { const [request, setRequest] = useState(verificationRequest); + // state to show a spinner immediately after clicking "start verification", + // before we have a request + const [isRequesting, setRequesting] = useState(false); + const [phase, setPhase] = useState(request && request.phase); useEffect(() => { setRequest(verificationRequest); + if (verificationRequest) { + setRequesting(false); + setPhase(verificationRequest.phase); + } }, [verificationRequest]); - const [phase, setPhase] = useState(request && request.phase); + useEffect(() => { + async function awaitPromise() { + setRequesting(true); + const request = await verificationRequestPromise; + setRequesting(false); + setRequest(request); + setPhase(request.phase); + } + if (verificationRequestPromise) { + awaitPromise(); + } + }, [verificationRequestPromise]); const changeHandler = useCallback(() => { // handle transitions -> cancelled for mismatches which fire a modal instead of showing a card if (request && request.cancelled && MISMATCHES.includes(request.cancellationCode)) { @@ -65,6 +84,7 @@ const EncryptionPanel = ({verificationRequest, member, onClose, layout}) => { useEventEmitter(request, "change", changeHandler); const onStartVerification = useCallback(async () => { + setRequesting(true); const cli = MatrixClientPeg.get(); const roomId = await ensureDMExists(cli, member.userId); const verificationRequest = await cli.requestVerificationDM(member.userId, roomId); @@ -72,9 +92,16 @@ const EncryptionPanel = ({verificationRequest, member, onClose, layout}) => { setPhase(verificationRequest.phase); }, [member.userId]); - const requested = request && (phase === PHASE_REQUESTED || phase === PHASE_UNSENT || phase === undefined); + const requested = + (!request && isRequesting) || + (request && (phase === PHASE_REQUESTED || phase === PHASE_UNSENT || phase === undefined)); if (!request || requested) { - return ; + const initiatedByMe = (!request && isRequesting) || (request && request.initiatedByMe); + return ; } else { return ( { const names = Object.create(null); @@ -135,54 +137,21 @@ function useIsEncrypted(cli, room) { return isEncrypted; } -async function verifyDevice(userId, device) { - const cli = MatrixClientPeg.get(); - const member = cli.getUser(userId); - const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); - Modal.createTrackedDialog("Verification warning", "unverified session", QuestionDialog, { - headerImage: require("../../../../res/img/e2e/warning.svg"), - title: _t("Not Trusted"), - description:
    -

    {_t("%(name)s (%(userId)s) signed in to a new session without verifying it:", {name: member.displayName, userId})}

    -

    {device.getDisplayName()} ({device.deviceId})

    -

    {_t("Ask this user to verify their session, or manually verify it below.")}

    -
    , - onFinished: async (doneClicked) => { - const manuallyVerifyClicked = !doneClicked; - if (!manuallyVerifyClicked) { - return; - } - const cli = MatrixClientPeg.get(); - const verificationRequest = await cli.requestVerification( - userId, - [device.deviceId], - ); - dis.dispatch({ - action: "set_right_panel_phase", - phase: RIGHT_PANEL_PHASES.EncryptionPanel, - refireParams: {member, verificationRequest}, - }); - }, - primaryButton: _t("Done"), - cancelButton: _t("Manually Verify"), - }); -} - -function verifyUser(user) { - const cli = MatrixClientPeg.get(); - const dmRoom = findDMForUser(cli, user.userId); - let existingRequest; - if (dmRoom) { - existingRequest = cli.findVerificationRequestDMInProgress(dmRoom.roomId); - } - dis.dispatch({ - action: "set_right_panel_phase", - phase: RIGHT_PANEL_PHASES.EncryptionPanel, - refireParams: { - member: user, - verificationRequest: existingRequest, - }, - }); +function useHasCrossSigningKeys(cli, member, canVerify, setUpdating) { + return useAsyncMemo(async () => { + if (!canVerify) { + return false; + } + setUpdating(true); + try { + await cli.downloadKeys([member.userId]); + const xsi = cli.getStoredCrossSigningForUser(member.userId); + const key = xsi && xsi.getId(); + return !!key; + } finally { + setUpdating(false); + } + }, [cli, member, canVerify], false); } function DeviceItem({userId, device}) { @@ -211,7 +180,7 @@ function DeviceItem({userId, device}) { const onDeviceClick = () => { if (!isVerified) { - verifyDevice(userId, device); + verifyDevice(cli.getUser(userId), device); } }; @@ -916,6 +885,12 @@ const useIsSynapseAdmin = (cli) => { return isAdmin; }; +const useHomeserverSupportsCrossSigning = (cli) => { + return useAsyncMemo(async () => { + return cli.doesServerSupportUnstableFeature("org.matrix.e2e_cross_signing"); + }, [cli], false); +}; + function useRoomPermissions(cli, room, user) { const [roomPermissions, setRoomPermissions] = useState({ // modifyLevelMax is the max PL we can set this user to, typically min(their PL, our PL) && canSetPL @@ -1315,16 +1290,31 @@ const BasicUserInfo = ({room, member, groupId, devices, isRoomEncrypted}) => { text = _t("Messages in this room are end-to-end encrypted."); } - const userTrust = cli.checkUserTrust(member.userId); - const userVerified = SettingsStore.isFeatureEnabled("feature_cross_signing") ? - userTrust.isCrossSigningVerified() : - userTrust.isVerified(); - const isMe = member.userId === cli.getUserId(); - let verifyButton; - if (isRoomEncrypted && !userVerified && !isMe) { + const homeserverSupportsCrossSigning = useHomeserverSupportsCrossSigning(cli); + + const userTrust = cli.checkUserTrust(member.userId); + const userVerified = userTrust.isCrossSigningVerified(); + const isMe = member.userId === cli.getUserId(); + const canVerify = SettingsStore.isFeatureEnabled("feature_cross_signing") && + homeserverSupportsCrossSigning && + isRoomEncrypted && !userVerified && !isMe; + + const setUpdating = (updating) => { + setPendingUpdateCount(count => count + (updating ? 1 : -1)); + }; + const hasCrossSigningKeys = + useHasCrossSigningKeys(cli, member, canVerify, setUpdating ); + + if (canVerify) { verifyButton = ( - verifyUser(member)}> + { + if (hasCrossSigningKeys) { + verifyUser(member); + } else { + legacyVerifyUser(member); + } + }}> {_t("Verify")} ); diff --git a/src/components/views/right_panel/VerificationPanel.js b/src/components/views/right_panel/VerificationPanel.js index 45a9b9eddb..5dd01c9f79 100644 --- a/src/components/views/right_panel/VerificationPanel.js +++ b/src/components/views/right_panel/VerificationPanel.js @@ -270,6 +270,8 @@ export default class VerificationPanel extends React.PureComponent { }; _onVerifierShowSas = (sasEvent) => { + const {request} = this.props; + request.verifier.off('show_sas', this._onVerifierShowSas); this.setState({sasEvent}); }; @@ -278,7 +280,7 @@ export default class VerificationPanel extends React.PureComponent { const hadVerifier = this._hasVerifier; this._hasVerifier = !!request.verifier; if (!hadVerifier && this._hasVerifier) { - request.verifier.once('show_sas', this._onVerifierShowSas); + request.verifier.on('show_sas', this._onVerifierShowSas); try { // on the requester side, this is also awaited in _startSAS, // but that's ok as verify should return the same promise. @@ -299,6 +301,10 @@ export default class VerificationPanel extends React.PureComponent { } componentWillUnmount() { - this.props.request.off("change", this._onRequestChange); + const {request} = this.props; + if (request.verifier) { + request.verifier.off('show_sas', this._onVerifierShowSas); + } + request.off("change", this._onRequestChange); } } diff --git a/src/components/views/room_settings/AliasSettings.js b/src/components/views/room_settings/AliasSettings.js index a52fa09c87..f8e2151c4f 100644 --- a/src/components/views/room_settings/AliasSettings.js +++ b/src/components/views/room_settings/AliasSettings.js @@ -25,6 +25,7 @@ import Field from "../elements/Field"; import ErrorDialog from "../dialogs/ErrorDialog"; import AccessibleButton from "../elements/AccessibleButton"; import Modal from "../../../Modal"; +import RoomPublishSetting from "./RoomPublishSetting"; class EditableAliasesList extends EditableItemList { constructor(props) { @@ -46,6 +47,11 @@ class EditableAliasesList extends EditableItemList { }; _renderNewItemField() { + // if we don't need the RoomAliasField, + // we don't need to overriden version of _renderNewItemField + if (!this.props.domain) { + return super._renderNewItemField(); + } const RoomAliasField = sdk.getComponent('views.elements.RoomAliasField'); const onChange = (alias) => this._onNewItemChanged({target: {value: alias}}); return ( @@ -87,91 +93,64 @@ export default class AliasSettings extends React.Component { super(props); const state = { - domainToAliases: {}, // { domain.com => [#alias1:domain.com, #alias2:domain.com] } - remoteDomains: [], // [ domain.com, foobar.com ] - canonicalAlias: null, // #canonical:domain.com + altAliases: [], // [ #alias:domain.tld, ... ] + localAliases: [], // [ #alias:my-hs.tld, ... ] + canonicalAlias: null, // #canonical:domain.tld updatingCanonicalAlias: false, - localAliasesLoading: true, + localAliasesLoading: false, + detailsOpen: false, }; if (props.canonicalAliasEvent) { - state.canonicalAlias = props.canonicalAliasEvent.getContent().alias; + const content = props.canonicalAliasEvent.getContent(); + const altAliases = content.alt_aliases; + if (Array.isArray(altAliases)) { + state.altAliases = altAliases.slice(); + } + state.canonicalAlias = content.alias; } this.state = state; } - async componentWillMount() { - const cli = MatrixClientPeg.get(); + componentDidMount() { + if (this.props.canSetCanonicalAlias) { + // load local aliases for providing recommendations + // for the canonical alias and alt_aliases + this.loadLocalAliases(); + } + } + + async loadLocalAliases() { + this.setState({ localAliasesLoading: true }); try { + const cli = MatrixClientPeg.get(); + let localAliases = []; if (await cli.doesServerSupportUnstableFeature("org.matrix.msc2432")) { const response = await cli.unstableGetLocalAliases(this.props.roomId); - const localAliases = response.aliases; - const localDomain = cli.getDomain(); - const domainToAliases = Object.assign( - {}, - // FIXME, any localhost alt_aliases will be ignored as they are overwritten by localAliases - this.aliasesToDictionary(this._getAltAliases()), - {[localDomain]: localAliases || []}, - ); - const remoteDomains = Object.keys(domainToAliases).filter((domain) => { - return domain !== localDomain && domainToAliases[domain].length > 0; - }); - this.setState({ domainToAliases, remoteDomains }); - } else { - const state = {}; - const localDomain = cli.getDomain(); - state.domainToAliases = this.aliasEventsToDictionary(this.props.aliasEvents || []); - state.remoteDomains = Object.keys(state.domainToAliases).filter((domain) => { - return domain !== localDomain && state.domainToAliases[domain].length > 0; - }); - this.setState(state); + if (Array.isArray(response.aliases)) { + localAliases = response.aliases; + } } + this.setState({ localAliases }); } finally { - this.setState({localAliasesLoading: false}); + this.setState({ localAliasesLoading: false }); } } - aliasesToDictionary(aliases) { - return aliases.reduce((dict, alias) => { - const domain = alias.split(":")[1]; - dict[domain] = dict[domain] || []; - dict[domain].push(alias); - return dict; - }, {}); - } - - aliasEventsToDictionary(aliasEvents) { // m.room.alias events - const dict = {}; - aliasEvents.forEach((event) => { - dict[event.getStateKey()] = ( - (event.getContent().aliases || []).slice() // shallow-copy - ); - }); - return dict; - } - - _getAltAliases() { - if (this.props.canonicalAliasEvent) { - const altAliases = this.props.canonicalAliasEvent.getContent().alt_aliases; - if (Array.isArray(altAliases)) { - return altAliases; - } - } - return []; - } - changeCanonicalAlias(alias) { if (!this.props.canSetCanonicalAlias) return; + const oldAlias = this.state.canonicalAlias; this.setState({ canonicalAlias: alias, updatingCanonicalAlias: true, }); - const eventContent = {}; - const altAliases = this._getAltAliases(); - if (altAliases) eventContent["alt_aliases"] = altAliases; + const eventContent = { + alt_aliases: this.state.altAliases, + }; + if (alias) eventContent["alias"] = alias; MatrixClientPeg.get().sendStateEvent(this.props.roomId, "m.room.canonical_alias", @@ -184,6 +163,39 @@ export default class AliasSettings extends React.Component { "or a temporary failure occurred.", ), }); + this.setState({canonicalAlias: oldAlias}); + }).finally(() => { + this.setState({updatingCanonicalAlias: false}); + }); + } + + changeAltAliases(altAliases) { + if (!this.props.canSetCanonicalAlias) return; + + this.setState({ + updatingCanonicalAlias: true, + altAliases, + }); + + const eventContent = {}; + + if (this.state.canonicalAlias) { + eventContent.alias = this.state.canonicalAlias; + } + if (altAliases) { + eventContent["alt_aliases"] = altAliases; + } + + MatrixClientPeg.get().sendStateEvent(this.props.roomId, "m.room.canonical_alias", + eventContent, "").catch((err) => { + console.error(err); + Modal.createTrackedDialog('Error updating alternative addresses', '', ErrorDialog, { + title: _t("Error updating main address"), + description: _t( + "There was an error updating the room's alternative addresses. " + + "It may not be allowed by the server or a temporary failure occurred.", + ), + }); }).finally(() => { this.setState({updatingCanonicalAlias: false}); }); @@ -200,16 +212,10 @@ export default class AliasSettings extends React.Component { if (!alias.includes(':')) alias += ':' + localDomain; MatrixClientPeg.get().createAlias(alias, this.props.roomId).then(() => { - const localAliases = this.state.domainToAliases[localDomain] || []; - const domainAliases = Object.assign({}, this.state.domainToAliases); - domainAliases[localDomain] = [...localAliases, alias]; - this.setState({ - domainToAliases: domainAliases, - // Reset the add field - newAlias: "", + localAliases: this.state.localAliases.concat(alias), + newAlias: null, }); - if (!this.state.canonicalAlias) { this.changeCanonicalAlias(alias); } @@ -226,38 +232,78 @@ export default class AliasSettings extends React.Component { }; onLocalAliasDeleted = (index) => { - const localDomain = MatrixClientPeg.get().getDomain(); - - const alias = this.state.domainToAliases[localDomain][index]; - + const alias = this.state.localAliases[index]; // TODO: In future, we should probably be making sure that the alias actually belongs // to this room. See https://github.com/vector-im/riot-web/issues/7353 MatrixClientPeg.get().deleteAlias(alias).then(() => { - const localAliases = this.state.domainToAliases[localDomain].filter((a) => a !== alias); - const domainAliases = Object.assign({}, this.state.domainToAliases); - domainAliases[localDomain] = localAliases; - - this.setState({domainToAliases: domainAliases}); + const localAliases = this.state.localAliases.slice(); + localAliases.splice(index, 1); + this.setState({localAliases}); if (this.state.canonicalAlias === alias) { this.changeCanonicalAlias(null); } }).catch((err) => { console.error(err); - Modal.createTrackedDialog('Error removing alias', '', ErrorDialog, { - title: _t("Error removing alias"), - description: _t( + let description; + if (err.errcode === "M_FORBIDDEN") { + description = _t("You don't have permission to delete the alias."); + } else { + description = _t( "There was an error removing that alias. It may no longer exist or a temporary " + "error occurred.", - ), + ); + } + Modal.createTrackedDialog('Error removing alias', '', ErrorDialog, { + title: _t("Error removing alias"), + description, }); }); }; + onLocalAliasesToggled = (event) => { + // expanded + if (event.target.open) { + // if local aliases haven't been preloaded yet at component mount + if (!this.props.canSetCanonicalAlias && this.state.localAliases.length === 0) { + this.loadLocalAliases(); + } + } + this.setState({detailsOpen: event.target.open}); + }; + onCanonicalAliasChange = (event) => { this.changeCanonicalAlias(event.target.value); }; + onNewAltAliasChanged = (value) => { + this.setState({newAltAlias: value}); + } + + onAltAliasAdded = (alias) => { + const altAliases = this.state.altAliases.slice(); + if (!altAliases.some(a => a.trim() === alias.trim())) { + altAliases.push(alias.trim()); + this.changeAltAliases(altAliases); + this.setState({newAltAlias: ""}); + } + } + + onAltAliasDeleted = (index) => { + const altAliases = this.state.altAliases.slice(); + altAliases.splice(index, 1); + this.changeAltAliases(altAliases); + } + + _getAliases() { + return this.state.altAliases.concat(this._getLocalNonAltAliases()); + } + + _getLocalNonAltAliases() { + const {altAliases} = this.state; + return this.state.localAliases.filter(alias => !altAliases.includes(alias)); + } + render() { const localDomain = MatrixClientPeg.get().getDomain(); @@ -269,15 +315,13 @@ export default class AliasSettings extends React.Component { element='select' id='canonicalAlias' label={_t('Main address')}> { - Object.keys(this.state.domainToAliases).map((domain, i) => { - return this.state.domainToAliases[domain].map((alias, j) => { - if (alias === this.state.canonicalAlias) found = true; - return ( - - ); - }); + this._getAliases().map((alias, i) => { + if (alias === this.state.canonicalAlias) found = true; + return ( + + ); }) } { @@ -289,53 +333,60 @@ export default class AliasSettings extends React.Component { ); - let remoteAliasesSection; - if (this.state.remoteDomains.length) { - remoteAliasesSection = ( -
    -
    - { _t("Remote addresses for this room:") } -
    -
      - { this.state.remoteDomains.map((domain, i) => { - return this.state.domainToAliases[domain].map((alias, j) => { - return
    • {alias}
    • ; - }); - }) } -
    -
    - ); - } - let localAliasesList; if (this.state.localAliasesLoading) { const Spinner = sdk.getComponent("elements.Spinner"); localAliasesList = ; } else { - localAliasesList = ; + />); } return (
    + {_t("Published Addresses")} +

    {_t("Published addresses can be used by anyone on any server to join your room. " + + "To publish an address, it needs to be set as a local address first.")}

    {canonicalAliasSection} - {localAliasesList} - {remoteAliasesSection} + + + {this._getLocalNonAltAliases().map(alias => { + return + + {_t("Local Addresses")} +

    {_t("Set addresses for this room so users can find this room through your homeserver (%(localDomain)s)", {localDomain})}

    +
    + { this.state.detailsOpen ? _t('Show less') : _t("Show more")} + {localAliasesList} +
    ); } diff --git a/src/components/views/room_settings/RoomPublishSetting.js b/src/components/views/room_settings/RoomPublishSetting.js new file mode 100644 index 0000000000..bac2dfc656 --- /dev/null +++ b/src/components/views/room_settings/RoomPublishSetting.js @@ -0,0 +1,60 @@ +/* +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 LabelledToggleSwitch from "../elements/LabelledToggleSwitch"; +import {_t} from "../../../languageHandler"; +import {MatrixClientPeg} from "../../../MatrixClientPeg"; + +export default class RoomPublishSetting extends React.PureComponent { + constructor(props) { + super(props); + this.state = {isRoomPublished: false}; + } + + onRoomPublishChange = (e) => { + const valueBefore = this.state.isRoomPublished; + const newValue = !valueBefore; + this.setState({isRoomPublished: newValue}); + const client = MatrixClientPeg.get(); + + client.setRoomDirectoryVisibility( + this.props.roomId, + newValue ? 'public' : 'private', + ).catch(() => { + // Roll back the local echo on the change + this.setState({isRoomPublished: valueBefore}); + }); + }; + + componentDidMount() { + const client = MatrixClientPeg.get(); + client.getRoomDirectoryVisibility(this.props.roomId).then((result => { + this.setState({isRoomPublished: result.visibility === 'public'}); + })); + } + + render() { + const client = MatrixClientPeg.get(); + + return (); + } +} diff --git a/src/components/views/rooms/AuxPanel.js b/src/components/views/rooms/AuxPanel.js index 50b25cb96f..0b34739e0e 100644 --- a/src/components/views/rooms/AuxPanel.js +++ b/src/components/views/rooms/AuxPanel.js @@ -219,7 +219,7 @@ export default createReactClass({ if (link) { span = ( - + { span } ); diff --git a/src/components/views/rooms/BasicMessageComposer.js b/src/components/views/rooms/BasicMessageComposer.js index 3c5c698f51..147f3c0af8 100644 --- a/src/components/views/rooms/BasicMessageComposer.js +++ b/src/components/views/rooms/BasicMessageComposer.js @@ -393,6 +393,20 @@ export default class BasicMessageEditor extends React.Component { } else if (event.key === Key.ENTER && (event.shiftKey || (IS_MAC && event.altKey))) { this._insertText("\n"); handled = true; + // move selection to start of composer + } else if (modKey && event.key === Key.HOME && !event.shiftKey) { + setSelection(this._editorRef, model, { + index: 0, + offset: 0, + }); + handled = true; + // move selection to end of composer + } else if (modKey && event.key === Key.END && !event.shiftKey) { + setSelection(this._editorRef, model, { + index: model.parts.length - 1, + offset: model.parts[model.parts.length - 1].text.length, + }); + handled = true; // autocomplete or enter to send below shouldn't have any modifier keys pressed. } else { const metaOrAltPressed = event.metaKey || event.altKey; @@ -458,10 +472,14 @@ export default class BasicMessageEditor extends React.Component { const addedLen = range.replace([partCreator.pillCandidate(range.text)]); return model.positionForOffset(caret.offset + addedLen, true); }); - await model.autoComplete.onTab(); - if (!model.autoComplete.hasSelection()) { - this.setState({showVisualBell: true}); - model.autoComplete.close(); + + // Don't try to do things with the autocomplete if there is none shown + if (model.autoComplete) { + await model.autoComplete.onTab(); + if (!model.autoComplete.hasSelection()) { + this.setState({showVisualBell: true}); + model.autoComplete.close(); + } } } catch (err) { console.error(err); @@ -491,6 +509,7 @@ export default class BasicMessageEditor extends React.Component { } componentWillUnmount() { + document.removeEventListener("selectionchange", this._onSelectionChange); this._editorRef.removeEventListener("input", this._onInput, true); this._editorRef.removeEventListener("compositionstart", this._onCompositionStart, true); this._editorRef.removeEventListener("compositionend", this._onCompositionEnd, true); @@ -545,6 +564,7 @@ export default class BasicMessageEditor extends React.Component { return; } this.historyManager.ensureLastChangesPushed(this.props.model); + this._modifiedFlag = true; switch (action) { case "bold": toggleInlineFormat(range, "**"); diff --git a/src/components/views/rooms/EntityTile.js b/src/components/views/rooms/EntityTile.js index fd92cb8e57..b6d2bce785 100644 --- a/src/components/views/rooms/EntityTile.js +++ b/src/components/views/rooms/EntityTile.js @@ -175,7 +175,8 @@ const EntityTile = createReactClass({ const BaseAvatar = sdk.getComponent('avatars.BaseAvatar'); - const av = this.props.avatarJsx || ; + const av = this.props.avatarJsx || +