Merge branch 'develop' into edit-slashcommands

This commit is contained in:
Travis Ralston 2021-05-10 20:09:33 -06:00
commit 12657f0fbc
336 changed files with 11521 additions and 4060 deletions

View file

@ -1,7 +1,7 @@
# autogenerated file: run scripts/generate-eslint-error-ignore-file to update. # autogenerated file: run scripts/generate-eslint-error-ignore-file to update.
src/Markdown.js src/Markdown.js
src/Velociraptor.js src/NodeAnimator.js
src/components/structures/RoomDirectory.js src/components/structures/RoomDirectory.js
src/components/views/rooms/MemberList.js src/components/views/rooms/MemberList.js
src/ratelimitedfunc.js src/ratelimitedfunc.js

View file

@ -15,7 +15,6 @@ module.exports = {
"prefer-promise-reject-errors": "off", "prefer-promise-reject-errors": "off",
"no-async-promise-executor": "off", "no-async-promise-executor": "off",
"quotes": "off", "quotes": "off",
"indent": "off",
}, },
overrides: [{ overrides: [{

1
.gitignore vendored
View file

@ -2,6 +2,7 @@
/*.log /*.log
package-lock.json package-lock.json
/coverage
/node_modules /node_modules
/lib /lib

View file

@ -1,3 +1,233 @@
Changes in [3.20.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.20.0) (2021-05-10)
=====================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.20.0-rc.1...v3.20.0)
* Upgrade to JS SDK 10.1.0
* [Release] Don't use the event's metadata to calc the scale of an image
[\#6004](https://github.com/matrix-org/matrix-react-sdk/pull/6004)
Changes in [3.20.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.20.0-rc.1) (2021-05-04)
===============================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.19.0...v3.20.0-rc.1)
* Upgrade to JS SDK 10.1.0-rc.1
* Translations update from Weblate
[\#5966](https://github.com/matrix-org/matrix-react-sdk/pull/5966)
* Fix more space panel layout and hover behaviour issues
[\#5965](https://github.com/matrix-org/matrix-react-sdk/pull/5965)
* Fix edge case with space panel alignment with subspaces on ff
[\#5964](https://github.com/matrix-org/matrix-react-sdk/pull/5964)
* Fix saving room pill part to history
[\#5951](https://github.com/matrix-org/matrix-react-sdk/pull/5951)
* Generate room preview even when minimized
[\#5948](https://github.com/matrix-org/matrix-react-sdk/pull/5948)
* Another change from recovery passphrase to Security Phrase
[\#5934](https://github.com/matrix-org/matrix-react-sdk/pull/5934)
* Sort rooms in the add existing to space dialog based on recency
[\#5943](https://github.com/matrix-org/matrix-react-sdk/pull/5943)
* Inhibit sending RR when context switching to a room
[\#5944](https://github.com/matrix-org/matrix-react-sdk/pull/5944)
* Prevent room list keyboard handling from landing focus on hidden nodes
[\#5950](https://github.com/matrix-org/matrix-react-sdk/pull/5950)
* Make the text filter search all spaces instead of just the selected one
[\#5942](https://github.com/matrix-org/matrix-react-sdk/pull/5942)
* Enable indent rule and fix indent
[\#5931](https://github.com/matrix-org/matrix-react-sdk/pull/5931)
* Prevent peeking members from reacting
[\#5946](https://github.com/matrix-org/matrix-react-sdk/pull/5946)
* Disallow inline display maths
[\#5939](https://github.com/matrix-org/matrix-react-sdk/pull/5939)
* Space creation prompt user to add existing rooms for "Just Me" spaces
[\#5923](https://github.com/matrix-org/matrix-react-sdk/pull/5923)
* Add test coverage collection script
[\#5937](https://github.com/matrix-org/matrix-react-sdk/pull/5937)
* Fix joining room using via servers regression
[\#5936](https://github.com/matrix-org/matrix-react-sdk/pull/5936)
* Revert "Fixes the two Todays problem in Redaction"
[\#5938](https://github.com/matrix-org/matrix-react-sdk/pull/5938)
* Handle encoded matrix URLs
[\#5903](https://github.com/matrix-org/matrix-react-sdk/pull/5903)
* Render ignored users setting regardless of if there are any
[\#5860](https://github.com/matrix-org/matrix-react-sdk/pull/5860)
* Fix inserting trailing colon after mention/pill
[\#5830](https://github.com/matrix-org/matrix-react-sdk/pull/5830)
* Fixes the two Todays problem in Redaction
[\#5917](https://github.com/matrix-org/matrix-react-sdk/pull/5917)
* Fix page up/down scrolling only half a page
[\#5920](https://github.com/matrix-org/matrix-react-sdk/pull/5920)
* Voice messages: Composer controls
[\#5935](https://github.com/matrix-org/matrix-react-sdk/pull/5935)
* Support MSC3086 asserted identity
[\#5886](https://github.com/matrix-org/matrix-react-sdk/pull/5886)
* Handle possible edge case with getting stuck in "unsent messages" bar
[\#5930](https://github.com/matrix-org/matrix-react-sdk/pull/5930)
* Fix suggested rooms not showing up regression from room list optimisation
[\#5932](https://github.com/matrix-org/matrix-react-sdk/pull/5932)
* Broadcast language change to ElectronPlatform
[\#5913](https://github.com/matrix-org/matrix-react-sdk/pull/5913)
* Fix VoIP PIP frame color
[\#5701](https://github.com/matrix-org/matrix-react-sdk/pull/5701)
* Convert some Flow-typed files to TypeScript
[\#5912](https://github.com/matrix-org/matrix-react-sdk/pull/5912)
* Initial SpaceStore tests work
[\#5906](https://github.com/matrix-org/matrix-react-sdk/pull/5906)
* Fix issues with space hierarchy in layout and with incompatible servers
[\#5926](https://github.com/matrix-org/matrix-react-sdk/pull/5926)
* Scale all mxc thumbs using device pixel ratio for hidpi
[\#5928](https://github.com/matrix-org/matrix-react-sdk/pull/5928)
* Fix add existing to space dialog no longer showing rooms for public spaces
[\#5918](https://github.com/matrix-org/matrix-react-sdk/pull/5918)
* Disable spaces context switching for when exploring a space
[\#5924](https://github.com/matrix-org/matrix-react-sdk/pull/5924)
* Autofocus search box in the add existing to space dialog
[\#5921](https://github.com/matrix-org/matrix-react-sdk/pull/5921)
* Use label element in add existing to space dialog for easier hit target
[\#5922](https://github.com/matrix-org/matrix-react-sdk/pull/5922)
* Dynamic max and min zoom in the new ImageView
[\#5916](https://github.com/matrix-org/matrix-react-sdk/pull/5916)
* Improve message error states
[\#5897](https://github.com/matrix-org/matrix-react-sdk/pull/5897)
* Check for null room in `VisibilityProvider`
[\#5914](https://github.com/matrix-org/matrix-react-sdk/pull/5914)
* Add unit tests for various collection-based utility functions
[\#5910](https://github.com/matrix-org/matrix-react-sdk/pull/5910)
* Spaces visual fixes
[\#5909](https://github.com/matrix-org/matrix-react-sdk/pull/5909)
* Remove reliance on DOM API to generated message preview
[\#5908](https://github.com/matrix-org/matrix-react-sdk/pull/5908)
* Expand upon voice message event & include overall waveform
[\#5888](https://github.com/matrix-org/matrix-react-sdk/pull/5888)
* Use floats for image background opacity
[\#5905](https://github.com/matrix-org/matrix-react-sdk/pull/5905)
* Show invites to spaces at the top of the space panel
[\#5902](https://github.com/matrix-org/matrix-react-sdk/pull/5902)
* Improve edge cases with spaces context switching
[\#5899](https://github.com/matrix-org/matrix-react-sdk/pull/5899)
* Fix spaces notification dots wrongly including upgraded (hidden) rooms
[\#5900](https://github.com/matrix-org/matrix-react-sdk/pull/5900)
* Iterate the spaces face pile design
[\#5898](https://github.com/matrix-org/matrix-react-sdk/pull/5898)
* Fix alignment issue with nested spaces being cut off wrong
[\#5890](https://github.com/matrix-org/matrix-react-sdk/pull/5890)
Changes in [3.19.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.19.0) (2021-04-26)
=====================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.19.0-rc.1...v3.19.0)
* Upgrade to JS SDK 10.0.0
* [Release] Dynamic max and min zoom in the new ImageView
[\#5927](https://github.com/matrix-org/matrix-react-sdk/pull/5927)
* [Release] Add a WheelEvent normalization function
[\#5911](https://github.com/matrix-org/matrix-react-sdk/pull/5911)
* Add a WheelEvent normalization function
[\#5904](https://github.com/matrix-org/matrix-react-sdk/pull/5904)
* [Release] Use floats for image background opacity
[\#5907](https://github.com/matrix-org/matrix-react-sdk/pull/5907)
Changes in [3.19.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.19.0-rc.1) (2021-04-21)
===============================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.18.0...v3.19.0-rc.1)
* Upgrade to JS SDK 10.0.0-rc.1
* Translations update from Weblate
[\#5896](https://github.com/matrix-org/matrix-react-sdk/pull/5896)
* Fix sticky tags header in room list
[\#5895](https://github.com/matrix-org/matrix-react-sdk/pull/5895)
* Fix spaces filtering sometimes lagging behind or behaving oddly
[\#5893](https://github.com/matrix-org/matrix-react-sdk/pull/5893)
* Fix issue with spaces context switching looping and breaking
[\#5894](https://github.com/matrix-org/matrix-react-sdk/pull/5894)
* Improve RoomList render time when filtering
[\#5874](https://github.com/matrix-org/matrix-react-sdk/pull/5874)
* Avoid being stuck in a space
[\#5891](https://github.com/matrix-org/matrix-react-sdk/pull/5891)
* [Spaces] Context switching
[\#5795](https://github.com/matrix-org/matrix-react-sdk/pull/5795)
* Warn when you attempt to leave room that you are the only member of
[\#5415](https://github.com/matrix-org/matrix-react-sdk/pull/5415)
* Ensure PersistedElement are unmounted on application logout
[\#5884](https://github.com/matrix-org/matrix-react-sdk/pull/5884)
* Add missing space in seshat dialog and the corresponding string
[\#5866](https://github.com/matrix-org/matrix-react-sdk/pull/5866)
* A tiny change to make the Add existing rooms dialog a little nicer
[\#5885](https://github.com/matrix-org/matrix-react-sdk/pull/5885)
* Remove weird margin from the file panel
[\#5889](https://github.com/matrix-org/matrix-react-sdk/pull/5889)
* Trigger lazy loading when filtering using spaces
[\#5882](https://github.com/matrix-org/matrix-react-sdk/pull/5882)
* Fix typo in method call in add existing to space dialog
[\#5883](https://github.com/matrix-org/matrix-react-sdk/pull/5883)
* New Image View fixes/improvements
[\#5872](https://github.com/matrix-org/matrix-react-sdk/pull/5872)
* Limit voice recording length
[\#5871](https://github.com/matrix-org/matrix-react-sdk/pull/5871)
* Clean up add existing to space dialog and include DMs in it too
[\#5881](https://github.com/matrix-org/matrix-react-sdk/pull/5881)
* Fix unknown slash command error exploding
[\#5853](https://github.com/matrix-org/matrix-react-sdk/pull/5853)
* Switch to a spec conforming email validation Regexp
[\#5852](https://github.com/matrix-org/matrix-react-sdk/pull/5852)
* Cleanup unused state in MessageComposer
[\#5877](https://github.com/matrix-org/matrix-react-sdk/pull/5877)
* Pulse animation for voice messages recording state
[\#5869](https://github.com/matrix-org/matrix-react-sdk/pull/5869)
* Don't include invisible rooms in notify summary
[\#5875](https://github.com/matrix-org/matrix-react-sdk/pull/5875)
* Properly disable composer access when recording a voice message
[\#5870](https://github.com/matrix-org/matrix-react-sdk/pull/5870)
* Stabilise starting a DM with multiple people flow
[\#5862](https://github.com/matrix-org/matrix-react-sdk/pull/5862)
* Render msgOption only if showReadReceipts is enabled
[\#5864](https://github.com/matrix-org/matrix-react-sdk/pull/5864)
* Labs: Add quick/cheap "do not disturb" flag
[\#5873](https://github.com/matrix-org/matrix-react-sdk/pull/5873)
* Fix ReadReceipts animations
[\#5836](https://github.com/matrix-org/matrix-react-sdk/pull/5836)
* Add tooltips to message previews
[\#5859](https://github.com/matrix-org/matrix-react-sdk/pull/5859)
* IRC Layout fix layout spacing in replies
[\#5855](https://github.com/matrix-org/matrix-react-sdk/pull/5855)
* Move user to welcome_page if continuing with previous session
[\#5849](https://github.com/matrix-org/matrix-react-sdk/pull/5849)
* Improve image view
[\#5521](https://github.com/matrix-org/matrix-react-sdk/pull/5521)
* Add a button to reset personal encryption state during login
[\#5819](https://github.com/matrix-org/matrix-react-sdk/pull/5819)
* Fix js-sdk import in SlashCommands
[\#5850](https://github.com/matrix-org/matrix-react-sdk/pull/5850)
* Fix useRoomPowerLevels hook
[\#5854](https://github.com/matrix-org/matrix-react-sdk/pull/5854)
* Prevent state events being rendered with invalid state keys
[\#5851](https://github.com/matrix-org/matrix-react-sdk/pull/5851)
* Give server ACLs a name in 'roles & permissions' tab
[\#5838](https://github.com/matrix-org/matrix-react-sdk/pull/5838)
* Don't hide notification badge on the home space button as it has no menu
[\#5845](https://github.com/matrix-org/matrix-react-sdk/pull/5845)
* User Info hide disambiguation as we always show MXID anyway
[\#5843](https://github.com/matrix-org/matrix-react-sdk/pull/5843)
* Improve kick state to not show if the target was not joined to begin with
[\#5846](https://github.com/matrix-org/matrix-react-sdk/pull/5846)
* Fix space store wrongly switching to a non-space filter
[\#5844](https://github.com/matrix-org/matrix-react-sdk/pull/5844)
* Tweak appearance of invite reason
[\#5847](https://github.com/matrix-org/matrix-react-sdk/pull/5847)
* Update Inter font to v3.18
[\#5840](https://github.com/matrix-org/matrix-react-sdk/pull/5840)
* Enable sharing historical keys on invite
[\#5839](https://github.com/matrix-org/matrix-react-sdk/pull/5839)
* Add ability to hide post-login encryption setup with customisation point
[\#5834](https://github.com/matrix-org/matrix-react-sdk/pull/5834)
* Use LaTeX and TeX delimiters by default
[\#5515](https://github.com/matrix-org/matrix-react-sdk/pull/5515)
* Clone author's deps fork for Netlify previews
[\#5837](https://github.com/matrix-org/matrix-react-sdk/pull/5837)
* Show drop file UI only if dragging a file
[\#5827](https://github.com/matrix-org/matrix-react-sdk/pull/5827)
* Ignore punctuation when filtering rooms
[\#5824](https://github.com/matrix-org/matrix-react-sdk/pull/5824)
* Resizable CallView
[\#5710](https://github.com/matrix-org/matrix-react-sdk/pull/5710)
Changes in [3.18.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.18.0) (2021-04-12) Changes in [3.18.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.18.0) (2021-04-12)
===================================================================================================== =====================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.18.0-rc.1...v3.18.0) [Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.18.0-rc.1...v3.18.0)
@ -312,11 +542,12 @@ Changes in [3.15.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/
## Security notice ## Security notice
matrix-react-sdk 3.15.0 fixes a low severity issue (CVE-2021-21320) where the matrix-react-sdk 3.15.0 fixes a moderate severity issue (CVE-2021-21320) where
user content sandbox can be abused to trick users into opening unexpected the user content sandbox can be abused to trick users into opening unexpected
documents. The content is opened with a `blob` origin that cannot access Matrix documents after several user interactions. The content can be opened with a
user data, so messages and secrets are not at risk. Thanks to @keerok for `blob` origin from the Matrix client, so it is possible for a malicious document
responsibly disclosing this via Matrix's Security Disclosure Policy. to access user messages and secrets. Thanks to @keerok for responsibly
disclosing this via Matrix's Security Disclosure Policy.
## All changes ## All changes

View file

@ -28,7 +28,7 @@ Platform Targets:
* WebRTC features (VoIP and Video calling) are only available in Chrome & Firefox. * WebRTC features (VoIP and Video calling) are only available in Chrome & Firefox.
* Mobile Web is not currently a target platform - instead please use the native * Mobile Web is not currently a target platform - instead please use the native
iOS (https://github.com/matrix-org/matrix-ios-kit) and Android iOS (https://github.com/matrix-org/matrix-ios-kit) and Android
(https://github.com/matrix-org/matrix-android-sdk) SDKs. (https://github.com/matrix-org/matrix-android-sdk2) SDKs.
All code lands on the `develop` branch - `master` is only used for stable releases. All code lands on the `develop` branch - `master` is only used for stable releases.
**Please file PRs against `develop`!!** **Please file PRs against `develop`!!**

View file

@ -1,6 +1,6 @@
{ {
"name": "matrix-react-sdk", "name": "matrix-react-sdk",
"version": "3.18.0", "version": "3.20.0",
"description": "SDK for matrix.org using React", "description": "SDK for matrix.org using React",
"author": "matrix.org", "author": "matrix.org",
"repository": { "repository": {
@ -23,9 +23,7 @@
"package.json" "package.json"
], ],
"bin": { "bin": {
"reskindex": "scripts/reskindex.js", "reskindex": "scripts/reskindex.js"
"matrix-gen-i18n": "scripts/gen-i18n.js",
"matrix-prune-i18n": "scripts/prune-i18n.js"
}, },
"main": "./src/index.js", "main": "./src/index.js",
"matrix_src_main": "./src/index.js", "matrix_src_main": "./src/index.js",
@ -35,7 +33,7 @@
"prepublishOnly": "yarn build", "prepublishOnly": "yarn build",
"i18n": "matrix-gen-i18n", "i18n": "matrix-gen-i18n",
"prunei18n": "matrix-prune-i18n", "prunei18n": "matrix-prune-i18n",
"diff-i18n": "cp src/i18n/strings/en_EN.json src/i18n/strings/en_EN_orig.json && ./scripts/gen-i18n.js && node scripts/compare-file.js src/i18n/strings/en_EN_orig.json src/i18n/strings/en_EN.json", "diff-i18n": "cp src/i18n/strings/en_EN.json src/i18n/strings/en_EN_orig.json && matrix-gen-i18n && matrix-compare-i18n-files src/i18n/strings/en_EN_orig.json src/i18n/strings/en_EN.json",
"reskindex": "node scripts/reskindex.js -h header", "reskindex": "node scripts/reskindex.js -h header",
"reskindex:watch": "node scripts/reskindex.js -h header -w", "reskindex:watch": "node scripts/reskindex.js -h header -w",
"rethemendex": "res/css/rethemendex.sh", "rethemendex": "res/css/rethemendex.sh",
@ -51,7 +49,8 @@
"lint:types": "tsc --noEmit --jsx react", "lint:types": "tsc --noEmit --jsx react",
"lint:style": "stylelint 'res/css/**/*.scss'", "lint:style": "stylelint 'res/css/**/*.scss'",
"test": "jest", "test": "jest",
"test:e2e": "./test/end-to-end-tests/run.sh --app-url http://localhost:8080" "test:e2e": "./test/end-to-end-tests/run.sh --app-url http://localhost:8080",
"coverage": "yarn test --coverage"
}, },
"dependencies": { "dependencies": {
"@babel/runtime": "^7.12.5", "@babel/runtime": "^7.12.5",
@ -98,11 +97,10 @@
"react-transition-group": "^4.4.1", "react-transition-group": "^4.4.1",
"resize-observer-polyfill": "^1.5.1", "resize-observer-polyfill": "^1.5.1",
"rfc4648": "^1.4.0", "rfc4648": "^1.4.0",
"sanitize-html": "github:apostrophecms/sanitize-html#3c7f93f2058f696f5359e3e58d464161647226db", "sanitize-html": "^2.3.2",
"tar-js": "^0.3.0", "tar-js": "^0.3.0",
"text-encoding-utf-8": "^1.0.2", "text-encoding-utf-8": "^1.0.2",
"url": "^0.11.0", "url": "^0.11.0",
"velocity-animate": "^2.0.6",
"what-input": "^5.2.10", "what-input": "^5.2.10",
"zxcvbn": "^4.4.2" "zxcvbn": "^4.4.2"
}, },
@ -134,6 +132,7 @@
"@types/modernizr": "^3.5.3", "@types/modernizr": "^3.5.3",
"@types/node": "^14.14.22", "@types/node": "^14.14.22",
"@types/pako": "^1.0.1", "@types/pako": "^1.0.1",
"@types/parse5": "^6.0.0",
"@types/qrcode": "^1.3.5", "@types/qrcode": "^1.3.5",
"@types/react": "^16.9", "@types/react": "^16.9",
"@types/react-dom": "^16.9.10", "@types/react-dom": "^16.9.10",
@ -161,6 +160,7 @@
"jest-fetch-mock": "^3.0.3", "jest-fetch-mock": "^3.0.3",
"matrix-mock-request": "^1.2.3", "matrix-mock-request": "^1.2.3",
"matrix-react-test-utils": "^0.2.2", "matrix-react-test-utils": "^0.2.2",
"matrix-web-i18n": "github:matrix-org/matrix-web-i18n",
"olm": "https://packages.matrix.org/npm/olm/olm-3.2.1.tgz", "olm": "https://packages.matrix.org/npm/olm/olm-3.2.1.tgz",
"react-test-renderer": "^16.14.0", "react-test-renderer": "^16.14.0",
"rimraf": "^3.0.2", "rimraf": "^3.0.2",
@ -190,6 +190,12 @@
}, },
"transformIgnorePatterns": [ "transformIgnorePatterns": [
"/node_modules/(?!matrix-js-sdk).+$" "/node_modules/(?!matrix-js-sdk).+$"
],
"collectCoverageFrom": [
"<rootDir>/src/**/*.{js,ts,tsx}"
],
"coverageReporters": [
"text"
] ]
} }
} }

View file

@ -28,6 +28,16 @@ $MessageTimestamp_width_hover: calc($MessageTimestamp_width - 2 * $EventTile_e2e
:root { :root {
font-size: 10px; font-size: 10px;
--transition-short: .1s;
--transition-standard: .3s;
}
@media (prefers-reduced-motion) {
:root {
--transition-short: 0;
--transition-standard: 0;
}
} }
html { html {

View file

@ -161,6 +161,7 @@
@import "./views/messages/_MStickerBody.scss"; @import "./views/messages/_MStickerBody.scss";
@import "./views/messages/_MTextBody.scss"; @import "./views/messages/_MTextBody.scss";
@import "./views/messages/_MVideoBody.scss"; @import "./views/messages/_MVideoBody.scss";
@import "./views/messages/_MVoiceMessageBody.scss";
@import "./views/messages/_MessageActionBar.scss"; @import "./views/messages/_MessageActionBar.scss";
@import "./views/messages/_MessageTimestamp.scss"; @import "./views/messages/_MessageTimestamp.scss";
@import "./views/messages/_MjolnirBody.scss"; @import "./views/messages/_MjolnirBody.scss";
@ -248,6 +249,8 @@
@import "./views/toasts/_AnalyticsToast.scss"; @import "./views/toasts/_AnalyticsToast.scss";
@import "./views/toasts/_NonUrgentEchoFailureToast.scss"; @import "./views/toasts/_NonUrgentEchoFailureToast.scss";
@import "./views/verification/_VerificationShowSas.scss"; @import "./views/verification/_VerificationShowSas.scss";
@import "./views/voice_messages/_PlayPauseButton.scss";
@import "./views/voice_messages/_PlaybackContainer.scss";
@import "./views/voice_messages/_Waveform.scss"; @import "./views/voice_messages/_Waveform.scss";
@import "./views/voip/_CallContainer.scss"; @import "./views/voip/_CallContainer.scss";
@import "./views/voip/_CallView.scss"; @import "./views/voip/_CallView.scss";

View file

@ -22,7 +22,6 @@ limitations under the License.
} }
.mx_FilePanel .mx_RoomView_messageListWrapper { .mx_FilePanel .mx_RoomView_messageListWrapper {
margin-right: 20px;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
justify-content: center; justify-content: center;

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
.mx_RoomStatusBar { .mx_RoomStatusBar:not(.mx_RoomStatusBar_unsentMessages) {
margin-left: 65px; margin-left: 65px;
min-height: 50px; min-height: 50px;
} }
@ -68,6 +68,99 @@ limitations under the License.
min-height: 58px; min-height: 58px;
} }
.mx_RoomStatusBar_unsentMessages {
> div[role="alert"] {
// cheat some basic alignment
display: flex;
align-items: center;
min-height: 70px;
margin: 12px;
padding-left: 16px;
background-color: $header-panel-bg-color;
border-radius: 4px;
}
.mx_RoomStatusBar_unsentBadge {
margin-right: 12px;
.mx_NotificationBadge {
// Override sizing from the default badge
width: 24px !important;
height: 24px !important;
border-radius: 24px !important;
.mx_NotificationBadge_count {
font-size: $font-16px !important; // override default
}
}
}
.mx_RoomStatusBar_unsentTitle {
color: $warning-color;
font-size: $font-15px;
}
.mx_RoomStatusBar_unsentDescription {
font-size: $font-12px;
}
.mx_RoomStatusBar_unsentButtonBar {
flex-grow: 1;
text-align: right;
margin-right: 22px;
color: $muted-fg-color;
.mx_AccessibleButton {
padding: 5px 10px;
padding-left: 28px; // 16px for the icon, 2px margin to text, 10px regular padding
display: inline-block;
position: relative;
&:nth-child(2) {
border-left: 1px solid $resend-button-divider-color;
}
&::before {
content: '';
position: absolute;
left: 10px; // inset for regular button padding
background-color: $muted-fg-color;
mask-repeat: no-repeat;
mask-position: center;
mask-size: contain;
}
&.mx_RoomStatusBar_unsentCancelAllBtn::before {
mask-image: url('$(res)/img/element-icons/trashcan.svg');
width: 12px;
height: 16px;
top: calc(50% - 8px); // text sizes are dynamic
}
&.mx_RoomStatusBar_unsentResendAllBtn {
padding-left: 34px; // 28px from above, but +6px to account for the wider icon
&::before {
mask-image: url('$(res)/img/element-icons/retry.svg');
width: 18px;
height: 18px;
top: calc(50% - 9px); // text sizes are dynamic
}
}
}
.mx_InlineSpinner {
vertical-align: middle;
margin-right: 5px;
top: 1px; // just to help the vertical alignment be slightly better
& + span {
margin-right: 10px; // same margin/padding as the rightmost button
}
}
}
}
.mx_RoomStatusBar_connectionLostBar img { .mx_RoomStatusBar_connectionLostBar img {
padding-left: 10px; padding-left: 10px;
padding-right: 10px; padding-right: 10px;
@ -103,7 +196,7 @@ limitations under the License.
} }
.mx_MatrixChat_useCompactLayout { .mx_MatrixChat_useCompactLayout {
.mx_RoomStatusBar { .mx_RoomStatusBar:not(.mx_RoomStatusBar_unsentMessages) {
min-height: 40px; min-height: 40px;
} }

View file

@ -21,6 +21,5 @@ limitations under the License.
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: flex-end; justify-content: flex-end;
overflow-y: hidden;
} }
} }

View file

@ -35,7 +35,7 @@ $activeBorderColor: $secondary-fg-color;
.mx_SpacePanel_spaceTreeWrapper { .mx_SpacePanel_spaceTreeWrapper {
flex: 1; flex: 1;
overflow-y: scroll; padding: 8px 8px 16px 0;
} }
.mx_SpacePanel_toggleCollapse { .mx_SpacePanel_toggleCollapse {
@ -59,11 +59,10 @@ $activeBorderColor: $secondary-fg-color;
margin: 0; margin: 0;
list-style: none; list-style: none;
padding: 0; padding: 0;
padding-left: 16px;
}
.mx_AutoHideScrollbar { > .mx_SpaceItem {
padding: 8px 0 16px; padding-left: 16px;
}
} }
.mx_SpaceButton_toggleCollapse { .mx_SpaceButton_toggleCollapse {
@ -80,6 +79,10 @@ $activeBorderColor: $secondary-fg-color;
.mx_SpaceItem { .mx_SpaceItem {
display: inline-flex; display: inline-flex;
flex-flow: wrap; flex-flow: wrap;
&.mx_SpaceItem_narrow {
align-self: baseline;
}
} }
.mx_SpaceItem.collapsed { .mx_SpaceItem.collapsed {
@ -234,7 +237,6 @@ $activeBorderColor: $secondary-fg-color;
.mx_SpacePanel_badgeContainer { .mx_SpacePanel_badgeContainer {
position: absolute; position: absolute;
height: 16px;
// Create a flexbox to make aligning dot badges easier // Create a flexbox to make aligning dot badges easier
display: flex; display: flex;
@ -246,23 +248,37 @@ $activeBorderColor: $secondary-fg-color;
.mx_NotificationBadge_dot { .mx_NotificationBadge_dot {
// make the smaller dot occupy the same width for centering // make the smaller dot occupy the same width for centering
margin-left: 7px; margin: 0 7px;
margin-right: 7px;
} }
} }
&.collapsed { &.collapsed {
.mx_SpaceButton { .mx_SpaceButton {
.mx_SpacePanel_badgeContainer { .mx_SpacePanel_badgeContainer {
right: -3px; right: 0;
top: -3px; top: 0;
.mx_NotificationBadge {
background-clip: padding-box;
}
.mx_NotificationBadge_dot {
margin: 0 -1px 0 0;
border: 3px solid $groupFilterPanel-bg-color;
}
.mx_NotificationBadge_2char,
.mx_NotificationBadge_3char {
margin: -5px -5px 0 0;
border: 2px solid $groupFilterPanel-bg-color;
}
} }
&.mx_SpaceButton_active .mx_SpacePanel_badgeContainer { &.mx_SpaceButton_active .mx_SpacePanel_badgeContainer {
// when we draw the selection border we move the relative bounds of our parent // when we draw the selection border we move the relative bounds of our parent
// so update our position within the bounds of the parent to maintain position overall // so update our position within the bounds of the parent to maintain position overall
right: -6px; right: -3px;
top: -6px; top: -3px;
} }
} }
} }
@ -276,7 +292,7 @@ $activeBorderColor: $secondary-fg-color;
.mx_SpaceButton:hover, .mx_SpaceButton:hover,
.mx_SpaceButton:focus-within, .mx_SpaceButton:focus-within,
.mx_SpaceButton_hasMenuOpen { .mx_SpaceButton_hasMenuOpen {
&:not(.mx_SpaceButton_home) { &:not(.mx_SpaceButton_home):not(.mx_SpaceButton_invite) {
// Hide the badge container on hover because it'll be a menu button // Hide the badge container on hover because it'll be a menu button
.mx_SpacePanel_badgeContainer { .mx_SpacePanel_badgeContainer {
width: 0; width: 0;

View file

@ -26,7 +26,10 @@ limitations under the License.
word-break: break-word; word-break: break-word;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
}
.mx_SpaceRoomDirectory,
.mx_SpaceRoomView_landing {
.mx_Dialog_title { .mx_Dialog_title {
display: flex; display: flex;
@ -56,65 +59,68 @@ limitations under the License.
} }
} }
.mx_Dialog_content { .mx_AccessibleButton_kind_link {
.mx_AccessibleButton_kind_link { padding: 0;
padding: 0; }
}
.mx_SearchBox { .mx_SearchBox {
margin: 24px 0 16px; margin: 24px 0 16px;
} }
.mx_SpaceRoomDirectory_noResults { .mx_SpaceRoomDirectory_noResults {
text-align: center; text-align: center;
> div { > div {
font-size: $font-15px;
line-height: $font-24px;
color: $secondary-fg-color;
}
}
.mx_SpaceRoomDirectory_listHeader {
display: flex;
min-height: 32px;
align-items: center;
font-size: $font-15px; font-size: $font-15px;
line-height: $font-24px; line-height: $font-24px;
color: $primary-fg-color; color: $secondary-fg-color;
}
}
.mx_AccessibleButton { .mx_SpaceRoomDirectory_listHeader {
padding: 2px 8px; display: flex;
font-weight: normal; min-height: 32px;
align-items: center;
font-size: $font-15px;
line-height: $font-24px;
color: $primary-fg-color;
& + .mx_AccessibleButton { .mx_AccessibleButton {
margin-left: 16px; padding: 4px 12px;
} font-weight: normal;
}
> span { & + .mx_AccessibleButton {
margin-left: auto; margin-left: 16px;
} }
} }
.mx_SpaceRoomDirectory_error { .mx_AccessibleButton_kind_danger_outline,
position: relative; .mx_AccessibleButton_kind_primary_outline {
font-weight: $font-semi-bold; padding: 3px 12px; // to account for the 1px border
color: $notice-primary-color; }
font-size: $font-15px;
line-height: $font-18px;
margin: 20px auto 12px;
padding-left: 24px;
width: max-content;
&::before { > span {
content: ""; margin-left: auto;
position: absolute; }
height: 16px; }
width: 16px;
left: 0; .mx_SpaceRoomDirectory_error {
background-image: url("$(res)/img/element-icons/warning-badge.svg"); position: relative;
} font-weight: $font-semi-bold;
color: $notice-primary-color;
font-size: $font-15px;
line-height: $font-18px;
margin: 20px auto 12px;
padding-left: 24px;
width: max-content;
&::before {
content: "";
position: absolute;
height: 16px;
width: 16px;
left: 0;
background-image: url("$(res)/img/element-icons/warning-badge.svg");
} }
} }
} }
@ -245,11 +251,17 @@ limitations under the License.
grid-row: 1/3; grid-row: 1/3;
.mx_AccessibleButton { .mx_AccessibleButton {
padding: 8px 18px; line-height: $font-24px;
padding: 4px 16px;
display: inline-block; display: inline-block;
visibility: hidden; visibility: hidden;
} }
.mx_AccessibleButton_kind_danger_outline,
.mx_AccessibleButton_kind_primary_outline {
padding: 3px 16px; // to account for the 1px border
}
.mx_Checkbox { .mx_Checkbox {
display: inline-flex; display: inline-flex;
vertical-align: middle; vertical-align: middle;

View file

@ -81,6 +81,16 @@ $SpaceRoomViewInnerWidth: 428px;
color: $secondary-fg-color; color: $secondary-fg-color;
margin-top: 12px; margin-top: 12px;
margin-bottom: 24px; margin-bottom: 24px;
max-width: $SpaceRoomViewInnerWidth;
}
.mx_AddExistingToSpace {
max-width: $SpaceRoomViewInnerWidth;
.mx_AddExistingToSpace_content {
height: calc(100vh - 360px);
max-height: 400px;
}
} }
.mx_SpaceRoomView_buttons { .mx_SpaceRoomView_buttons {
@ -214,51 +224,22 @@ $SpaceRoomViewInnerWidth: 428px;
.mx_SpaceRoomView_info { .mx_SpaceRoomView_info {
display: inline-block; display: inline-block;
margin: 0; margin: 0 auto 0 0;
} }
.mx_FacePile { .mx_FacePile {
display: inline-block; display: inline-block;
margin-left: auto;
margin-right: 12px; margin-right: 12px;
.mx_FacePile_faces { .mx_FacePile_faces {
cursor: pointer; cursor: pointer;
> span:hover {
.mx_BaseAvatar {
filter: brightness(0.8);
}
}
> span:first-child {
position: relative;
.mx_BaseAvatar {
filter: brightness(0.8);
}
&::before {
content: "";
z-index: 1;
position: absolute;
top: 0;
left: 0;
height: 30px;
width: 30px;
background: #ffffff; // white icon fill
mask-position: center;
mask-size: 24px;
mask-repeat: no-repeat;
mask-image: url('$(res)/img/element-icons/room/ellipsis.svg');
}
}
} }
} }
.mx_SpaceRoomView_landing_inviteButton { .mx_SpaceRoomView_landing_inviteButton {
position: relative; position: relative;
padding-left: 40px; padding: 4px 18px 4px 40px;
line-height: $font-24px;
height: min-content; height: min-content;
&::before { &::before {
@ -274,6 +255,27 @@ $SpaceRoomViewInnerWidth: 428px;
mask-image: url('$(res)/img/element-icons/room/invite.svg'); mask-image: url('$(res)/img/element-icons/room/invite.svg');
} }
} }
.mx_SpaceRoomView_landing_settingsButton {
position: relative;
margin-left: 16px;
width: 24px;
height: 24px;
&::before {
position: absolute;
content: "";
left: 0;
top: 0;
height: 24px;
width: 24px;
background: $tertiary-fg-color;
mask-position: center;
mask-size: contain;
mask-repeat: no-repeat;
mask-image: url('$(res)/img/element-icons/settings.svg');
}
}
} }
.mx_SpaceRoomView_landing_topic { .mx_SpaceRoomView_landing_topic {
@ -288,80 +290,6 @@ $SpaceRoomViewInnerWidth: 428px;
background-color: $groupFilterPanel-bg-color; background-color: $groupFilterPanel-bg-color;
} }
.mx_SpaceRoomView_landing_adminButtons {
margin-top: 24px;
.mx_AccessibleButton {
position: relative;
width: 160px;
height: 124px;
box-sizing: border-box;
padding: 72px 16px 0;
border-radius: 12px;
border: 1px solid $input-border-color;
margin-right: 28px;
margin-bottom: 20px;
font-size: $font-14px;
display: inline-block;
vertical-align: bottom;
&:last-child {
margin-right: 0;
}
&:hover {
background-color: rgba(141, 151, 165, 0.1);
}
&::before, &::after {
position: absolute;
content: "";
left: 16px;
top: 16px;
height: 40px;
width: 40px;
border-radius: 20px;
}
&::after {
mask-position: center;
mask-size: 30px;
mask-repeat: no-repeat;
background: #ffffff; // white icon fill
}
&.mx_SpaceRoomView_landing_addButton {
&::before {
background-color: #ac3ba8;
}
&::after {
mask-image: url('$(res)/img/element-icons/roomlist/explore.svg');
}
}
&.mx_SpaceRoomView_landing_createButton {
&::before {
background-color: #368bd6;
}
&::after {
mask-image: url('$(res)/img/element-icons/roomlist/explore.svg');
}
}
&.mx_SpaceRoomView_landing_settingsButton {
&::before {
background-color: #5c56f5;
}
&::after {
mask-image: url('$(res)/img/element-icons/settings.svg');
}
}
}
}
.mx_SearchBox { .mx_SearchBox {
margin: 0 0 20px; margin: 0 0 20px;
} }

View file

@ -117,6 +117,32 @@ limitations under the License.
.mx_UserMenu_headerButtons { .mx_UserMenu_headerButtons {
// No special styles: the rest of the layout happens to make it work. // No special styles: the rest of the layout happens to make it work.
} }
.mx_UserMenu_dnd {
width: 24px;
height: 24px;
margin-right: 8px;
position: relative;
&::before {
content: '';
position: absolute;
width: 24px;
height: 24px;
mask-position: center;
mask-size: contain;
mask-repeat: no-repeat;
background: $muted-fg-color;
}
&.mx_UserMenu_dnd_noisy::before {
mask-image: url('$(res)/img/element-icons/notifications.svg');
}
&.mx_UserMenu_dnd_muted::before {
mask-image: url('$(res)/img/element-icons/roomlist/notifications-off.svg');
}
}
} }
&.mx_UserMenu_minimized { &.mx_UserMenu_minimized {

View file

@ -21,6 +21,66 @@ limitations under the License.
} }
} }
.mx_AddExistingToSpace {
.mx_SearchBox {
// To match the space around the title
margin: 0 0 15px 0;
flex-grow: 0;
}
.mx_AddExistingToSpace_content {
flex-grow: 1;
}
.mx_AddExistingToSpace_noResults {
display: block;
margin-top: 24px;
}
.mx_AddExistingToSpace_section {
&:not(:first-child) {
margin-top: 24px;
}
> h3 {
margin: 0;
color: $secondary-fg-color;
font-size: $font-12px;
font-weight: $font-semi-bold;
line-height: $font-15px;
}
.mx_AddExistingToSpace_entry {
display: flex;
margin-top: 12px;
.mx_BaseAvatar {
margin-right: 12px;
}
.mx_AddExistingToSpace_entry_name {
font-size: $font-15px;
line-height: 30px;
flex-grow: 1;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
margin-right: 12px;
}
.mx_Checkbox {
align-items: center;
}
}
}
.mx_AddExistingToSpace_section_spaces {
.mx_BaseAvatar_image {
border-radius: 8px;
}
}
}
.mx_AddExistingToSpaceDialog { .mx_AddExistingToSpaceDialog {
width: 480px; width: 480px;
color: $primary-fg-color; color: $primary-fg-color;
@ -41,7 +101,7 @@ limitations under the License.
.mx_BaseAvatar { .mx_BaseAvatar {
display: inline-flex; display: inline-flex;
margin: 5px 16px 5px 5px; margin: auto 16px auto 5px;
vertical-align: middle; vertical-align: middle;
} }
@ -100,80 +160,32 @@ limitations under the License.
} }
} }
.mx_SearchBox { .mx_AddExistingToSpace {
margin: 0; display: contents;
flex-grow: 0;
}
.mx_AddExistingToSpaceDialog_errorText {
font-weight: $font-semi-bold;
font-size: $font-12px;
line-height: $font-15px;
color: $notice-primary-color;
margin-bottom: 28px;
}
.mx_AddExistingToSpaceDialog_content {
flex-grow: 1;
.mx_AddExistingToSpaceDialog_noResults {
display: block;
margin-top: 24px;
}
}
.mx_AddExistingToSpaceDialog_section {
margin-top: 24px;
> h3 {
margin: 0;
color: $secondary-fg-color;
font-size: $font-12px;
font-weight: $font-semi-bold;
line-height: $font-15px;
}
.mx_AddExistingToSpaceDialog_entry {
display: flex;
margin-top: 12px;
.mx_BaseAvatar {
margin-right: 12px;
}
.mx_AddExistingToSpaceDialog_entry_name {
font-size: $font-15px;
line-height: 30px;
flex-grow: 1;
}
.mx_FormButton {
min-width: 92px;
font-weight: normal;
box-sizing: border-box;
}
}
}
.mx_AddExistingToSpaceDialog_section_spaces {
.mx_BaseAvatar_image {
border-radius: 8px;
}
} }
.mx_AddExistingToSpaceDialog_footer { .mx_AddExistingToSpaceDialog_footer {
display: flex; display: flex;
margin-top: 32px; margin-top: 20px;
> span { > span {
flex-grow: 1; flex-grow: 1;
font-size: $font-14px; font-size: $font-12px;
line-height: $font-15px; line-height: $font-15px;
font-weight: $font-semi-bold; color: $secondary-fg-color;
.mx_AccessibleButton { .mx_ProgressBar {
font-size: inherit; height: 8px;
display: inline-block; width: 100%;
@mixin ProgressBarBorderRadius 8px;
}
.mx_AddExistingToSpaceDialog_progressText {
margin-top: 8px;
font-size: $font-15px;
line-height: $font-24px;
color: $primary-fg-color;
} }
> * { > * {
@ -181,16 +193,58 @@ limitations under the License.
} }
} }
.mx_AddExistingToSpaceDialog_error {
padding-left: 12px;
> img {
align-self: center;
}
.mx_AddExistingToSpaceDialog_errorHeading {
font-weight: $font-semi-bold;
font-size: $font-15px;
line-height: $font-18px;
color: $notice-primary-color;
}
.mx_AddExistingToSpaceDialog_errorCaption {
margin-top: 4px;
font-size: $font-12px;
line-height: $font-15px;
color: $primary-fg-color;
}
}
.mx_AccessibleButton { .mx_AccessibleButton {
display: inline-block; display: inline-block;
align-self: center;
}
.mx_AccessibleButton_kind_primary {
padding: 8px 36px;
}
.mx_AddExistingToSpaceDialog_retryButton {
margin-left: 12px;
padding-left: 24px;
position: relative;
&::before {
content: '';
position: absolute;
background-color: $primary-fg-color;
mask-repeat: no-repeat;
mask-position: center;
mask-size: contain;
mask-image: url('$(res)/img/element-icons/retry.svg');
width: 18px;
height: 18px;
left: 0;
}
} }
.mx_AccessibleButton_kind_link { .mx_AccessibleButton_kind_link {
padding: 0; padding: 0;
} }
} }
.mx_FormButton {
padding: 8px 22px;
}
} }

View file

@ -76,12 +76,16 @@ limitations under the License.
border: 1px solid $button-danger-bg-color; border: 1px solid $button-danger-bg-color;
} }
.mx_AccessibleButton_kind_danger.mx_AccessibleButton_disabled, .mx_AccessibleButton_kind_danger.mx_AccessibleButton_disabled {
.mx_AccessibleButton_kind_danger_outline.mx_AccessibleButton_disabled {
color: $button-danger-disabled-fg-color; color: $button-danger-disabled-fg-color;
background-color: $button-danger-disabled-bg-color; background-color: $button-danger-disabled-bg-color;
} }
.mx_AccessibleButton_kind_danger_outline.mx_AccessibleButton_disabled {
color: $button-danger-disabled-bg-color;
border-color: $button-danger-disabled-bg-color;
}
.mx_AccessibleButton_hasKind.mx_AccessibleButton_kind_danger_sm { .mx_AccessibleButton_hasKind.mx_AccessibleButton_kind_danger_sm {
padding: 5px 12px; padding: 5px 12px;
color: $button-danger-fg-color; color: $button-danger-fg-color;

View file

@ -20,7 +20,7 @@ limitations under the License.
flex-direction: row-reverse; flex-direction: row-reverse;
vertical-align: middle; vertical-align: middle;
> span + span { > .mx_FacePile_face + .mx_FacePile_face {
margin-right: -8px; margin-right: -8px;
} }
@ -31,9 +31,32 @@ limitations under the License.
.mx_BaseAvatar_initial { .mx_BaseAvatar_initial {
margin: 1px; // to offset the border on the image margin: 1px; // to offset the border on the image
} }
.mx_FacePile_more {
position: relative;
border-radius: 100%;
width: 30px;
height: 30px;
background-color: $groupFilterPanel-bg-color;
&::before {
content: "";
z-index: 1;
position: absolute;
top: 0;
left: 0;
height: inherit;
width: inherit;
background: $tertiary-fg-color;
mask-position: center;
mask-size: 20px;
mask-repeat: no-repeat;
mask-image: url('$(res)/img/element-icons/room/ellipsis.svg');
}
}
} }
> span { .mx_FacePile_summary {
margin-left: 12px; margin-left: 12px;
font-size: $font-14px; font-size: $font-14px;
line-height: $font-24px; line-height: $font-24px;

View file

@ -31,8 +31,7 @@ limitations under the License.
.mx_ImageView_image { .mx_ImageView_image {
pointer-events: all; pointer-events: all;
max-width: 95%; flex-shrink: 0;
max-height: 95%;
} }
.mx_ImageView_panel { .mx_ImageView_panel {

View file

@ -21,7 +21,7 @@ progress.mx_ProgressBar {
appearance: none; appearance: none;
border: none; border: none;
@mixin ProgressBarBorderRadius "6px"; @mixin ProgressBarBorderRadius 6px;
@mixin ProgressBarColour $progressbar-fg-color; @mixin ProgressBarColour $progressbar-fg-color;
@mixin ProgressBarBgColour $progressbar-bg-color; @mixin ProgressBarBgColour $progressbar-bg-color;
::-webkit-progress-value { ::-webkit-progress-value {

View file

@ -61,9 +61,9 @@ limitations under the License.
.mx_MFileBody_info { .mx_MFileBody_info {
background-color: $message-body-panel-bg-color; background-color: $message-body-panel-bg-color;
border-radius: 4px; border-radius: 12px;
width: 270px; width: 243px; // same width as a playable voice message, accounting for padding
padding: 8px; padding: 6px 12px;
color: $message-body-panel-fg-color; color: $message-body-panel-fg-color;
.mx_MFileBody_info_icon { .mx_MFileBody_info_icon {
@ -82,7 +82,7 @@ limitations under the License.
mask-position: center; mask-position: center;
mask-size: cover; mask-size: cover;
mask-image: url('$(res)/img/element-icons/room/composer/attach.svg'); mask-image: url('$(res)/img/element-icons/room/composer/attach.svg');
background-color: $message-body-panel-fg-color; background-color: $message-body-panel-icon-fg-color;
width: 13px; width: 13px;
height: 15px; height: 15px;

View file

@ -0,0 +1,19 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
.mx_MVoiceMessageBody {
display: inline-block; // makes the playback controls magically line up
}

View file

@ -105,3 +105,11 @@ limitations under the License.
.mx_MessageActionBar_optionsButton::after { .mx_MessageActionBar_optionsButton::after {
mask-image: url('$(res)/img/element-icons/context-menu.svg'); mask-image: url('$(res)/img/element-icons/context-menu.svg');
} }
.mx_MessageActionBar_resendButton::after {
mask-image: url('$(res)/img/element-icons/retry.svg');
}
.mx_MessageActionBar_cancelButton::after {
mask-image: url('$(res)/img/element-icons/trashcan.svg');
}

View file

@ -34,6 +34,10 @@ limitations under the License.
border-color: $reaction-row-button-selected-border-color; border-color: $reaction-row-button-selected-border-color;
} }
&.mx_AccessibleButton_disabled {
cursor: not-allowed;
}
.mx_ReactionsRowButton_content { .mx_ReactionsRowButton_content {
max-width: 100px; max-width: 100px;
overflow: hidden; overflow: hidden;

View file

@ -68,8 +68,8 @@ limitations under the License.
} }
&.mx_BasicMessageComposer_input_disabled { &.mx_BasicMessageComposer_input_disabled {
// Ignore all user input to avoid accidentally triggering the composer
pointer-events: none; pointer-events: none;
cursor: not-allowed;
} }
} }

View file

@ -214,10 +214,6 @@ $left-gutter: 64px;
color: $accent-fg-color; color: $accent-fg-color;
} }
.mx_EventTile_notSent {
color: $event-notsent-color;
}
.mx_EventTile_receiptSent, .mx_EventTile_receiptSent,
.mx_EventTile_receiptSending { .mx_EventTile_receiptSending {
// We don't use `position: relative` on the element because then it won't line // We don't use `position: relative` on the element because then it won't line
@ -283,6 +279,10 @@ $left-gutter: 64px;
display: inline-block; display: inline-block;
height: $font-14px; height: $font-14px;
width: $font-14px; width: $font-14px;
transition:
left var(--transition-short) ease-out,
top var(--transition-standard) ease-out;
} }
.mx_EventTile_readAvatarRemainder { .mx_EventTile_readAvatarRemainder {

View file

@ -18,6 +18,10 @@ limitations under the License.
margin-left: 8px; margin-left: 8px;
margin-bottom: 4px; margin-bottom: 4px;
&.mx_RoomSublist_hidden {
display: none;
}
.mx_RoomSublist_headerContainer { .mx_RoomSublist_headerContainer {
// Create a flexbox to make alignment easy // Create a flexbox to make alignment easy
display: flex; display: flex;
@ -37,7 +41,9 @@ limitations under the License.
// The combined height must be set in the LeftPanel component for sticky headers // The combined height must be set in the LeftPanel component for sticky headers
// to work correctly. // to work correctly.
padding-bottom: 8px; padding-bottom: 8px;
height: 24px; // Allow the container to collapse on itself if its children
// are not in the normal document flow
max-height: 24px;
color: $roomlist-header-color; color: $roomlist-header-color;
.mx_RoomSublist_stickable { .mx_RoomSublist_stickable {

View file

@ -35,42 +35,64 @@ limitations under the License.
} }
} }
.mx_VoiceRecordComposerTile_waveformContainer { .mx_VoiceRecordComposerTile_delete {
padding: 5px; width: 14px; // w&h are size of icon
padding-right: 4px; // there's 1px from the waveform itself, so account for that height: 18px;
padding-left: 15px; // +10px for the live circle, +5px for regular padding vertical-align: middle;
background-color: $voice-record-waveform-bg-color; margin-right: 11px; // distance from left edge of waveform container (container has some margin too)
border-radius: 12px; background-color: $voice-record-icon-color;
margin-right: 12px; // isolate from stop button mask-repeat: no-repeat;
mask-size: contain;
mask-image: url('$(res)/img/element-icons/trashcan.svg');
}
// Cheat at alignment a bit .mx_MessageComposer_row .mx_VoiceMessagePrimaryContainer {
display: flex; // Note: remaining class properties are in the PlayerContainer CSS.
align-items: center;
margin: 6px; // force the composer area to put a gutter around us
margin-right: 12px; // isolate from stop/send button
position: relative; // important for the live circle position: relative; // important for the live circle
color: $voice-record-waveform-fg-color; &.mx_VoiceRecordComposerTile_recording {
font-size: $font-14px; // We are putting the circle in this padding, so we need +10px from the regular
// padding on the left side.
padding-left: 22px;
&::before { &::before {
// TODO: @@ TravisR: Animate animation: recording-pulse 2s infinite;
content: '';
background-color: $voice-record-live-circle-color;
width: 10px;
height: 10px;
position: absolute;
left: 8px;
top: 16px; // vertically center
border-radius: 10px;
}
.mx_Waveform_bar { content: '';
background-color: $voice-record-waveform-fg-color; background-color: $voice-record-live-circle-color;
} width: 10px;
height: 10px;
.mx_Clock { position: absolute;
padding-right: 8px; // isolate from waveform left: 12px; // 12px from the left edge for container padding
padding-left: 10px; // isolate from live circle top: 18px; // vertically center (middle align with clock)
width: 42px; // we're not using a monospace font, so fake it border-radius: 10px;
}
}
}
// The keyframes are slightly weird here to help make a ramping/punch effect
// for the recording dot. We start and end at 100% opacity to help make the
// dot feel a bit like a real lamp that is blinking: the animation ends up
// spending a lot of its time showing a steady state without a fade effect.
// This lamp effect extends into why the 0% opacity keyframe is not in the
// midpoint: lamps take longer to turn off than they do to turn on, and the
// extra frames give it a bit of a realistic punch for when the animation is
// ramping back up to 100% opacity.
//
// Target animation timings: steady for 1.5s, fade out for 0.3s, fade in for 0.2s
// (intended to be used in a loop for 2s animation speed)
@keyframes recording-pulse {
0% {
opacity: 1;
}
35% {
opacity: 0;
}
65% {
opacity: 1;
} }
} }

View file

@ -22,3 +22,34 @@ limitations under the License.
.mx_HelpUserSettingsTab span.mx_AccessibleButton { .mx_HelpUserSettingsTab span.mx_AccessibleButton {
word-break: break-word; word-break: break-word;
} }
.mx_HelpUserSettingsTab code {
word-break: break-all;
user-select: all;
}
.mx_HelpUserSettingsTab_accessToken {
display: flex;
justify-content: space-between;
border-radius: 5px;
border: solid 1px $light-fg-color;
margin-bottom: 10px;
margin-top: 10px;
padding: 10px;
}
.mx_HelpUserSettingsTab_accessToken_copy {
flex-shrink: 0;
cursor: pointer;
margin-left: 20px;
display: inherit;
}
.mx_HelpUserSettingsTab_accessToken_copy > div {
mask-image: url($copy-button-url);
background-color: $message-action-bar-fg-color;
margin-left: 5px;
width: 20px;
height: 20px;
background-repeat: no-repeat;
}

View file

@ -0,0 +1,51 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
.mx_PlayPauseButton {
position: relative;
width: 32px;
height: 32px;
border-radius: 32px;
background-color: $voice-playback-button-bg-color;
&::before {
content: '';
position: absolute; // sizing varies by icon
background-color: $voice-playback-button-fg-color;
mask-repeat: no-repeat;
mask-size: contain;
}
&.mx_PlayPauseButton_disabled::before {
opacity: 0.5;
}
&.mx_PlayPauseButton_play::before {
width: 13px;
height: 16px;
top: 8px; // center
left: 12px; // center
mask-image: url('$(res)/img/element-icons/play.svg');
}
&.mx_PlayPauseButton_pause::before {
width: 10px;
height: 12px;
top: 10px; // center
left: 11px; // center
mask-image: url('$(res)/img/element-icons/pause.svg');
}
}

View file

@ -0,0 +1,53 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
// Dev note: there's no actual component called <PlaybackContainer />. These classes
// are shared amongst multiple voice message components.
// Container for live recording and playback controls
.mx_VoiceMessagePrimaryContainer {
// 7px top and bottom for visual design. 12px left & right, but the waveform (right)
// has a 1px padding on it that we want to account for.
padding: 7px 12px 7px 11px;
background-color: $voice-record-waveform-bg-color;
border-radius: 12px;
// Cheat at alignment a bit
display: flex;
align-items: center;
color: $voice-record-waveform-fg-color;
font-size: $font-14px;
line-height: $font-24px;
.mx_Waveform {
.mx_Waveform_bar {
background-color: $voice-record-waveform-incomplete-fg-color;
&.mx_Waveform_bar_100pct {
// Small animation to remove the mechanical feel of progress
transition: background-color 250ms ease;
background-color: $voice-record-waveform-fg-color;
}
}
}
.mx_Clock {
width: 42px; // we're not using a monospace font, so fake it
padding-right: 6px; // with the fixed width this ends up as a visual 8px most of the time, as intended.
padding-left: 8px; // isolate from recording circle / play control
}
}

View file

@ -17,7 +17,7 @@ limitations under the License.
.mx_CallView { .mx_CallView {
border-radius: 8px; border-radius: 8px;
background-color: $voipcall-plinth-color; background-color: $dark-panel-bg-color;
padding-left: 8px; padding-left: 8px;
padding-right: 8px; padding-right: 8px;
// XXX: CallContainer sets pointer-events: none - should probably be set back in a better place // XXX: CallContainer sets pointer-events: none - should probably be set back in a better place
@ -40,7 +40,8 @@ limitations under the License.
width: 320px; width: 320px;
padding-bottom: 8px; padding-bottom: 8px;
margin-top: 10px; margin-top: 10px;
box-shadow: 0px 14px 24px rgba(0, 0, 0, 0.08); background-color: $voipcall-plinth-color;
box-shadow: 0px 4px 20px rgba(0, 0, 0, 0.20);
border-radius: 8px; border-radius: 8px;
.mx_CallView_voice { .mx_CallView_voice {
@ -64,14 +65,17 @@ limitations under the License.
} }
} }
.mx_CallView_voice { .mx_CallView_content {
position: relative; position: relative;
display: flex; display: flex;
flex-direction: column; border-radius: 8px;
}
.mx_CallView_voice {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
flex-direction: column;
background-color: $inverted-bg-color; background-color: $inverted-bg-color;
border-radius: 8px;
} }
.mx_CallView_voice_avatarsContainer { .mx_CallView_voice_avatarsContainer {
@ -108,9 +112,7 @@ limitations under the License.
.mx_CallView_video { .mx_CallView_video {
width: 100%; width: 100%;
height: 100%; height: 100%;
position: relative;
z-index: 30; z-index: 30;
border-radius: 8px;
overflow: hidden; overflow: hidden;
} }

View file

@ -14,21 +14,37 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
.mx_VideoFeed_voice {
// We don't want to collide with the call controls that have 52px of height
padding-bottom: 52px;
background-color: $inverted-bg-color;
}
.mx_VideoFeed_remote { .mx_VideoFeed_remote {
width: 100%; width: 100%;
height: 100%; height: 100%;
background-color: #000; display: flex;
z-index: 50; justify-content: center;
align-items: center;
&.mx_VideoFeed_video {
background-color: #000;
}
} }
.mx_VideoFeed_local { .mx_VideoFeed_local {
width: 25%; max-width: 25%;
height: 25%; max-height: 25%;
position: absolute; position: absolute;
right: 10px; right: 10px;
top: 10px; top: 10px;
z-index: 100; z-index: 100;
border-radius: 4px; border-radius: 4px;
&.mx_VideoFeed_video {
background-color: transparent;
}
} }
.mx_VideoFeed_mirror { .mx_VideoFeed_mirror {

View file

@ -0,0 +1,4 @@
<svg width="10" height="12" viewBox="0 0 10 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0 1C0 0.447715 0.447715 0 1 0H2C2.55228 0 3 0.447715 3 1V11C3 11.5523 2.55228 12 2 12H1C0.447715 12 0 11.5523 0 11V1Z" fill="#737D8C"/>
<path d="M7 1C7 0.447715 7.44772 0 8 0H9C9.55228 0 10 0.447715 10 1V11C10 11.5523 9.55228 12 9 12H8C7.44772 12 7 11.5523 7 11V1Z" fill="#737D8C"/>
</svg>

After

Width:  |  Height:  |  Size: 396 B

View file

@ -0,0 +1,3 @@
<svg width="13" height="16" viewBox="0 0 13 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0 14.2104V1.78956C0 1.00724 0.857827 0.527894 1.5241 0.937906L11.6161 7.14834C12.2506 7.53883 12.2506 8.46117 11.6161 8.85166L1.5241 15.0621C0.857828 15.4721 0 14.9928 0 14.2104Z" fill="#737D8C"/>
</svg>

After

Width:  |  Height:  |  Size: 310 B

View file

@ -0,0 +1,3 @@
<svg width="18" height="19" viewBox="0 0 18 19" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M4.58365 3.90848C5.79757 2.94852 7.33285 2.375 9 2.375C12.6817 2.375 15.7112 5.1675 16.086 8.75H17.6314C17.9253 8.75 18.1006 9.07792 17.9376 9.32274L15.6812 12.711C15.5355 12.9297 15.2145 12.9297 15.0688 12.711L12.8124 9.32274C12.6494 9.07792 12.8247 8.75 13.1186 8.75H14.5754C14.2088 5.99798 11.8523 3.875 9 3.875C7.68247 3.875 6.4726 4.32705 5.51407 5.08504C5.45221 5.13396 5.39899 5.17326 5.36001 5.20114C5.34047 5.21513 5.32433 5.22637 5.31229 5.23463L5.29733 5.24482L5.29227 5.24821L5.29037 5.24948L5.28958 5.25L5.28923 5.25023L5.28906 5.25034L5.28898 5.2504L4.875 4.625L5.2889 5.25045C4.94347 5.47904 4.47814 5.38433 4.24955 5.0389C4.02136 4.69408 4.11534 4.22977 4.45929 4.00075L4.4633 3.99802C4.46789 3.99487 4.47605 3.9892 4.48719 3.98123C4.5096 3.9652 4.5433 3.94038 4.58365 3.90848ZM3.42456 10.25H4.88138C5.1753 10.25 5.35061 9.92208 5.18758 9.67726L2.93119 6.28905C2.78553 6.07032 2.46447 6.07032 2.31881 6.28905L0.0624241 9.67726C-0.100613 9.92207 0.0746987 10.25 0.368618 10.25H1.914C2.28878 13.8325 5.31828 16.625 9 16.625C10.7415 16.625 12.3388 15.9992 13.5764 14.9611C13.8938 14.6949 13.9353 14.2219 13.6691 13.9045C13.4029 13.5872 12.9298 13.5457 12.6125 13.8119C11.6349 14.6319 10.376 15.125 9 15.125C6.14769 15.125 3.79123 13.002 3.42456 10.25Z" fill="#737D8C"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View file

@ -0,0 +1,3 @@
<svg width="12" height="17" viewBox="0 0 12 17" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0.857143 14.5C0.857143 15.4491 1.62857 16.5 2.57143 16.5H9.42857C10.3714 16.5 11.1429 15.2542 11.1429 14.3051V5.67692C11.1429 4.72781 10.3714 3.95128 9.42857 3.95128H2.57143C1.62857 3.95128 0.857143 4.72781 0.857143 5.67692V14.5ZM11.1429 1.36282H9L8.39143 0.750218C8.23714 0.59491 8.01429 0.5 7.79143 0.5H4.20857C3.98571 0.5 3.76286 0.59491 3.60857 0.750218L3 1.36282H0.857143C0.385714 1.36282 0 1.75109 0 2.22564C0 2.70019 0.385714 3.08846 0.857143 3.08846H11.1429C11.6143 3.08846 12 2.70019 12 2.22564C12 1.75109 11.6143 1.36282 11.1429 1.36282Z" fill="#737D8C"/>
</svg>

After

Width:  |  Height:  |  Size: 679 B

View file

@ -9,6 +9,7 @@ $header-panel-text-primary-color: #B9BEC6;
$header-panel-text-secondary-color: #c8c8cd; $header-panel-text-secondary-color: #c8c8cd;
$text-primary-color: #ffffff; $text-primary-color: #ffffff;
$text-secondary-color: #B9BEC6; $text-secondary-color: #B9BEC6;
$quaternary-fg-color: #6F7882;
$search-bg-color: #181b21; $search-bg-color: #181b21;
$search-placeholder-color: #61708b; $search-placeholder-color: #61708b;
$room-highlight-color: #343a46; $room-highlight-color: #343a46;
@ -63,6 +64,8 @@ $input-invalid-border-color: $warning-color;
$field-focused-label-bg-color: $bg-color; $field-focused-label-bg-color: $bg-color;
$resend-button-divider-color: #b9bec64a; // muted-text with a 4A opacity.
// scrollbars // scrollbars
$scrollbar-thumb-color: rgba(255, 255, 255, 0.2); $scrollbar-thumb-color: rgba(255, 255, 255, 0.2);
$scrollbar-track-color: transparent; $scrollbar-track-color: transparent;
@ -85,7 +88,7 @@ $dialog-close-fg-color: #9fa9ba;
$dialog-background-bg-color: $header-panel-bg-color; $dialog-background-bg-color: $header-panel-bg-color;
$lightbox-background-bg-color: #000; $lightbox-background-bg-color: #000;
$lightbox-background-bg-opacity: 85%; $lightbox-background-bg-opacity: 0.85;
$settings-grey-fg-color: #a2a2a2; $settings-grey-fg-color: #a2a2a2;
$settings-profile-placeholder-bg-color: #21262c; $settings-profile-placeholder-bg-color: #21262c;
@ -110,7 +113,7 @@ $header-divider-color: $header-panel-text-primary-color;
$composer-e2e-icon-color: $header-panel-text-primary-color; $composer-e2e-icon-color: $header-panel-text-primary-color;
// this probably shouldn't have it's own colour // this probably shouldn't have it's own colour
$voipcall-plinth-color: #21262c; $voipcall-plinth-color: #394049;
// ******************** // ********************
@ -203,9 +206,18 @@ $breadcrumb-placeholder-bg-color: #272c35;
$user-tile-hover-bg-color: $header-panel-bg-color; $user-tile-hover-bg-color: $header-panel-bg-color;
$message-body-panel-bg-color: #21262c82; $message-body-panel-fg-color: $secondary-fg-color;
$message-body-panel-icon-bg-color: #8e99a4; $message-body-panel-bg-color: #394049; // "Dark Tile"
$message-body-panel-fg-color: $primary-fg-color; $message-body-panel-icon-fg-color: #21262C; // "Separator"
$message-body-panel-icon-bg-color: $tertiary-fg-color;
$voice-record-stop-border-color: $quaternary-fg-color;
$voice-record-waveform-bg-color: $message-body-panel-bg-color;
$voice-record-waveform-fg-color: $message-body-panel-fg-color;
$voice-record-waveform-incomplete-fg-color: $quaternary-fg-color;
$voice-record-icon-color: $quaternary-fg-color;
$voice-playback-button-bg-color: $message-body-panel-icon-bg-color;
$voice-playback-button-fg-color: $message-body-panel-icon-fg-color;
// Appearance tab colors // Appearance tab colors
$appearance-tab-border-color: $room-highlight-color; $appearance-tab-border-color: $room-highlight-color;

View file

@ -61,6 +61,8 @@ $input-invalid-border-color: $warning-color;
$field-focused-label-bg-color: $bg-color; $field-focused-label-bg-color: $bg-color;
$resend-button-divider-color: $muted-fg-color;
// scrollbars // scrollbars
$scrollbar-thumb-color: rgba(255, 255, 255, 0.2); $scrollbar-thumb-color: rgba(255, 255, 255, 0.2);
$scrollbar-track-color: transparent; $scrollbar-track-color: transparent;
@ -83,7 +85,7 @@ $dialog-close-fg-color: #9fa9ba;
$dialog-background-bg-color: $header-panel-bg-color; $dialog-background-bg-color: $header-panel-bg-color;
$lightbox-background-bg-color: #000; $lightbox-background-bg-color: #000;
$lightbox-background-bg-opacity: 85%; $lightbox-background-bg-opacity: 0.85;
$settings-grey-fg-color: #a2a2a2; $settings-grey-fg-color: #a2a2a2;
$settings-profile-placeholder-bg-color: #e7e7e7; $settings-profile-placeholder-bg-color: #e7e7e7;
@ -107,7 +109,7 @@ $header-divider-color: $header-panel-text-primary-color;
$composer-e2e-icon-color: $header-panel-text-primary-color; $composer-e2e-icon-color: $header-panel-text-primary-color;
// this probably shouldn't have it's own colour // this probably shouldn't have it's own colour
$voipcall-plinth-color: #f2f5f8; $voipcall-plinth-color: #394049;
// ******************** // ********************
@ -198,9 +200,19 @@ $breadcrumb-placeholder-bg-color: #272c35;
$user-tile-hover-bg-color: $header-panel-bg-color; $user-tile-hover-bg-color: $header-panel-bg-color;
$message-body-panel-bg-color: #21262c82; $message-body-panel-fg-color: $secondary-fg-color;
$message-body-panel-icon-bg-color: #8e99a4; $message-body-panel-bg-color: #394049;
$message-body-panel-fg-color: $primary-fg-color; $message-body-panel-icon-fg-color: $primary-bg-color;
$message-body-panel-icon-bg-color: $secondary-fg-color;
// See non-legacy dark for variable information
$voice-record-stop-border-color: #6F7882;
$voice-record-waveform-bg-color: $message-body-panel-bg-color;
$voice-record-waveform-fg-color: $message-body-panel-fg-color;
$voice-record-waveform-incomplete-fg-color: #6F7882;
$voice-record-icon-color: #6F7882;
$voice-playback-button-bg-color: $tertiary-fg-color;
$voice-playback-button-fg-color: #21262C;
// Appearance tab colors // Appearance tab colors
$appearance-tab-border-color: $room-highlight-color; $appearance-tab-border-color: $room-highlight-color;

View file

@ -97,6 +97,8 @@ $input-invalid-border-color: $warning-color;
$field-focused-label-bg-color: #ffffff; $field-focused-label-bg-color: #ffffff;
$resend-button-divider-color: $input-darker-bg-color;
$button-bg-color: $accent-color; $button-bg-color: $accent-color;
$button-fg-color: white; $button-fg-color: white;
@ -127,7 +129,7 @@ $dialog-close-fg-color: #c1c1c1;
$dialog-background-bg-color: #e9e9e9; $dialog-background-bg-color: #e9e9e9;
$lightbox-background-bg-color: #000; $lightbox-background-bg-color: #000;
$lightbox-background-bg-opacity: 95%; $lightbox-background-bg-opacity: 0.95;
$imagebody-giflabel: rgba(0, 0, 0, 0.7); $imagebody-giflabel: rgba(0, 0, 0, 0.7);
$imagebody-giflabel-border: rgba(0, 0, 0, 0.2); $imagebody-giflabel-border: rgba(0, 0, 0, 0.2);
@ -174,7 +176,7 @@ $composer-e2e-icon-color: #91a1c0;
$header-divider-color: #91a1c0; $header-divider-color: #91a1c0;
// this probably shouldn't have it's own colour // this probably shouldn't have it's own colour
$voipcall-plinth-color: #f2f5f8; $voipcall-plinth-color: #F4F6FA;
// ******************** // ********************
@ -189,12 +191,6 @@ $roomsublist-skeleton-ui-bg: linear-gradient(180deg, #ffffff 0%, #ffffff00 100%)
$groupFilterPanel-divider-color: $roomlist-header-color; $groupFilterPanel-divider-color: $roomlist-header-color;
$voice-record-stop-border-color: #E3E8F0;
$voice-record-stop-symbol-color: $warning-color;
$voice-record-waveform-bg-color: #E3E8F0;
$voice-record-waveform-fg-color: $muted-fg-color;
$voice-record-live-circle-color: $warning-color;
$roomtile-preview-color: #9e9e9e; $roomtile-preview-color: #9e9e9e;
$roomtile-default-badge-bg-color: #61708b; $roomtile-default-badge-bg-color: #61708b;
$roomtile-selected-bg-color: #fff; $roomtile-selected-bg-color: #fff;
@ -327,9 +323,21 @@ $breadcrumb-placeholder-bg-color: #e8eef5;
$user-tile-hover-bg-color: $header-panel-bg-color; $user-tile-hover-bg-color: $header-panel-bg-color;
$message-body-panel-bg-color: #e3e8f082; $message-body-panel-fg-color: $secondary-fg-color;
$message-body-panel-icon-bg-color: #ffffff; $message-body-panel-bg-color: #E3E8F0;
$message-body-panel-fg-color: $muted-fg-color; $message-body-panel-icon-fg-color: $secondary-fg-color;
$message-body-panel-icon-bg-color: $primary-bg-color;
// See non-legacy _light for variable information
$voice-record-stop-symbol-color: #ff4b55;
$voice-record-live-circle-color: #ff4b55;
$voice-record-stop-border-color: #E3E8F0;
$voice-record-waveform-bg-color: $message-body-panel-bg-color;
$voice-record-waveform-fg-color: $message-body-panel-fg-color;
$voice-record-waveform-incomplete-fg-color: #C1C6CD;
$voice-record-icon-color: $tertiary-fg-color;
$voice-playback-button-bg-color: $message-body-panel-icon-bg-color;
$voice-playback-button-fg-color: $message-body-panel-icon-fg-color;
// FontSlider colors // FontSlider colors
$appearance-tab-border-color: $input-darker-bg-color; $appearance-tab-border-color: $input-darker-bg-color;

View file

@ -21,6 +21,7 @@ $notice-primary-bg-color: rgba(255, 75, 85, 0.16);
$primary-fg-color: #2e2f32; $primary-fg-color: #2e2f32;
$secondary-fg-color: #737D8C; $secondary-fg-color: #737D8C;
$tertiary-fg-color: #8D99A5; $tertiary-fg-color: #8D99A5;
$quaternary-fg-color: #C1C6CD;
$header-panel-bg-color: #f3f8fd; $header-panel-bg-color: #f3f8fd;
// typical text (dark-on-white in light skin) // typical text (dark-on-white in light skin)
@ -91,6 +92,8 @@ $field-focused-label-bg-color: #ffffff;
$button-bg-color: $accent-color; $button-bg-color: $accent-color;
$button-fg-color: white; $button-fg-color: white;
$resend-button-divider-color: $input-darker-bg-color;
// apart from login forms, which have stronger border // apart from login forms, which have stronger border
$strong-input-border-color: #c7c7c7; $strong-input-border-color: #c7c7c7;
@ -118,7 +121,7 @@ $dialog-close-fg-color: #c1c1c1;
$dialog-background-bg-color: #e9e9e9; $dialog-background-bg-color: #e9e9e9;
$lightbox-background-bg-color: #000; $lightbox-background-bg-color: #000;
$lightbox-background-bg-opacity: 95%; $lightbox-background-bg-opacity: 0.95;
$imagebody-giflabel: rgba(0, 0, 0, 0.7); $imagebody-giflabel: rgba(0, 0, 0, 0.7);
$imagebody-giflabel-border: rgba(0, 0, 0, 0.2); $imagebody-giflabel-border: rgba(0, 0, 0, 0.2);
@ -165,7 +168,7 @@ $composer-e2e-icon-color: #91A1C0;
$header-divider-color: #91A1C0; $header-divider-color: #91A1C0;
// this probably shouldn't have it's own colour // this probably shouldn't have it's own colour
$voipcall-plinth-color: #f2f5f8; $voipcall-plinth-color: #F4F6FA;
// ******************** // ********************
@ -180,12 +183,6 @@ $roomsublist-skeleton-ui-bg: linear-gradient(180deg, #ffffff 0%, #ffffff00 100%)
$groupFilterPanel-divider-color: $roomlist-header-color; $groupFilterPanel-divider-color: $roomlist-header-color;
$voice-record-stop-border-color: #E3E8F0;
$voice-record-stop-symbol-color: $warning-color;
$voice-record-waveform-bg-color: #E3E8F0;
$voice-record-waveform-fg-color: $muted-fg-color;
$voice-record-live-circle-color: $warning-color;
$roomtile-preview-color: $secondary-fg-color; $roomtile-preview-color: $secondary-fg-color;
$roomtile-default-badge-bg-color: #61708b; $roomtile-default-badge-bg-color: #61708b;
$roomtile-selected-bg-color: #FFF; $roomtile-selected-bg-color: #FFF;
@ -325,9 +322,23 @@ $breadcrumb-placeholder-bg-color: #e8eef5;
$user-tile-hover-bg-color: $header-panel-bg-color; $user-tile-hover-bg-color: $header-panel-bg-color;
$message-body-panel-bg-color: #e3e8f082; $message-body-panel-fg-color: $secondary-fg-color;
$message-body-panel-icon-bg-color: #ffffff; $message-body-panel-bg-color: #E3E8F0; // "Separator"
$message-body-panel-fg-color: $muted-fg-color; $message-body-panel-icon-fg-color: $secondary-fg-color;
$message-body-panel-icon-bg-color: $primary-bg-color;
// These two don't change between themes. They are the $warning-color, but we don't
// want custom themes to affect them by accident.
$voice-record-stop-symbol-color: #ff4b55;
$voice-record-live-circle-color: #ff4b55;
$voice-record-stop-border-color: #E3E8F0; // "Separator"
$voice-record-waveform-bg-color: $message-body-panel-bg-color;
$voice-record-waveform-fg-color: $message-body-panel-fg-color;
$voice-record-waveform-incomplete-fg-color: $quaternary-fg-color;
$voice-record-icon-color: $tertiary-fg-color;
$voice-playback-button-bg-color: $message-body-panel-icon-bg-color;
$voice-playback-button-fg-color: $message-body-panel-icon-fg-color;
// FontSlider colors // FontSlider colors
$appearance-tab-border-color: $input-darker-bg-color; $appearance-tab-border-color: $input-darker-bg-color;

View file

@ -1,10 +0,0 @@
const fs = require("fs");
if (process.argv.length < 4) throw new Error("Missing source and target file arguments");
const sourceFile = fs.readFileSync(process.argv[2], 'utf8');
const targetFile = fs.readFileSync(process.argv[3], 'utf8');
if (sourceFile !== targetFile) {
throw new Error("Files do not match");
}

View file

@ -1,304 +0,0 @@
#!/usr/bin/env node
/*
Copyright 2017 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
/**
* Regenerates the translations en_EN file by walking the source tree and
* parsing each file with the appropriate parser. Emits a JSON file with the
* translatable strings mapped to themselves in the order they appeared
* in the files and grouped by the file they appeared in.
*
* Usage: node scripts/gen-i18n.js
*/
const fs = require('fs');
const path = require('path');
const walk = require('walk');
const parser = require("@babel/parser");
const traverse = require("@babel/traverse");
const TRANSLATIONS_FUNCS = ['_t', '_td'];
const INPUT_TRANSLATIONS_FILE = 'src/i18n/strings/en_EN.json';
const OUTPUT_FILE = 'src/i18n/strings/en_EN.json';
// NB. The sync version of walk is broken for single files so we walk
// all of res rather than just res/home.html.
// https://git.daplie.com/Daplie/node-walk/merge_requests/1 fixes it,
// or if we get bored waiting for it to be merged, we could switch
// to a project that's actively maintained.
const SEARCH_PATHS = ['src', 'res'];
function getObjectValue(obj, key) {
for (const prop of obj.properties) {
if (prop.key.type === 'Identifier' && prop.key.name === key) {
return prop.value;
}
}
return null;
}
function getTKey(arg) {
if (arg.type === 'Literal' || arg.type === "StringLiteral") {
return arg.value;
} else if (arg.type === 'BinaryExpression' && arg.operator === '+') {
return getTKey(arg.left) + getTKey(arg.right);
} else if (arg.type === 'TemplateLiteral') {
return arg.quasis.map((q) => {
return q.value.raw;
}).join('');
}
return null;
}
function getFormatStrings(str) {
// Match anything that starts with %
// We could make a regex that matched the full placeholder, but this
// would just not match invalid placeholders and so wouldn't help us
// detect the invalid ones.
// Also note that for simplicity, this just matches a % character and then
// anything up to the next % character (or a single %, or end of string).
const formatStringRe = /%([^%]+|%|$)/g;
const formatStrings = new Set();
let match;
while ( (match = formatStringRe.exec(str)) !== null ) {
const placeholder = match[1]; // Minus the leading '%'
if (placeholder === '%') continue; // Literal % is %%
const placeholderMatch = placeholder.match(/^\((.*?)\)(.)/);
if (placeholderMatch === null) {
throw new Error("Invalid format specifier: '"+match[0]+"'");
}
if (placeholderMatch.length < 3) {
throw new Error("Malformed format specifier");
}
const placeholderName = placeholderMatch[1];
const placeholderFormat = placeholderMatch[2];
if (placeholderFormat !== 's') {
throw new Error(`'${placeholderFormat}' used as format character: you probably meant 's'`);
}
formatStrings.add(placeholderName);
}
return formatStrings;
}
function getTranslationsJs(file) {
const contents = fs.readFileSync(file, { encoding: 'utf8' });
const trs = new Set();
try {
const plugins = [
// https://babeljs.io/docs/en/babel-parser#plugins
"classProperties",
"objectRestSpread",
"throwExpressions",
"exportDefaultFrom",
"decorators-legacy",
];
if (file.endsWith(".js") || file.endsWith(".jsx")) {
// all JS is assumed to be flow or react
plugins.push("flow", "jsx");
} else if (file.endsWith(".ts")) {
// TS can't use JSX unless it's a TSX file (otherwise angle casts fail)
plugins.push("typescript");
} else if (file.endsWith(".tsx")) {
// When the file is a TSX file though, enable JSX parsing
plugins.push("typescript", "jsx");
}
const babelParsed = parser.parse(contents, {
allowImportExportEverywhere: true,
errorRecovery: true,
sourceFilename: file,
tokens: true,
plugins,
});
traverse.default(babelParsed, {
enter: (p) => {
const node = p.node;
if (p.isCallExpression() && node.callee && TRANSLATIONS_FUNCS.includes(node.callee.name)) {
const tKey = getTKey(node.arguments[0]);
// This happens whenever we call _t with non-literals (ie. whenever we've
// had to use a _td to compensate) so is expected.
if (tKey === null) return;
// check the format string against the args
// We only check _t: _td has no args
if (node.callee.name === '_t') {
try {
const placeholders = getFormatStrings(tKey);
for (const placeholder of placeholders) {
if (node.arguments.length < 2) {
throw new Error(`Placeholder found ('${placeholder}') but no substitutions given`);
}
const value = getObjectValue(node.arguments[1], placeholder);
if (value === null) {
throw new Error(`No value found for placeholder '${placeholder}'`);
}
}
// Validate tag replacements
if (node.arguments.length > 2) {
const tagMap = node.arguments[2];
for (const prop of tagMap.properties || []) {
if (prop.key.type === 'Literal') {
const tag = prop.key.value;
// RegExp same as in src/languageHandler.js
const regexp = new RegExp(`(<${tag}>(.*?)<\\/${tag}>|<${tag}>|<${tag}\\s*\\/>)`);
if (!tKey.match(regexp)) {
throw new Error(`No match for ${regexp} in ${tKey}`);
}
}
}
}
} catch (e) {
console.log();
console.error(`ERROR: ${file}:${node.loc.start.line} ${tKey}`);
console.error(e);
process.exit(1);
}
}
let isPlural = false;
if (node.arguments.length > 1 && node.arguments[1].type === 'ObjectExpression') {
const countVal = getObjectValue(node.arguments[1], 'count');
if (countVal) {
isPlural = true;
}
}
if (isPlural) {
trs.add(tKey + "|other");
const plurals = enPlurals[tKey];
if (plurals) {
for (const pluralType of Object.keys(plurals)) {
trs.add(tKey + "|" + pluralType);
}
}
} else {
trs.add(tKey);
}
}
},
});
} catch (e) {
console.error(e);
process.exit(1);
}
return trs;
}
function getTranslationsOther(file) {
const contents = fs.readFileSync(file, { encoding: 'utf8' });
const trs = new Set();
// Taken from element-web src/components/structures/HomePage.js
const translationsRegex = /_t\(['"]([\s\S]*?)['"]\)/mg;
let matches;
while (matches = translationsRegex.exec(contents)) {
trs.add(matches[1]);
}
return trs;
}
// gather en_EN plural strings from the input translations file:
// the en_EN strings are all in the source with the exception of
// pluralised strings, which we need to pull in from elsewhere.
const inputTranslationsRaw = JSON.parse(fs.readFileSync(INPUT_TRANSLATIONS_FILE, { encoding: 'utf8' }));
const enPlurals = {};
for (const key of Object.keys(inputTranslationsRaw)) {
const parts = key.split("|");
if (parts.length > 1) {
const plurals = enPlurals[parts[0]] || {};
plurals[parts[1]] = inputTranslationsRaw[key];
enPlurals[parts[0]] = plurals;
}
}
const translatables = new Set();
const walkOpts = {
listeners: {
names: function(root, nodeNamesArray) {
// Sort the names case insensitively and alphabetically to
// maintain some sense of order between the different strings.
nodeNamesArray.sort((a, b) => {
a = a.toLowerCase();
b = b.toLowerCase();
if (a > b) return 1;
if (a < b) return -1;
return 0;
});
},
file: function(root, fileStats, next) {
const fullPath = path.join(root, fileStats.name);
let trs;
if (fileStats.name.endsWith('.js') || fileStats.name.endsWith('.ts') || fileStats.name.endsWith('.tsx')) {
trs = getTranslationsJs(fullPath);
} else if (fileStats.name.endsWith('.html')) {
trs = getTranslationsOther(fullPath);
} else {
return;
}
console.log(`${fullPath} (${trs.size} strings)`);
for (const tr of trs.values()) {
// Convert DOS line endings to unix
translatables.add(tr.replace(/\r\n/g, "\n"));
}
},
}
};
for (const path of SEARCH_PATHS) {
if (fs.existsSync(path)) {
walk.walkSync(path, walkOpts);
}
}
const trObj = {};
for (const tr of translatables) {
if (tr.includes("|")) {
if (inputTranslationsRaw[tr]) {
trObj[tr] = inputTranslationsRaw[tr];
} else {
trObj[tr] = tr.split("|")[0];
}
} else {
trObj[tr] = tr;
}
}
fs.writeFileSync(
OUTPUT_FILE,
JSON.stringify(trObj, translatables.values(), 4) + "\n"
);
console.log();
console.log(`Wrote ${translatables.size} strings to ${OUTPUT_FILE}`);

View file

@ -1,68 +0,0 @@
#!/usr/bin/env node
/*
Copyright 2017 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
/*
* Looks through all the translation files and removes any strings
* which don't appear in en_EN.json.
* Use this if you remove a translation, but merge any outstanding changes
* from weblate first or you'll need to resolve the conflict in weblate.
*/
const fs = require('fs');
const path = require('path');
const I18NDIR = 'src/i18n/strings';
const enStringsRaw = JSON.parse(fs.readFileSync(path.join(I18NDIR, 'en_EN.json')));
const enStrings = new Set();
for (const str of Object.keys(enStringsRaw)) {
const parts = str.split('|');
if (parts.length > 1) {
enStrings.add(parts[0]);
} else {
enStrings.add(str);
}
}
for (const filename of fs.readdirSync(I18NDIR)) {
if (filename === 'en_EN.json') continue;
if (filename === 'basefile.json') continue;
if (!filename.endsWith('.json')) continue;
const trs = JSON.parse(fs.readFileSync(path.join(I18NDIR, filename)));
const oldLen = Object.keys(trs).length;
for (const tr of Object.keys(trs)) {
const parts = tr.split('|');
const trKey = parts.length > 1 ? parts[0] : tr;
if (!enStrings.has(trKey)) {
delete trs[tr];
}
}
const removed = oldLen - Object.keys(trs).length;
if (removed > 0) {
console.log(`${filename}: removed ${removed} translations`);
// XXX: This is totally relying on the impl serialising the JSON object in the
// same order as they were parsed from the file. JSON.stringify() has a specific argument
// that can be used to control the order, but JSON.parse() lacks any kind of equivalent.
// Empirically this does maintain the order on my system, so I'm going to leave it like
// this for now.
fs.writeFileSync(path.join(I18NDIR, filename), JSON.stringify(trs, undefined, 4) + "\n");
}
}

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2020 The Matrix.org Foundation C.I.C. Copyright 2020-2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -39,7 +39,9 @@ import {ModalWidgetStore} from "../stores/ModalWidgetStore";
import { WidgetLayoutStore } from "../stores/widgets/WidgetLayoutStore"; import { WidgetLayoutStore } from "../stores/widgets/WidgetLayoutStore";
import VoipUserMapper from "../VoipUserMapper"; import VoipUserMapper from "../VoipUserMapper";
import {SpaceStoreClass} from "../stores/SpaceStore"; import {SpaceStoreClass} from "../stores/SpaceStore";
import {VoiceRecorder} from "../voice/VoiceRecorder"; import TypingStore from "../stores/TypingStore";
import { EventIndexPeg } from "../indexing/EventIndexPeg";
import {VoiceRecordingStore} from "../stores/VoiceRecordingStore";
declare global { declare global {
interface Window { interface Window {
@ -71,12 +73,16 @@ declare global {
mxModalWidgetStore: ModalWidgetStore; mxModalWidgetStore: ModalWidgetStore;
mxVoipUserMapper: VoipUserMapper; mxVoipUserMapper: VoipUserMapper;
mxSpaceStore: SpaceStoreClass; mxSpaceStore: SpaceStoreClass;
mxVoiceRecorder: typeof VoiceRecorder; mxVoiceRecordingStore: VoiceRecordingStore;
mxTypingStore: TypingStore;
mxEventIndexPeg: EventIndexPeg;
} }
interface Document { interface Document {
// https://developer.mozilla.org/en-US/docs/Web/API/Document/hasStorageAccess // https://developer.mozilla.org/en-US/docs/Web/API/Document/hasStorageAccess
hasStorageAccess?: () => Promise<boolean>; hasStorageAccess?: () => Promise<boolean>;
// https://developer.mozilla.org/en-US/docs/Web/API/Document/requestStorageAccess
requestStorageAccess?: () => Promise<undefined>;
// Safari & IE11 only have this prefixed: we used prefixed versions // Safari & IE11 only have this prefixed: we used prefixed versions
// previously so let's continue to support them for now // previously so let's continue to support them for now
@ -112,6 +118,16 @@ declare global {
interface HTMLAudioElement { interface HTMLAudioElement {
type?: string; type?: string;
// sinkId & setSinkId are experimental and typescript doesn't know about them
sinkId: string;
setSinkId(outputId: string);
}
interface HTMLVideoElement {
type?: string;
// sinkId & setSinkId are experimental and typescript doesn't know about them
sinkId: string;
setSinkId(outputId: string);
} }
interface Element { interface Element {
@ -129,4 +145,30 @@ declare global {
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/columnNumber // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/columnNumber
columnNumber?: number; columnNumber?: number;
} }
// https://github.com/microsoft/TypeScript/issues/28308#issuecomment-650802278
interface AudioWorkletProcessor {
readonly port: MessagePort;
process(
inputs: Float32Array[][],
outputs: Float32Array[][],
parameters: Record<string, Float32Array>
): boolean;
}
// https://github.com/microsoft/TypeScript/issues/28308#issuecomment-650802278
const AudioWorkletProcessor: {
prototype: AudioWorkletProcessor;
new (options?: AudioWorkletNodeOptions): AudioWorkletProcessor;
};
// https://github.com/microsoft/TypeScript/issues/28308#issuecomment-650802278
function registerProcessor(
name: string,
processorCtor: (new (
options?: AudioWorkletNodeOptions
) => AudioWorkletProcessor) & {
parameterDescriptors?: AudioParamDescriptor[];
}
);
} }

View file

@ -20,6 +20,7 @@ import {Room} from "matrix-js-sdk/src/models/room";
import DMRoomMap from './utils/DMRoomMap'; import DMRoomMap from './utils/DMRoomMap';
import {mediaFromMxc} from "./customisations/Media"; import {mediaFromMxc} from "./customisations/Media";
import SettingsStore from "./settings/SettingsStore";
export type ResizeMethod = "crop" | "scale"; export type ResizeMethod = "crop" | "scale";
@ -27,11 +28,7 @@ export type ResizeMethod = "crop" | "scale";
export function avatarUrlForMember(member: RoomMember, width: number, height: number, resizeMethod: ResizeMethod) { export function avatarUrlForMember(member: RoomMember, width: number, height: number, resizeMethod: ResizeMethod) {
let url: string; let url: string;
if (member?.getMxcAvatarUrl()) { if (member?.getMxcAvatarUrl()) {
url = mediaFromMxc(member.getMxcAvatarUrl()).getThumbnailOfSourceHttp( url = mediaFromMxc(member.getMxcAvatarUrl()).getThumbnailOfSourceHttp(width, height, resizeMethod);
Math.floor(width * window.devicePixelRatio),
Math.floor(height * window.devicePixelRatio),
resizeMethod,
);
} }
if (!url) { if (!url) {
// member can be null here currently since on invites, the JS SDK // member can be null here currently since on invites, the JS SDK
@ -44,11 +41,7 @@ export function avatarUrlForMember(member: RoomMember, width: number, height: nu
export function avatarUrlForUser(user: User, width: number, height: number, resizeMethod?: ResizeMethod) { export function avatarUrlForUser(user: User, width: number, height: number, resizeMethod?: ResizeMethod) {
if (!user.avatarUrl) return null; if (!user.avatarUrl) return null;
return mediaFromMxc(user.avatarUrl).getThumbnailOfSourceHttp( return mediaFromMxc(user.avatarUrl).getThumbnailOfSourceHttp(width, height, resizeMethod);
Math.floor(width * window.devicePixelRatio),
Math.floor(height * window.devicePixelRatio),
resizeMethod,
);
} }
function isValidHexColor(color: string): boolean { function isValidHexColor(color: string): boolean {
@ -151,7 +144,7 @@ export function avatarUrlForRoom(room: Room, width: number, height: number, resi
} }
// space rooms cannot be DMs so skip the rest // space rooms cannot be DMs so skip the rest
if (room.isSpaceRoom()) return null; if (SettingsStore.getValue("feature_spaces") && room.isSpaceRoom()) return null;
let otherMember = null; let otherMember = null;
const otherUserId = DMRoomMap.shared().getUserIdForRoomId(room.roomId); const otherUserId = DMRoomMap.shared().getUserIdForRoomId(room.roomId);

View file

@ -258,7 +258,7 @@ export default abstract class BasePlatform {
return null; return null;
} }
setLanguage(preferredLangs: string[]) {} async setLanguage(preferredLangs: string[]) {}
setSpellCheckLanguages(preferredLangs: string[]) {} setSpellCheckLanguages(preferredLangs: string[]) {}

View file

@ -59,7 +59,6 @@ import {MatrixClientPeg} from './MatrixClientPeg';
import PlatformPeg from './PlatformPeg'; import PlatformPeg from './PlatformPeg';
import Modal from './Modal'; import Modal from './Modal';
import { _t } from './languageHandler'; import { _t } from './languageHandler';
import { createNewMatrixCall } from 'matrix-js-sdk/src/webrtc/call';
import dis from './dispatcher/dispatcher'; import dis from './dispatcher/dispatcher';
import WidgetUtils from './utils/WidgetUtils'; import WidgetUtils from './utils/WidgetUtils';
import WidgetEchoStore from './stores/WidgetEchoStore'; import WidgetEchoStore from './stores/WidgetEchoStore';
@ -86,6 +85,9 @@ import { Action } from './dispatcher/actions';
import VoipUserMapper from './VoipUserMapper'; import VoipUserMapper from './VoipUserMapper';
import { addManagedHybridWidget, isManagedHybridWidgetEnabled } from './widgets/ManagedHybrid'; import { addManagedHybridWidget, isManagedHybridWidgetEnabled } from './widgets/ManagedHybrid';
import { randomUppercaseString, randomLowercaseString } from "matrix-js-sdk/src/randomstring"; import { randomUppercaseString, randomLowercaseString } from "matrix-js-sdk/src/randomstring";
import EventEmitter from 'events';
import SdkConfig from './SdkConfig';
import { ensureDMExists, findDMForUser } from './createRoom';
export const PROTOCOL_PSTN = 'm.protocol.pstn'; export const PROTOCOL_PSTN = 'm.protocol.pstn';
export const PROTOCOL_PSTN_PREFIXED = 'im.vector.protocol.pstn'; export const PROTOCOL_PSTN_PREFIXED = 'im.vector.protocol.pstn';
@ -137,22 +139,12 @@ export enum PlaceCallType {
ScreenSharing = 'screensharing', ScreenSharing = 'screensharing',
} }
function getRemoteAudioElement(): HTMLAudioElement { export enum CallHandlerEvent {
// this needs to be somewhere at the top of the DOM which CallsChanged = "calls_changed",
// always exists to avoid audio interruptions. CallChangeRoom = "call_change_room",
// Might as well just use DOM.
const remoteAudioElement = document.getElementById("remoteAudio") as HTMLAudioElement;
if (!remoteAudioElement) {
console.error(
"Failed to find remoteAudio element - cannot play audio!" +
"You need to add an <audio/> to the DOM.",
);
return null;
}
return remoteAudioElement;
} }
export default class CallHandler { export default class CallHandler extends EventEmitter {
private calls = new Map<string, MatrixCall>(); // roomId -> call private calls = new Map<string, MatrixCall>(); // roomId -> call
// Calls started as an attended transfer, ie. with the intention of transferring another // Calls started as an attended transfer, ie. with the intention of transferring another
// call with a different party to this one. // call with a different party to this one.
@ -167,6 +159,11 @@ export default class CallHandler {
private invitedRoomsAreVirtual = new Map<string, boolean>(); private invitedRoomsAreVirtual = new Map<string, boolean>();
private invitedRoomCheckInProgress = false; private invitedRoomCheckInProgress = false;
// Map of the asserted identity users after we've looked them up using the API.
// We need to be be able to determine the mapped room synchronously, so we
// do the async lookup when we get new information and then store these mappings here
private assertedIdentityNativeUsers = new Map<string, string>();
static sharedInstance() { static sharedInstance() {
if (!window.mxCallHandler) { if (!window.mxCallHandler) {
window.mxCallHandler = new CallHandler() window.mxCallHandler = new CallHandler()
@ -179,8 +176,19 @@ export default class CallHandler {
* Gets the user-facing room associated with a call (call.roomId may be the call "virtual room" * Gets the user-facing room associated with a call (call.roomId may be the call "virtual room"
* if a voip_mxid_translate_pattern is set in the config) * if a voip_mxid_translate_pattern is set in the config)
*/ */
public static roomIdForCall(call: MatrixCall): string { public roomIdForCall(call: MatrixCall): string {
if (!call) return null; if (!call) return null;
const voipConfig = SdkConfig.get()['voip'];
if (voipConfig && voipConfig.obeyAssertedIdentity) {
const nativeUser = this.assertedIdentityNativeUsers[call.callId];
if (nativeUser) {
const room = findDMForUser(MatrixClientPeg.get(), nativeUser);
if (room) return room.roomId
}
}
return VoipUserMapper.sharedInstance().nativeRoomForVirtualRoom(call.roomId) || call.roomId; return VoipUserMapper.sharedInstance().nativeRoomForVirtualRoom(call.roomId) || call.roomId;
} }
@ -379,14 +387,14 @@ export default class CallHandler {
// We don't allow placing more than one call per room, but that doesn't mean there // We don't allow placing more than one call per room, but that doesn't mean there
// can't be more than one, eg. in a glare situation. This checks that the given call // can't be more than one, eg. in a glare situation. This checks that the given call
// is the call we consider 'the' call for its room. // is the call we consider 'the' call for its room.
const mappedRoomId = CallHandler.roomIdForCall(call); const mappedRoomId = this.roomIdForCall(call);
const callForThisRoom = this.getCallForRoom(mappedRoomId); const callForThisRoom = this.getCallForRoom(mappedRoomId);
return callForThisRoom && call.callId === callForThisRoom.callId; return callForThisRoom && call.callId === callForThisRoom.callId;
} }
private setCallListeners(call: MatrixCall) { private setCallListeners(call: MatrixCall) {
const mappedRoomId = CallHandler.roomIdForCall(call); let mappedRoomId = CallHandler.sharedInstance().roomIdForCall(call);
call.on(CallEvent.Error, (err: CallError) => { call.on(CallEvent.Error, (err: CallError) => {
if (!this.matchesCallForThisRoom(call)) return; if (!this.matchesCallForThisRoom(call)) return;
@ -497,9 +505,43 @@ export default class CallHandler {
} }
this.calls.set(mappedRoomId, newCall); this.calls.set(mappedRoomId, newCall);
this.emit(CallHandlerEvent.CallsChanged, this.calls);
this.setCallListeners(newCall); this.setCallListeners(newCall);
this.setCallState(newCall, newCall.state); this.setCallState(newCall, newCall.state);
}); });
call.on(CallEvent.AssertedIdentityChanged, async () => {
if (!this.matchesCallForThisRoom(call)) return;
console.log(`Call ID ${call.callId} got new asserted identity:`, call.getRemoteAssertedIdentity());
const newAssertedIdentity = call.getRemoteAssertedIdentity().id;
let newNativeAssertedIdentity = newAssertedIdentity;
if (newAssertedIdentity) {
const response = await this.sipNativeLookup(newAssertedIdentity);
if (response.length) newNativeAssertedIdentity = response[0].userid;
}
console.log(`Asserted identity ${newAssertedIdentity} mapped to ${newNativeAssertedIdentity}`);
if (newNativeAssertedIdentity) {
this.assertedIdentityNativeUsers[call.callId] = newNativeAssertedIdentity;
// If we don't already have a room with this user, make one. This will be slightly odd
// if they called us because we'll be inviting them, but there's not much we can do about
// this if we want the actual, native room to exist (which we do). This is why it's
// important to only obey asserted identity in trusted environments, since anyone you're
// on a call with can cause you to send a room invite to someone.
await ensureDMExists(MatrixClientPeg.get(), newNativeAssertedIdentity);
const newMappedRoomId = this.roomIdForCall(call);
console.log(`Old room ID: ${mappedRoomId}, new room ID: ${newMappedRoomId}`);
if (newMappedRoomId !== mappedRoomId) {
this.removeCallForRoom(mappedRoomId);
mappedRoomId = newMappedRoomId;
this.calls.set(mappedRoomId, call);
this.emit(CallHandlerEvent.CallChangeRoom, call);
}
}
});
} }
private async logCallStats(call: MatrixCall, mappedRoomId: string) { private async logCallStats(call: MatrixCall, mappedRoomId: string) {
@ -545,13 +587,8 @@ export default class CallHandler {
} }
} }
private setCallAudioElement(call: MatrixCall) {
const audioElement = getRemoteAudioElement();
if (audioElement) call.setRemoteAudioElement(audioElement);
}
private setCallState(call: MatrixCall, status: CallState) { private setCallState(call: MatrixCall, status: CallState) {
const mappedRoomId = CallHandler.roomIdForCall(call); const mappedRoomId = CallHandler.sharedInstance().roomIdForCall(call);
console.log( console.log(
`Call state in ${mappedRoomId} changed to ${status}`, `Call state in ${mappedRoomId} changed to ${status}`,
@ -566,6 +603,7 @@ export default class CallHandler {
private removeCallForRoom(roomId: string) { private removeCallForRoom(roomId: string) {
this.calls.delete(roomId); this.calls.delete(roomId);
this.emit(CallHandlerEvent.CallsChanged, this.calls);
} }
private showICEFallbackPrompt() { private showICEFallbackPrompt() {
@ -626,11 +664,7 @@ export default class CallHandler {
}, null, true); }, null, true);
} }
private async placeCall( private async placeCall(roomId: string, type: PlaceCallType, transferee: MatrixCall) {
roomId: string, type: PlaceCallType,
localElement: HTMLVideoElement, remoteElement: HTMLVideoElement,
transferee: MatrixCall,
) {
Analytics.trackEvent('voip', 'placeCall', 'type', type); Analytics.trackEvent('voip', 'placeCall', 'type', type);
CountlyAnalytics.instance.trackStartCall(roomId, type === PlaceCallType.Video, false); CountlyAnalytics.instance.trackStartCall(roomId, type === PlaceCallType.Video, false);
@ -639,25 +673,22 @@ export default class CallHandler {
const timeUntilTurnCresExpire = MatrixClientPeg.get().getTurnServersExpiry() - Date.now(); const timeUntilTurnCresExpire = MatrixClientPeg.get().getTurnServersExpiry() - Date.now();
console.log("Current turn creds expire in " + timeUntilTurnCresExpire + " ms"); console.log("Current turn creds expire in " + timeUntilTurnCresExpire + " ms");
const call = createNewMatrixCall(MatrixClientPeg.get(), mappedRoomId); const call = MatrixClientPeg.get().createCall(mappedRoomId);
this.calls.set(roomId, call); this.calls.set(roomId, call);
this.emit(CallHandlerEvent.CallsChanged, this.calls);
if (transferee) { if (transferee) {
this.transferees[call.callId] = transferee; this.transferees[call.callId] = transferee;
} }
this.setCallListeners(call); this.setCallListeners(call);
this.setCallAudioElement(call);
this.setActiveCallRoomId(roomId); this.setActiveCallRoomId(roomId);
if (type === PlaceCallType.Voice) { if (type === PlaceCallType.Voice) {
call.placeVoiceCall(); call.placeVoiceCall();
} else if (type === 'video') { } else if (type === 'video') {
call.placeVideoCall( call.placeVideoCall();
remoteElement,
localElement,
);
} else if (type === PlaceCallType.ScreenSharing) { } else if (type === PlaceCallType.ScreenSharing) {
const screenCapErrorString = PlatformPeg.get().screenCaptureErrorString(); const screenCapErrorString = PlatformPeg.get().screenCaptureErrorString();
if (screenCapErrorString) { if (screenCapErrorString) {
@ -671,13 +702,12 @@ export default class CallHandler {
} }
call.placeScreenSharingCall( call.placeScreenSharingCall(
remoteElement, async (): Promise<DesktopCapturerSource> => {
localElement,
async () : Promise<DesktopCapturerSource> => {
const {finished} = Modal.createDialog(DesktopCapturerSourcePicker); const {finished} = Modal.createDialog(DesktopCapturerSourcePicker);
const [source] = await finished; const [source] = await finished;
return source; return source;
}); },
);
} else { } else {
console.error("Unknown conf call type: " + type); console.error("Unknown conf call type: " + type);
} }
@ -734,17 +764,12 @@ export default class CallHandler {
} else if (members.length === 2) { } else if (members.length === 2) {
console.info(`Place ${payload.type} call in ${payload.room_id}`); console.info(`Place ${payload.type} call in ${payload.room_id}`);
this.placeCall( this.placeCall(payload.room_id, payload.type, payload.transferee);
payload.room_id, payload.type, payload.local_element, payload.remote_element,
payload.transferee,
);
} else { // > 2 } else { // > 2
dis.dispatch({ dis.dispatch({
action: "place_conference_call", action: "place_conference_call",
room_id: payload.room_id, room_id: payload.room_id,
type: payload.type, type: payload.type,
remote_element: payload.remote_element,
local_element: payload.local_element,
}); });
} }
} }
@ -772,7 +797,7 @@ export default class CallHandler {
const call = payload.call as MatrixCall; const call = payload.call as MatrixCall;
const mappedRoomId = CallHandler.roomIdForCall(call); const mappedRoomId = CallHandler.sharedInstance().roomIdForCall(call);
if (this.getCallForRoom(mappedRoomId)) { if (this.getCallForRoom(mappedRoomId)) {
// ignore multiple incoming calls to the same room // ignore multiple incoming calls to the same room
return; return;
@ -780,6 +805,7 @@ export default class CallHandler {
Analytics.trackEvent('voip', 'receiveCall', 'type', call.type); Analytics.trackEvent('voip', 'receiveCall', 'type', call.type);
this.calls.set(mappedRoomId, call) this.calls.set(mappedRoomId, call)
this.emit(CallHandlerEvent.CallsChanged, this.calls);
this.setCallListeners(call); this.setCallListeners(call);
// get ready to send encrypted events in the room, so if the user does answer // get ready to send encrypted events in the room, so if the user does answer
@ -822,7 +848,6 @@ export default class CallHandler {
const call = this.calls.get(payload.room_id); const call = this.calls.get(payload.room_id);
call.answer(); call.answer();
this.setCallAudioElement(call);
this.setActiveCallRoomId(payload.room_id); this.setActiveCallRoomId(payload.room_id);
CountlyAnalytics.instance.trackJoinCall(payload.room_id, call.type === CallType.Video, false); CountlyAnalytics.instance.trackJoinCall(payload.room_id, call.type === CallType.Video, false);
dis.dispatch({ dis.dispatch({

View file

@ -16,7 +16,7 @@
import SettingsStore from "./settings/SettingsStore"; import SettingsStore from "./settings/SettingsStore";
import {SettingLevel} from "./settings/SettingLevel"; import {SettingLevel} from "./settings/SettingLevel";
import {setMatrixCallAudioInput, setMatrixCallAudioOutput, setMatrixCallVideoInput} from "matrix-js-sdk/src/matrix"; import {setMatrixCallAudioInput, setMatrixCallVideoInput} from "matrix-js-sdk/src/matrix";
export default { export default {
hasAnyLabeledDevices: async function() { hasAnyLabeledDevices: async function() {
@ -50,18 +50,15 @@ export default {
}, },
loadDevices: function() { loadDevices: function() {
const audioOutDeviceId = SettingsStore.getValue("webrtc_audiooutput");
const audioDeviceId = SettingsStore.getValue("webrtc_audioinput"); const audioDeviceId = SettingsStore.getValue("webrtc_audioinput");
const videoDeviceId = SettingsStore.getValue("webrtc_videoinput"); const videoDeviceId = SettingsStore.getValue("webrtc_videoinput");
setMatrixCallAudioOutput(audioOutDeviceId);
setMatrixCallAudioInput(audioDeviceId); setMatrixCallAudioInput(audioDeviceId);
setMatrixCallVideoInput(videoDeviceId); setMatrixCallVideoInput(videoDeviceId);
}, },
setAudioOutput: function(deviceId) { setAudioOutput: function(deviceId) {
SettingsStore.setValue("webrtc_audiooutput", null, SettingLevel.DEVICE, deviceId); SettingsStore.setValue("webrtc_audiooutput", null, SettingLevel.DEVICE, deviceId);
setMatrixCallAudioOutput(deviceId);
}, },
setAudioInput: function(deviceId) { setAudioInput: function(deviceId) {

View file

@ -148,13 +148,15 @@ function _onGroupAddRoomFinished(groupId, addrs, addRoomsPublicly) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog( Modal.createTrackedDialog(
'Failed to add the following room to the group', 'Failed to add the following room to the group',
'', ErrorDialog, '',
{ ErrorDialog,
title: _t( {
"Failed to add the following rooms to %(groupId)s:", title: _t(
{groupId}, "Failed to add the following rooms to %(groupId)s:",
), {groupId},
description: errorList.join(", "), ),
}); description: errorList.join(", "),
},
);
}); });
} }

View file

@ -130,11 +130,14 @@ export function sanitizedHtmlNode(insaneHtml: string) {
return <div dangerouslySetInnerHTML={{ __html: saneHtml }} dir="auto" />; return <div dangerouslySetInnerHTML={{ __html: saneHtml }} dir="auto" />;
} }
export function sanitizedHtmlNodeInnerText(insaneHtml: string) { export function getHtmlText(insaneHtml: string) {
const saneHtml = sanitizeHtml(insaneHtml, sanitizeHtmlParams); return sanitizeHtml(insaneHtml, {
const contentDiv = document.createElement("div"); allowedTags: [],
contentDiv.innerHTML = saneHtml; allowedAttributes: {},
return contentDiv.innerText; selfClosing: [],
allowedSchemes: [],
disallowedTagsMode: 'discard',
})
} }
/** /**

View file

@ -163,7 +163,7 @@ export default class IdentityAuthClient {
</div> </div>
), ),
button: _t("Trust"), button: _t("Trust"),
}); });
const [confirmed] = await finished; const [confirmed] = await finished;
if (confirmed) { if (confirmed) {
// eslint-disable-next-line react-hooks/rules-of-hooks // eslint-disable-next-line react-hooks/rules-of-hooks

View file

@ -231,8 +231,10 @@ export class KeyBindingsManager {
/** /**
* Finds a matching KeyAction for a given KeyboardEvent * Finds a matching KeyAction for a given KeyboardEvent
*/ */
private getAction<T extends string>(getters: KeyBindingGetter<T>[], ev: KeyboardEvent | React.KeyboardEvent) private getAction<T extends string>(
: T | undefined { getters: KeyBindingGetter<T>[],
ev: KeyboardEvent | React.KeyboardEvent,
): T | undefined {
for (const getter of getters) { for (const getter of getters) {
const bindings = getter(); const bindings = getter();
const binding = bindings.find(it => isKeyComboMatch(ev, it.keyCombo, isMac)); const binding = bindings.find(it => isKeyComboMatch(ev, it.keyCombo, isMac));

View file

@ -1,9 +1,6 @@
/* /*
Copyright 2015, 2016 OpenMarket Ltd Copyright 2015-2021 The Matrix.org Foundation C.I.C.
Copyright 2017 Vector Creations Ltd
Copyright 2018 New Vector Ltd
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -59,7 +56,7 @@ export type LoginFlow = ISSOFlow | IPasswordFlow;
// TODO: Move this to JS SDK // TODO: Move this to JS SDK
/* eslint-disable camelcase */ /* eslint-disable camelcase */
interface ILoginParams { interface ILoginParams {
identifier?: string; identifier?: object;
password?: string; password?: string;
token?: string; token?: string;
device_id?: string; device_id?: string;

View file

@ -1,16 +1,15 @@
import React from "react"; import React from "react";
import ReactDom from "react-dom"; import ReactDom from "react-dom";
import Velocity from "velocity-animate";
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
/** /**
* The Velociraptor contains components and animates transitions with velocity. * The NodeAnimator contains components and animates transitions.
* It will only pick up direct changes to properties ('left', currently), and so * It will only pick up direct changes to properties ('left', currently), and so
* will not work for animating positional changes where the position is implicit * will not work for animating positional changes where the position is implicit
* from DOM order. This makes it a lot simpler and lighter: if you need fully * from DOM order. This makes it a lot simpler and lighter: if you need fully
* automatic positional animation, look at react-shuffle or similar libraries. * automatic positional animation, look at react-shuffle or similar libraries.
*/ */
export default class Velociraptor extends React.Component { export default class NodeAnimator extends React.Component {
static propTypes = { static propTypes = {
// either a list of child nodes, or a single child. // either a list of child nodes, or a single child.
children: PropTypes.any, children: PropTypes.any,
@ -20,14 +19,10 @@ export default class Velociraptor extends React.Component {
// a list of state objects to apply to each child node in turn // a list of state objects to apply to each child node in turn
startStyles: PropTypes.array, startStyles: PropTypes.array,
// a list of transition options from the corresponding startStyle
enterTransitionOpts: PropTypes.array,
}; };
static defaultProps = { static defaultProps = {
startStyles: [], startStyles: [],
enterTransitionOpts: [],
}; };
constructor(props) { constructor(props) {
@ -41,6 +36,18 @@ export default class Velociraptor extends React.Component {
this._updateChildren(this.props.children); this._updateChildren(this.props.children);
} }
/**
*
* @param {HTMLElement} node element to apply styles to
* @param {object} styles a key/value pair of CSS properties
* @returns {void}
*/
_applyStyles(node, styles) {
Object.entries(styles).forEach(([property, value]) => {
node.style[property] = value;
});
}
_updateChildren(newChildren) { _updateChildren(newChildren) {
const oldChildren = this.children || {}; const oldChildren = this.children || {};
this.children = {}; this.children = {};
@ -50,17 +57,8 @@ export default class Velociraptor extends React.Component {
const oldNode = ReactDom.findDOMNode(this.nodes[old.key]); const oldNode = ReactDom.findDOMNode(this.nodes[old.key]);
if (oldNode && oldNode.style.left !== c.props.style.left) { if (oldNode && oldNode.style.left !== c.props.style.left) {
Velocity(oldNode, { left: c.props.style.left }, this.props.transition).then(() => { this._applyStyles(oldNode, { left: c.props.style.left });
// special case visibility because it's nonsensical to animate an invisible element // console.log("translation: "+oldNode.style.left+" -> "+c.props.style.left);
// so we always hidden->visible pre-transition and visible->hidden after
if (oldNode.style.visibility === 'visible' && c.props.style.visibility === 'hidden') {
oldNode.style.visibility = c.props.style.visibility;
}
});
//console.log("translation: "+oldNode.style.left+" -> "+c.props.style.left);
}
if (oldNode && oldNode.style.visibility === 'hidden' && c.props.style.visibility === 'visible') {
oldNode.style.visibility = c.props.style.visibility;
} }
// clone the old element with the props (and children) of the new element // clone the old element with the props (and children) of the new element
// so prop updates are still received by the children. // so prop updates are still received by the children.
@ -94,33 +92,22 @@ export default class Velociraptor extends React.Component {
this.props.startStyles.length > 0 this.props.startStyles.length > 0
) { ) {
const startStyles = this.props.startStyles; const startStyles = this.props.startStyles;
const transitionOpts = this.props.enterTransitionOpts;
const domNode = ReactDom.findDOMNode(node); const domNode = ReactDom.findDOMNode(node);
// start from startStyle 1: 0 is the one we gave it // start from startStyle 1: 0 is the one we gave it
// to start with, so now we animate 1 etc. // to start with, so now we animate 1 etc.
for (var i = 1; i < startStyles.length; ++i) { for (let i = 1; i < startStyles.length; ++i) {
Velocity(domNode, startStyles[i], transitionOpts[i-1]); this._applyStyles(domNode, startStyles[i]);
/* // console.log("start:"
console.log("start:", // JSON.stringify(startStyles[i]),
JSON.stringify(transitionOpts[i-1]), // );
"->",
JSON.stringify(startStyles[i]),
);
*/
} }
// and then we animate to the resting state // and then we animate to the resting state
Velocity(domNode, restingStyle, setTimeout(() => {
transitionOpts[i-1]) this._applyStyles(domNode, restingStyle);
.then(() => { }, 0);
// once we've reached the resting state, hide the element if
// appropriate
domNode.style.visibility = restingStyle.visibility;
});
// console.log("enter:", // console.log("enter:",
// JSON.stringify(transitionOpts[i-1]),
// "->",
// JSON.stringify(restingStyle)); // JSON.stringify(restingStyle));
} }
this.nodes[k] = node; this.nodes[k] = node;
@ -128,9 +115,7 @@ export default class Velociraptor extends React.Component {
render() { render() {
return ( return (
<span> <>{ Object.values(this.children) }</>
{ Object.values(this.children) }
</span>
); );
} }
} }

View file

@ -383,6 +383,10 @@ export const Notifier = {
// don't bother notifying as user was recently active in this room // don't bother notifying as user was recently active in this room
return; return;
} }
if (SettingsStore.getValue("doNotDisturb")) {
// Don't bother the user if they didn't ask to be bothered
return;
}
if (this.isEnabled()) { if (this.isEnabled()) {
this._displayPopupNotification(ev, room); this._displayPopupNotification(ev, room);

View file

@ -54,7 +54,7 @@ export default class PasswordReset {
return res; return res;
}, function(err) { }, function(err) {
if (err.errcode === 'M_THREEPID_NOT_FOUND') { if (err.errcode === 'M_THREEPID_NOT_FOUND') {
err.message = _t('This email address was not found'); err.message = _t('This email address was not found');
} else if (err.httpStatus) { } else if (err.httpStatus) {
err.message = err.message + ` (Status ${err.httpStatus})`; err.message = err.message + ` (Status ${err.httpStatus})`;
} }

View file

@ -21,11 +21,11 @@ import { EventStatus } from 'matrix-js-sdk/src/models/event';
export default class Resend { export default class Resend {
static resendUnsentEvents(room) { static resendUnsentEvents(room) {
room.getPendingEvents().filter(function(ev) { return Promise.all(room.getPendingEvents().filter(function(ev) {
return ev.status === EventStatus.NOT_SENT; return ev.status === EventStatus.NOT_SENT;
}).forEach(function(event) { }).map(function(event) {
Resend.resend(event); return Resend.resend(event);
}); }));
} }
static cancelUnsentEvents(room) { static cancelUnsentEvents(room) {
@ -38,7 +38,7 @@ export default class Resend {
static resend(event) { static resend(event) {
const room = MatrixClientPeg.get().getRoom(event.getRoomId()); const room = MatrixClientPeg.get().getRoom(event.getRoomId());
MatrixClientPeg.get().resendEvent(event, room).then(function(res) { return MatrixClientPeg.get().resendEvent(event, room).then(function(res) {
dis.dispatch({ dis.dispatch({
action: 'message_sent', action: 'message_sent',
event: event, event: event,

View file

@ -1,6 +1,5 @@
/* /*
Copyright 2016 OpenMarket Ltd Copyright 2016, 2019, 2021 The Matrix.org Foundation C.I.C.
Copyright 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -17,13 +16,14 @@ limitations under the License.
import url from 'url'; import url from 'url';
import SettingsStore from "./settings/SettingsStore"; import SettingsStore from "./settings/SettingsStore";
import { Service, startTermsFlow, TermsNotSignedError } from './Terms'; import { Service, startTermsFlow, TermsInteractionCallback, TermsNotSignedError } from './Terms';
import {MatrixClientPeg} from "./MatrixClientPeg"; import {MatrixClientPeg} from "./MatrixClientPeg";
import request from "browser-request"; import request from "browser-request";
import SdkConfig from "./SdkConfig"; import SdkConfig from "./SdkConfig";
import {WidgetType} from "./widgets/WidgetType"; import {WidgetType} from "./widgets/WidgetType";
import {SERVICE_TYPES} from "matrix-js-sdk/src/service-types"; import {SERVICE_TYPES} from "matrix-js-sdk/src/service-types";
import { Room } from "matrix-js-sdk/src/models/room";
// The version of the integration manager API we're intending to work with // The version of the integration manager API we're intending to work with
const imApiVersion = "1.1"; const imApiVersion = "1.1";
@ -31,9 +31,11 @@ const imApiVersion = "1.1";
// TODO: Generify the name of this class and all components within - it's not just for Scalar. // TODO: Generify the name of this class and all components within - it's not just for Scalar.
export default class ScalarAuthClient { export default class ScalarAuthClient {
constructor(apiUrl, uiUrl) { private scalarToken: string;
this.apiUrl = apiUrl; private termsInteractionCallback: TermsInteractionCallback;
this.uiUrl = uiUrl; private isDefaultManager: boolean;
constructor(private apiUrl: string, private uiUrl: string) {
this.scalarToken = null; this.scalarToken = null;
// `undefined` to allow `startTermsFlow` to fallback to a default // `undefined` to allow `startTermsFlow` to fallback to a default
// callback if this is unset. // callback if this is unset.
@ -46,7 +48,7 @@ export default class ScalarAuthClient {
this.isDefaultManager = apiUrl === configApiUrl && configUiUrl === uiUrl; this.isDefaultManager = apiUrl === configApiUrl && configUiUrl === uiUrl;
} }
_writeTokenToStore() { private writeTokenToStore() {
window.localStorage.setItem("mx_scalar_token_at_" + this.apiUrl, this.scalarToken); window.localStorage.setItem("mx_scalar_token_at_" + this.apiUrl, this.scalarToken);
if (this.isDefaultManager) { if (this.isDefaultManager) {
// We remove the old token from storage to migrate upwards. This is safe // We remove the old token from storage to migrate upwards. This is safe
@ -56,7 +58,7 @@ export default class ScalarAuthClient {
} }
} }
_readTokenFromStore() { private readTokenFromStore(): string {
let token = window.localStorage.getItem("mx_scalar_token_at_" + this.apiUrl); let token = window.localStorage.getItem("mx_scalar_token_at_" + this.apiUrl);
if (!token && this.isDefaultManager) { if (!token && this.isDefaultManager) {
token = window.localStorage.getItem("mx_scalar_token"); token = window.localStorage.getItem("mx_scalar_token");
@ -64,33 +66,33 @@ export default class ScalarAuthClient {
return token; return token;
} }
_readToken() { private readToken(): string {
if (this.scalarToken) return this.scalarToken; if (this.scalarToken) return this.scalarToken;
return this._readTokenFromStore(); return this.readTokenFromStore();
} }
setTermsInteractionCallback(callback) { setTermsInteractionCallback(callback) {
this.termsInteractionCallback = callback; this.termsInteractionCallback = callback;
} }
connect() { connect(): Promise<void> {
return this.getScalarToken().then((tok) => { return this.getScalarToken().then((tok) => {
this.scalarToken = tok; this.scalarToken = tok;
}); });
} }
hasCredentials() { hasCredentials(): boolean {
return this.scalarToken != null; // undef or null return this.scalarToken != null; // undef or null
} }
// Returns a promise that resolves to a scalar_token string // Returns a promise that resolves to a scalar_token string
getScalarToken() { getScalarToken(): Promise<string> {
const token = this._readToken(); const token = this.readToken();
if (!token) { if (!token) {
return this.registerForToken(); return this.registerForToken();
} else { } else {
return this._checkToken(token).catch((e) => { return this.checkToken(token).catch((e) => {
if (e instanceof TermsNotSignedError) { if (e instanceof TermsNotSignedError) {
// retrying won't help this // retrying won't help this
throw e; throw e;
@ -100,7 +102,7 @@ export default class ScalarAuthClient {
} }
} }
_getAccountName(token) { private getAccountName(token: string): Promise<string> {
const url = this.apiUrl + "/account"; const url = this.apiUrl + "/account";
return new Promise(function(resolve, reject) { return new Promise(function(resolve, reject) {
@ -125,8 +127,8 @@ export default class ScalarAuthClient {
}); });
} }
_checkToken(token) { private checkToken(token: string): Promise<string> {
return this._getAccountName(token).then(userId => { return this.getAccountName(token).then(userId => {
const me = MatrixClientPeg.get().getUserId(); const me = MatrixClientPeg.get().getUserId();
if (userId !== me) { if (userId !== me) {
throw new Error("Scalar token is owned by someone else: " + me); throw new Error("Scalar token is owned by someone else: " + me);
@ -154,7 +156,7 @@ export default class ScalarAuthClient {
parsedImRestUrl.pathname = ''; parsedImRestUrl.pathname = '';
return startTermsFlow([new Service( return startTermsFlow([new Service(
SERVICE_TYPES.IM, SERVICE_TYPES.IM,
parsedImRestUrl.format(), url.format(parsedImRestUrl),
token, token,
)], this.termsInteractionCallback).then(() => { )], this.termsInteractionCallback).then(() => {
return token; return token;
@ -165,22 +167,22 @@ export default class ScalarAuthClient {
}); });
} }
registerForToken() { registerForToken(): Promise<string> {
// Get openid bearer token from the HS as the first part of our dance // Get openid bearer token from the HS as the first part of our dance
return MatrixClientPeg.get().getOpenIdToken().then((tokenObject) => { return MatrixClientPeg.get().getOpenIdToken().then((tokenObject) => {
// Now we can send that to scalar and exchange it for a scalar token // Now we can send that to scalar and exchange it for a scalar token
return this.exchangeForScalarToken(tokenObject); return this.exchangeForScalarToken(tokenObject);
}).then((token) => { }).then((token) => {
// Validate it (this mostly checks to see if the IM needs us to agree to some terms) // Validate it (this mostly checks to see if the IM needs us to agree to some terms)
return this._checkToken(token); return this.checkToken(token);
}).then((token) => { }).then((token) => {
this.scalarToken = token; this.scalarToken = token;
this._writeTokenToStore(); this.writeTokenToStore();
return token; return token;
}); });
} }
exchangeForScalarToken(openidTokenObject) { exchangeForScalarToken(openidTokenObject: any): Promise<string> {
const scalarRestUrl = this.apiUrl; const scalarRestUrl = this.apiUrl;
return new Promise(function(resolve, reject) { return new Promise(function(resolve, reject) {
@ -194,7 +196,7 @@ export default class ScalarAuthClient {
if (err) { if (err) {
reject(err); reject(err);
} else if (response.statusCode / 100 !== 2) { } else if (response.statusCode / 100 !== 2) {
reject({statusCode: response.statusCode}); reject(new Error(`Scalar request failed: ${response.statusCode}`));
} else if (!body || !body.scalar_token) { } else if (!body || !body.scalar_token) {
reject(new Error("Missing scalar_token in response")); reject(new Error("Missing scalar_token in response"));
} else { } else {
@ -204,7 +206,7 @@ export default class ScalarAuthClient {
}); });
} }
getScalarPageTitle(url) { getScalarPageTitle(url: string): Promise<string> {
let scalarPageLookupUrl = this.apiUrl + '/widgets/title_lookup'; let scalarPageLookupUrl = this.apiUrl + '/widgets/title_lookup';
scalarPageLookupUrl = this.getStarterLink(scalarPageLookupUrl); scalarPageLookupUrl = this.getStarterLink(scalarPageLookupUrl);
scalarPageLookupUrl += '&curl=' + encodeURIComponent(url); scalarPageLookupUrl += '&curl=' + encodeURIComponent(url);
@ -218,7 +220,7 @@ export default class ScalarAuthClient {
if (err) { if (err) {
reject(err); reject(err);
} else if (response.statusCode / 100 !== 2) { } else if (response.statusCode / 100 !== 2) {
reject({statusCode: response.statusCode}); reject(new Error(`Scalar request failed: ${response.statusCode}`));
} else if (!body) { } else if (!body) {
reject(new Error("Missing page title in response")); reject(new Error("Missing page title in response"));
} else { } else {
@ -240,10 +242,10 @@ export default class ScalarAuthClient {
* @param {string} widgetId The widget ID to disable assets for * @param {string} widgetId The widget ID to disable assets for
* @return {Promise} Resolves on completion * @return {Promise} Resolves on completion
*/ */
disableWidgetAssets(widgetType: WidgetType, widgetId) { disableWidgetAssets(widgetType: WidgetType, widgetId: string): Promise<void> {
let url = this.apiUrl + '/widgets/set_assets_state'; let url = this.apiUrl + '/widgets/set_assets_state';
url = this.getStarterLink(url); url = this.getStarterLink(url);
return new Promise((resolve, reject) => { return new Promise<void>((resolve, reject) => {
request({ request({
method: 'GET', // XXX: Actions shouldn't be GET requests method: 'GET', // XXX: Actions shouldn't be GET requests
uri: url, uri: url,
@ -257,7 +259,7 @@ export default class ScalarAuthClient {
if (err) { if (err) {
reject(err); reject(err);
} else if (response.statusCode / 100 !== 2) { } else if (response.statusCode / 100 !== 2) {
reject({statusCode: response.statusCode}); reject(new Error(`Scalar request failed: ${response.statusCode}`));
} else if (!body) { } else if (!body) {
reject(new Error("Failed to set widget assets state")); reject(new Error("Failed to set widget assets state"));
} else { } else {
@ -267,7 +269,7 @@ export default class ScalarAuthClient {
}); });
} }
getScalarInterfaceUrlForRoom(room, screen, id) { getScalarInterfaceUrlForRoom(room: Room, screen: string, id: string): string {
const roomId = room.roomId; const roomId = room.roomId;
const roomName = room.name; const roomName = room.name;
let url = this.uiUrl; let url = this.uiUrl;
@ -284,7 +286,7 @@ export default class ScalarAuthClient {
return url; return url;
} }
getStarterLink(starterLinkUrl) { getStarterLink(starterLinkUrl: string): string {
return starterLinkUrl + "?scalar_token=" + encodeURIComponent(this.scalarToken); return starterLinkUrl + "?scalar_token=" + encodeURIComponent(this.scalarToken);
} }
} }

View file

@ -38,7 +38,7 @@ import {isPermalinkHost, parsePermalink} from "./utils/permalinks/Permalinks";
import {inviteUsersToRoom} from "./RoomInvite"; import {inviteUsersToRoom} from "./RoomInvite";
import { WidgetType } from "./widgets/WidgetType"; import { WidgetType } from "./widgets/WidgetType";
import { Jitsi } from "./widgets/Jitsi"; import { Jitsi } from "./widgets/Jitsi";
import { parseFragment as parseHtml } from "parse5"; import { parseFragment as parseHtml, Element as ChildElement } from "parse5";
import BugReportDialog from "./components/views/dialogs/BugReportDialog"; import BugReportDialog from "./components/views/dialogs/BugReportDialog";
import { ensureDMExists } from "./createRoom"; import { ensureDMExists } from "./createRoom";
import { ViewUserPayload } from "./dispatcher/payloads/ViewUserPayload"; import { ViewUserPayload } from "./dispatcher/payloads/ViewUserPayload";
@ -856,7 +856,7 @@ export const Commands = [
// some superfast regex over the text so we don't have to. // some superfast regex over the text so we don't have to.
const embed = parseHtml(widgetUrl); const embed = parseHtml(widgetUrl);
if (embed && embed.childNodes && embed.childNodes.length === 1) { if (embed && embed.childNodes && embed.childNodes.length === 1) {
const iframe = embed.childNodes[0]; const iframe = embed.childNodes[0] as ChildElement;
if (iframe.tagName.toLowerCase() === 'iframe' && iframe.attrs) { if (iframe.tagName.toLowerCase() === 'iframe' && iframe.attrs) {
const srcAttr = iframe.attrs.find(a => a.name === 'src'); const srcAttr = iframe.attrs.find(a => a.name === 'src');
console.log("Pulling URL out of iframe (embed code)"); console.log("Pulling URL out of iframe (embed code)");
@ -1222,4 +1222,5 @@ export function getCommand(input: string) {
args, args,
}; };
} }
return {};
} }

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2019 The Matrix.org Foundation C.I.C. Copyright 2019, 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -17,7 +17,7 @@ limitations under the License.
import classNames from 'classnames'; import classNames from 'classnames';
import {MatrixClientPeg} from './MatrixClientPeg'; import {MatrixClientPeg} from './MatrixClientPeg';
import * as sdk from './'; import * as sdk from '.';
import Modal from './Modal'; import Modal from './Modal';
export class TermsNotSignedError extends Error {} export class TermsNotSignedError extends Error {}
@ -32,13 +32,30 @@ export class Service {
* @param {string} baseUrl The Base URL of the service (ie. before '/_matrix') * @param {string} baseUrl The Base URL of the service (ie. before '/_matrix')
* @param {string} accessToken The user's access token for the service * @param {string} accessToken The user's access token for the service
*/ */
constructor(serviceType, baseUrl, accessToken) { constructor(public serviceType: string, public baseUrl: string, public accessToken: string) {
this.serviceType = serviceType;
this.baseUrl = baseUrl;
this.accessToken = accessToken;
} }
} }
interface Policy {
// @ts-ignore: No great way to express indexed types together with other keys
version: string;
[lang: string]: {
url: string;
};
}
type Policies = {
[policy: string]: Policy,
};
export type TermsInteractionCallback = (
policiesAndServicePairs: {
service: Service,
policies: Policies,
}[],
agreedUrls: string[],
extraClassNames?: string,
) => Promise<string[]>;
/** /**
* Start a flow where the user is presented with terms & conditions for some services * Start a flow where the user is presented with terms & conditions for some services
* *
@ -51,8 +68,8 @@ export class Service {
* if they cancel. * if they cancel.
*/ */
export async function startTermsFlow( export async function startTermsFlow(
services, services: Service[],
interactionCallback = dialogTermsInteractionCallback, interactionCallback: TermsInteractionCallback = dialogTermsInteractionCallback,
) { ) {
const termsPromises = services.map( const termsPromises = services.map(
(s) => MatrixClientPeg.get().getTerms(s.serviceType, s.baseUrl), (s) => MatrixClientPeg.get().getTerms(s.serviceType, s.baseUrl),
@ -77,7 +94,7 @@ export async function startTermsFlow(
* } * }
*/ */
const terms = await Promise.all(termsPromises); const terms: { policies: Policies }[] = await Promise.all(termsPromises);
const policiesAndServicePairs = terms.map((t, i) => { return { 'service': services[i], 'policies': t.policies }; }); const policiesAndServicePairs = terms.map((t, i) => { return { 'service': services[i], 'policies': t.policies }; });
// fetch the set of agreed policy URLs from account data // fetch the set of agreed policy URLs from account data
@ -158,10 +175,13 @@ export async function startTermsFlow(
} }
export function dialogTermsInteractionCallback( export function dialogTermsInteractionCallback(
policiesAndServicePairs, policiesAndServicePairs: {
agreedUrls, service: Service,
extraClassNames, policies: { [policy: string]: Policy },
) { }[],
agreedUrls: string[],
extraClassNames?: string,
): Promise<string[]> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
console.log("Terms that need agreement", policiesAndServicePairs); console.log("Terms that need agreement", policiesAndServicePairs);
const TermsDialog = sdk.getComponent("views.dialogs.TermsDialog"); const TermsDialog = sdk.getComponent("views.dialogs.TermsDialog");

View file

@ -547,17 +547,23 @@ function textForMjolnirEvent(event) {
// else the entity !== prevEntity - count as a removal & add // else the entity !== prevEntity - count as a removal & add
if (USER_RULE_TYPES.includes(event.getType())) { if (USER_RULE_TYPES.includes(event.getType())) {
return _t("%(senderName)s changed a rule that was banning users matching %(oldGlob)s to matching " + return _t(
"%(senderName)s changed a rule that was banning users matching %(oldGlob)s to matching " +
"%(newGlob)s for %(reason)s", "%(newGlob)s for %(reason)s",
{senderName, oldGlob: prevEntity, newGlob: entity, reason}); {senderName, oldGlob: prevEntity, newGlob: entity, reason},
);
} else if (ROOM_RULE_TYPES.includes(event.getType())) { } else if (ROOM_RULE_TYPES.includes(event.getType())) {
return _t("%(senderName)s changed a rule that was banning rooms matching %(oldGlob)s to matching " + return _t(
"%(senderName)s changed a rule that was banning rooms matching %(oldGlob)s to matching " +
"%(newGlob)s for %(reason)s", "%(newGlob)s for %(reason)s",
{senderName, oldGlob: prevEntity, newGlob: entity, reason}); {senderName, oldGlob: prevEntity, newGlob: entity, reason},
);
} else if (SERVER_RULE_TYPES.includes(event.getType())) { } else if (SERVER_RULE_TYPES.includes(event.getType())) {
return _t("%(senderName)s changed a rule that was banning servers matching %(oldGlob)s to matching " + return _t(
"%(senderName)s changed a rule that was banning servers matching %(oldGlob)s to matching " +
"%(newGlob)s for %(reason)s", "%(newGlob)s for %(reason)s",
{senderName, oldGlob: prevEntity, newGlob: entity, reason}); {senderName, oldGlob: prevEntity, newGlob: entity, reason},
);
} }
// Unknown type. We'll say something but we shouldn't end up here. // Unknown type. We'll say something but we shouldn't end up here.

View file

@ -45,7 +45,7 @@ export function eventTriggersUnreadCount(ev) {
} }
export function doesRoomHaveUnreadMessages(room) { export function doesRoomHaveUnreadMessages(room) {
const myUserId = MatrixClientPeg.get().credentials.userId; const myUserId = MatrixClientPeg.get().getUserId();
// get the most recent read receipt sent by our account. // get the most recent read receipt sent by our account.
// N.B. this is NOT a read marker (RM, aka "read up to marker"), // N.B. this is NOT a read marker (RM, aka "read up to marker"),

View file

@ -1,17 +0,0 @@
import Velocity from "velocity-animate";
// courtesy of https://github.com/julianshapiro/velocity/issues/283
// We only use easeOutBounce (easeInBounce is just sort of nonsensical)
function bounce( p ) {
let pow2;
let bounce = 4;
while ( p < ( ( pow2 = Math.pow( 2, --bounce ) ) - 1 ) / 11 ) {
// just sets pow2
}
return 1 / Math.pow( 4, 3 - bounce ) - 7.5625 * Math.pow( ( pow2 * 3 - 2 ) / 22 - p, 2 );
}
Velocity.Easings.easeOutBounce = function(p) {
return 1 - bounce(1 - p);
};

View file

@ -57,7 +57,11 @@ export default class VoipUserMapper {
if (!virtualRoom) return null; if (!virtualRoom) return null;
const virtualRoomEvent = virtualRoom.getAccountData(VIRTUAL_ROOM_EVENT_TYPE); const virtualRoomEvent = virtualRoom.getAccountData(VIRTUAL_ROOM_EVENT_TYPE);
if (!virtualRoomEvent || !virtualRoomEvent.getContent()) return null; if (!virtualRoomEvent || !virtualRoomEvent.getContent()) return null;
return virtualRoomEvent.getContent()['native_room'] || null; const nativeRoomID = virtualRoomEvent.getContent()['native_room'];
const nativeRoom = MatrixClientPeg.get().getRoom(nativeRoomID);
if (!nativeRoom || nativeRoom.getMyMembership() !== 'join') return null;
return nativeRoomID;
} }
public isVirtualRoom(room: Room): boolean { public isVirtualRoom(room: Room): boolean {

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2020 The Matrix.org Foundation C.I.C. Copyright 2020-2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -16,7 +16,6 @@ limitations under the License.
import React from 'react'; import React from 'react';
import * as sdk from '../../../../index'; import * as sdk from '../../../../index';
import PropTypes from 'prop-types';
import { _t } from '../../../../languageHandler'; import { _t } from '../../../../languageHandler';
import SdkConfig from '../../../../SdkConfig'; import SdkConfig from '../../../../SdkConfig';
import SettingsStore from "../../../../settings/SettingsStore"; import SettingsStore from "../../../../settings/SettingsStore";
@ -26,14 +25,23 @@ import {formatBytes, formatCountLong} from "../../../../utils/FormattingUtils";
import EventIndexPeg from "../../../../indexing/EventIndexPeg"; import EventIndexPeg from "../../../../indexing/EventIndexPeg";
import {SettingLevel} from "../../../../settings/SettingLevel"; import {SettingLevel} from "../../../../settings/SettingLevel";
interface IProps {
onFinished: (confirmed: boolean) => void;
}
interface IState {
eventIndexSize: number;
eventCount: number;
crawlingRoomsCount: number;
roomCount: number;
currentRoom: string;
crawlerSleepTime: number;
}
/* /*
* Allows the user to introspect the event index state and disable it. * Allows the user to introspect the event index state and disable it.
*/ */
export default class ManageEventIndexDialog extends React.Component { export default class ManageEventIndexDialog extends React.Component<IProps, IState> {
static propTypes = {
onFinished: PropTypes.func.isRequired,
};
constructor(props) { constructor(props) {
super(props); super(props);
@ -84,7 +92,7 @@ export default class ManageEventIndexDialog extends React.Component {
} }
} }
async componentDidMount(): void { async componentDidMount(): Promise<void> {
let eventIndexSize = 0; let eventIndexSize = 0;
let crawlingRoomsCount = 0; let crawlingRoomsCount = 0;
let roomCount = 0; let roomCount = 0;
@ -123,14 +131,14 @@ export default class ManageEventIndexDialog extends React.Component {
}); });
} }
_onDisable = async () => { private onDisable = async () => {
Modal.createTrackedDialogAsync("Disable message search", "Disable message search", Modal.createTrackedDialogAsync("Disable message search", "Disable message search",
import("./DisableEventIndexDialog"), import("./DisableEventIndexDialog"),
null, null, /* priority = */ false, /* static = */ true, null, null, /* priority = */ false, /* static = */ true,
); );
}; };
_onCrawlerSleepTimeChange = (e) => { private onCrawlerSleepTimeChange = (e) => {
this.setState({crawlerSleepTime: e.target.value}); this.setState({crawlerSleepTime: e.target.value});
SettingsStore.setValue("crawlerSleepTime", null, SettingLevel.DEVICE, e.target.value); SettingsStore.setValue("crawlerSleepTime", null, SettingLevel.DEVICE, e.target.value);
}; };
@ -144,7 +152,7 @@ export default class ManageEventIndexDialog extends React.Component {
crawlerState = _t("Not currently indexing messages for any room."); crawlerState = _t("Not currently indexing messages for any room.");
} else { } else {
crawlerState = ( crawlerState = (
_t("Currently indexing: %(currentRoom)s", { currentRoom: this.state.currentRoom }) _t("Currently indexing: %(currentRoom)s", { currentRoom: this.state.currentRoom })
); );
} }
@ -169,7 +177,7 @@ export default class ManageEventIndexDialog extends React.Component {
label={_t('Message downloading sleep time(ms)')} label={_t('Message downloading sleep time(ms)')}
type='number' type='number'
value={this.state.crawlerSleepTime} value={this.state.crawlerSleepTime}
onChange={this._onCrawlerSleepTimeChange} /> onChange={this.onCrawlerSleepTimeChange} />
</div> </div>
</div> </div>
); );
@ -188,7 +196,7 @@ export default class ManageEventIndexDialog extends React.Component {
onPrimaryButtonClick={this.props.onFinished} onPrimaryButtonClick={this.props.onFinished}
primaryButtonClass="primary" primaryButtonClass="primary"
cancelButton={_t("Disable")} cancelButton={_t("Disable")}
onCancel={this._onDisable} onCancel={this.onDisable}
cancelButtonClass="danger" cancelButtonClass="danger"
/> />
</BaseDialog> </BaseDialog>

View file

@ -310,7 +310,7 @@ export default class CreateKeyBackupDialog extends React.PureComponent {
const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
return <form onSubmit={this._onPassPhraseConfirmNextClick}> return <form onSubmit={this._onPassPhraseConfirmNextClick}>
<p>{_t( <p>{_t(
"Please enter your Security Phrase a second time to confirm.", "Enter your Security Phrase a second time to confirm it.",
)}</p> )}</p>
<div className="mx_CreateKeyBackupDialog_primaryContainer"> <div className="mx_CreateKeyBackupDialog_primaryContainer">
<div className="mx_CreateKeyBackupDialog_passPhraseContainer"> <div className="mx_CreateKeyBackupDialog_passPhraseContainer">
@ -498,9 +498,9 @@ export default class CreateKeyBackupDialog extends React.PureComponent {
title={this._titleForPhase(this.state.phase)} title={this._titleForPhase(this.state.phase)}
hasCancel={[PHASE_PASSPHRASE, PHASE_DONE].includes(this.state.phase)} hasCancel={[PHASE_PASSPHRASE, PHASE_DONE].includes(this.state.phase)}
> >
<div> <div>
{content} {content}
</div> </div>
</BaseDialog> </BaseDialog>
); );
} }

View file

@ -647,7 +647,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
} }
return <form onSubmit={this._onPassPhraseConfirmNextClick}> return <form onSubmit={this._onPassPhraseConfirmNextClick}>
<p>{_t( <p>{_t(
"Enter your recovery passphrase a second time to confirm it.", "Enter your Security Phrase a second time to confirm it.",
)}</p> )}</p>
<div className="mx_CreateSecretStorageDialog_passPhraseContainer"> <div className="mx_CreateSecretStorageDialog_passPhraseContainer">
<Field <Field
@ -655,7 +655,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
onChange={this._onPassPhraseConfirmChange} onChange={this._onPassPhraseConfirmChange}
value={this.state.passPhraseConfirm} value={this.state.passPhraseConfirm}
className="mx_CreateSecretStorageDialog_passPhraseField" className="mx_CreateSecretStorageDialog_passPhraseField"
label={_t("Confirm your recovery passphrase")} label={_t("Confirm your Security Phrase")}
autoFocus={true} autoFocus={true}
autoComplete="new-password" autoComplete="new-password"
/> />
@ -856,9 +856,9 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
hasCancel={this.props.hasCancel && [PHASE_PASSPHRASE].includes(this.state.phase)} hasCancel={this.props.hasCancel && [PHASE_PASSPHRASE].includes(this.state.phase)}
fixedWidth={false} fixedWidth={false}
> >
<div> <div>
{content} {content}
</div> </div>
</BaseDialog> </BaseDialog>
); );
} }

View file

@ -170,8 +170,11 @@ export default class ExportE2eKeysDialog extends React.Component {
</div> </div>
</div> </div>
<div className='mx_Dialog_buttons'> <div className='mx_Dialog_buttons'>
<input className='mx_Dialog_primary' type='submit' value={_t('Export')} <input
disabled={disableForm} className='mx_Dialog_primary'
type='submit'
value={_t('Export')}
disabled={disableForm}
/> />
<button onClick={this._onCancelClick} disabled={disableForm}> <button onClick={this._onCancelClick} disabled={disableForm}>
{ _t("Cancel") } { _t("Cancel") }

View file

@ -140,36 +140,36 @@ export default class ImportE2eKeysDialog extends React.Component {
</div> </div>
<div className='mx_E2eKeysDialog_inputTable'> <div className='mx_E2eKeysDialog_inputTable'>
<div className='mx_E2eKeysDialog_inputRow'> <div className='mx_E2eKeysDialog_inputRow'>
<div className='mx_E2eKeysDialog_inputLabel'> <div className='mx_E2eKeysDialog_inputLabel'>
<label htmlFor='importFile'> <label htmlFor='importFile'>
{ _t("File to import") } { _t("File to import") }
</label> </label>
</div> </div>
<div className='mx_E2eKeysDialog_inputCell'> <div className='mx_E2eKeysDialog_inputCell'>
<input <input
ref={this._file} ref={this._file}
id='importFile' id='importFile'
type='file' type='file'
autoFocus={true} autoFocus={true}
onChange={this._onFormChange} onChange={this._onFormChange}
disabled={disableForm} /> disabled={disableForm} />
</div> </div>
</div> </div>
<div className='mx_E2eKeysDialog_inputRow'> <div className='mx_E2eKeysDialog_inputRow'>
<div className='mx_E2eKeysDialog_inputLabel'> <div className='mx_E2eKeysDialog_inputLabel'>
<label htmlFor='passphrase'> <label htmlFor='passphrase'>
{ _t("Enter passphrase") } { _t("Enter passphrase") }
</label> </label>
</div> </div>
<div className='mx_E2eKeysDialog_inputCell'> <div className='mx_E2eKeysDialog_inputCell'>
<input <input
ref={this._passphrase} ref={this._passphrase}
id='passphrase' id='passphrase'
size='64' size='64'
type='password' type='password'
onChange={this._onFormChange} onChange={this._onFormChange}
disabled={disableForm} /> disabled={disableForm} />
</div> </div>
</div> </div>
</div> </div>
</div> </div>

View file

@ -200,10 +200,10 @@ class FilePanel extends React.Component {
previousPhase={RightPanelPhases.RoomSummary} previousPhase={RightPanelPhases.RoomSummary}
> >
<div className="mx_RoomView_empty"> <div className="mx_RoomView_empty">
{ _t("You must <a>register</a> to use this functionality", { _t("You must <a>register</a> to use this functionality",
{}, {},
{ 'a': (sub) => <a href="#/register" key="sub">{ sub }</a> }) { 'a': (sub) => <a href="#/register" key="sub">{ sub }</a> })
} }
</div> </div>
</BaseCard>; </BaseCard>;
} else if (this.noRoom) { } else if (this.noRoom) {

View file

@ -153,17 +153,17 @@ class GroupFilterPanel extends React.Component {
type="draggable-TagTile" type="draggable-TagTile"
> >
{ (provided, snapshot) => ( { (provided, snapshot) => (
<div <div
className="mx_GroupFilterPanel_tagTileContainer" className="mx_GroupFilterPanel_tagTileContainer"
ref={provided.innerRef} ref={provided.innerRef}
> >
{ this.renderGlobalIcon() } { this.renderGlobalIcon() }
{ tags } { tags }
<div> <div>
{createButton} {createButton}
</div>
{ provided.placeholder }
</div> </div>
{ provided.placeholder }
</div>
) } ) }
</Droppable> </Droppable>
</AutoHideScrollbar> </AutoHideScrollbar>

View file

@ -43,7 +43,7 @@ import {mediaFromMxc} from "../../customisations/Media";
import {replaceableComponent} from "../../utils/replaceableComponent"; import {replaceableComponent} from "../../utils/replaceableComponent";
const LONG_DESC_PLACEHOLDER = _td( const LONG_DESC_PLACEHOLDER = _td(
`<h1>HTML for your community's page</h1> `<h1>HTML for your community's page</h1>
<p> <p>
Use the long description to introduce new members to the community, or distribute Use the long description to introduce new members to the community, or distribute
some important <a href="foo">links</a> some important <a href="foo">links</a>
@ -110,14 +110,16 @@ class CategoryRoomList extends React.Component {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog( Modal.createTrackedDialog(
'Failed to add the following room to the group summary', 'Failed to add the following room to the group summary',
'', ErrorDialog, '',
{ ErrorDialog,
title: _t( {
"Failed to add the following rooms to the summary of %(groupId)s:", title: _t(
{groupId: this.props.groupId}, "Failed to add the following rooms to the summary of %(groupId)s:",
), {groupId: this.props.groupId},
description: errorList.join(", "), ),
}); description: errorList.join(", "),
},
);
}); });
}, },
}, /*className=*/null, /*isPriority=*/false, /*isStatic=*/true); }, /*className=*/null, /*isPriority=*/false, /*isStatic=*/true);
@ -146,8 +148,8 @@ class CategoryRoomList extends React.Component {
let catHeader = <div />; let catHeader = <div />;
if (this.props.category && this.props.category.profile) { if (this.props.category && this.props.category.profile) {
catHeader = <div className="mx_GroupView_featuredThings_category"> catHeader = <div className="mx_GroupView_featuredThings_category">
{ this.props.category.profile.name } { this.props.category.profile.name }
</div>; </div>;
} }
return <div className="mx_GroupView_featuredThings_container"> return <div className="mx_GroupView_featuredThings_container">
{ catHeader } { catHeader }
@ -190,13 +192,14 @@ class FeaturedRoom extends React.Component {
Modal.createTrackedDialog( Modal.createTrackedDialog(
'Failed to remove room from group summary', 'Failed to remove room from group summary',
'', ErrorDialog, '', ErrorDialog,
{ {
title: _t( title: _t(
"Failed to remove the room from the summary of %(groupId)s", "Failed to remove the room from the summary of %(groupId)s",
{groupId: this.props.groupId}, {groupId: this.props.groupId},
), ),
description: _t("The room '%(roomName)s' could not be removed from the summary.", {roomName}), description: _t("The room '%(roomName)s' could not be removed from the summary.", {roomName}),
}); },
);
}); });
}; };
@ -283,13 +286,14 @@ class RoleUserList extends React.Component {
Modal.createTrackedDialog( Modal.createTrackedDialog(
'Failed to add the following users to the community summary', 'Failed to add the following users to the community summary',
'', ErrorDialog, '', ErrorDialog,
{ {
title: _t( title: _t(
"Failed to add the following users to the summary of %(groupId)s:", "Failed to add the following users to the summary of %(groupId)s:",
{groupId: this.props.groupId}, {groupId: this.props.groupId},
), ),
description: errorList.join(", "), description: errorList.join(", "),
}); },
);
}); });
}, },
}, /*className=*/null, /*isPriority=*/false, /*isStatic=*/true); }, /*className=*/null, /*isPriority=*/false, /*isStatic=*/true);
@ -299,11 +303,11 @@ class RoleUserList extends React.Component {
const TintableSvg = sdk.getComponent("elements.TintableSvg"); const TintableSvg = sdk.getComponent("elements.TintableSvg");
const addButton = this.props.editing ? const addButton = this.props.editing ?
(<AccessibleButton className="mx_GroupView_featuredThings_addButton" onClick={this.onAddUsersClicked}> (<AccessibleButton className="mx_GroupView_featuredThings_addButton" onClick={this.onAddUsersClicked}>
<TintableSvg src={require("../../../res/img/icons-create-room.svg")} width="64" height="64" /> <TintableSvg src={require("../../../res/img/icons-create-room.svg")} width="64" height="64" />
<div className="mx_GroupView_featuredThings_addButton_label"> <div className="mx_GroupView_featuredThings_addButton_label">
{ _t('Add a User') } { _t('Add a User') }
</div> </div>
</AccessibleButton>) : <div />; </AccessibleButton>) : <div />;
const userNodes = this.props.users.map((u) => { const userNodes = this.props.users.map((u) => {
return <FeaturedUser return <FeaturedUser
key={u.user_id} key={u.user_id}
@ -352,14 +356,16 @@ class FeaturedUser extends React.Component {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog( Modal.createTrackedDialog(
'Failed to remove user from community summary', 'Failed to remove user from community summary',
'', ErrorDialog, '',
{ ErrorDialog,
title: _t( {
"Failed to remove a user from the summary of %(groupId)s", title: _t(
{groupId: this.props.groupId}, "Failed to remove a user from the summary of %(groupId)s",
), {groupId: this.props.groupId},
description: _t("The user '%(displayName)s' could not be removed from the summary.", {displayName}), ),
}); description: _t("The user '%(displayName)s' could not be removed from the summary.", {displayName}),
},
);
}); });
}; };
@ -767,8 +773,8 @@ export default class GroupView extends React.Component {
title: _t("Leave Community"), title: _t("Leave Community"),
description: ( description: (
<span> <span>
{ _t("Leave %(groupName)s?", {groupName: this.props.groupId}) } { _t("Leave %(groupName)s?", {groupName: this.props.groupId}) }
{ warnings } { warnings }
</span> </span>
), ),
button: _t("Leave"), button: _t("Leave"),
@ -1055,10 +1061,11 @@ export default class GroupView extends React.Component {
return null; return null;
} }
const membershipButtonClasses = classnames([ const membershipButtonClasses = classnames(
'mx_RoomHeader_textButton', [
'mx_GroupView_textButton', 'mx_RoomHeader_textButton',
], 'mx_GroupView_textButton',
],
membershipButtonExtraClasses, membershipButtonExtraClasses,
); );

View file

@ -154,7 +154,7 @@ export default class LeftPanel extends React.Component<IProps, IState> {
private doStickyHeaders(list: HTMLDivElement) { private doStickyHeaders(list: HTMLDivElement) {
const topEdge = list.scrollTop; const topEdge = list.scrollTop;
const bottomEdge = list.offsetHeight + list.scrollTop; const bottomEdge = list.offsetHeight + list.scrollTop;
const sublists = list.querySelectorAll<HTMLDivElement>(".mx_RoomSublist"); const sublists = list.querySelectorAll<HTMLDivElement>(".mx_RoomSublist:not(.mx_RoomSublist_hidden)");
const headerRightMargin = 15; // calculated from margins and widths to align with non-sticky tiles const headerRightMargin = 15; // calculated from margins and widths to align with non-sticky tiles
const headerStickyWidth = list.clientWidth - headerRightMargin; const headerStickyWidth = list.clientWidth - headerRightMargin;
@ -347,7 +347,7 @@ export default class LeftPanel extends React.Component<IProps, IState> {
if (element) { if (element) {
classes = element.classList; classes = element.classList;
} }
} while (element && !cssClasses.some(c => classes.contains(c))); } while (element && (!cssClasses.some(c => classes.contains(c)) || element.offsetParent === null));
if (element) { if (element) {
element.focus(); element.focus();
@ -416,7 +416,7 @@ export default class LeftPanel extends React.Component<IProps, IState> {
const roomList = <RoomList const roomList = <RoomList
onKeyDown={this.onKeyDown} onKeyDown={this.onKeyDown}
resizeNotifier={null} resizeNotifier={this.props.resizeNotifier}
onFocus={this.onFocus} onFocus={this.onFocus}
onBlur={this.onBlur} onBlur={this.onBlur}
isMinimized={this.props.isMinimized} isMinimized={this.props.isMinimized}

View file

@ -59,6 +59,9 @@ import { getKeyBindingsManager, NavigationAction, RoomAction } from '../../KeyBi
import { IOpts } from "../../createRoom"; import { IOpts } from "../../createRoom";
import SpacePanel from "../views/spaces/SpacePanel"; import SpacePanel from "../views/spaces/SpacePanel";
import {replaceableComponent} from "../../utils/replaceableComponent"; import {replaceableComponent} from "../../utils/replaceableComponent";
import CallHandler, { CallHandlerEvent } from '../../CallHandler';
import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
import AudioFeedArrayForCall from '../views/voip/AudioFeedArrayForCall';
// We need to fetch each pinned message individually (if we don't already have it) // We need to fetch each pinned message individually (if we don't already have it)
// so each pinned message may trigger a request. Limit the number per room for sanity. // so each pinned message may trigger a request. Limit the number per room for sanity.
@ -119,6 +122,7 @@ interface IState {
usageLimitEventContent?: IUsageLimit; usageLimitEventContent?: IUsageLimit;
usageLimitEventTs?: number; usageLimitEventTs?: number;
useCompactLayout: boolean; useCompactLayout: boolean;
activeCalls: Array<MatrixCall>;
} }
/** /**
@ -160,6 +164,7 @@ class LoggedInView extends React.Component<IProps, IState> {
// use compact timeline view // use compact timeline view
useCompactLayout: SettingsStore.getValue('useCompactLayout'), useCompactLayout: SettingsStore.getValue('useCompactLayout'),
usageLimitDismissed: false, usageLimitDismissed: false,
activeCalls: [],
}; };
// stash the MatrixClient in case we log out before we are unmounted // stash the MatrixClient in case we log out before we are unmounted
@ -175,6 +180,7 @@ class LoggedInView extends React.Component<IProps, IState> {
componentDidMount() { componentDidMount() {
document.addEventListener('keydown', this._onNativeKeyDown, false); document.addEventListener('keydown', this._onNativeKeyDown, false);
CallHandler.sharedInstance().addListener(CallHandlerEvent.CallsChanged, this.onCallsChanged);
this._updateServerNoticeEvents(); this._updateServerNoticeEvents();
@ -199,6 +205,7 @@ class LoggedInView extends React.Component<IProps, IState> {
componentWillUnmount() { componentWillUnmount() {
document.removeEventListener('keydown', this._onNativeKeyDown, false); document.removeEventListener('keydown', this._onNativeKeyDown, false);
CallHandler.sharedInstance().removeListener(CallHandlerEvent.CallsChanged, this.onCallsChanged);
this._matrixClient.removeListener("accountData", this.onAccountData); this._matrixClient.removeListener("accountData", this.onAccountData);
this._matrixClient.removeListener("sync", this.onSync); this._matrixClient.removeListener("sync", this.onSync);
this._matrixClient.removeListener("RoomState.events", this.onRoomStateEvents); this._matrixClient.removeListener("RoomState.events", this.onRoomStateEvents);
@ -206,6 +213,12 @@ class LoggedInView extends React.Component<IProps, IState> {
this.resizer.detach(); this.resizer.detach();
} }
private onCallsChanged = () => {
this.setState({
activeCalls: CallHandler.sharedInstance().getAllActiveCalls(),
});
};
// Child components assume that the client peg will not be null, so give them some // Child components assume that the client peg will not be null, so give them some
// sort of assurance here by only allowing a re-render if the client is truthy. // sort of assurance here by only allowing a re-render if the client is truthy.
// //
@ -661,6 +674,12 @@ class LoggedInView extends React.Component<IProps, IState> {
bodyClasses += ' mx_MatrixChat_useCompactLayout'; bodyClasses += ' mx_MatrixChat_useCompactLayout';
} }
const audioFeedArraysForCalls = this.state.activeCalls.map((call) => {
return (
<AudioFeedArrayForCall call={call} key={call.callId} />
);
});
return ( return (
<MatrixClientContext.Provider value={this._matrixClient}> <MatrixClientContext.Provider value={this._matrixClient}>
<div <div
@ -685,6 +704,7 @@ class LoggedInView extends React.Component<IProps, IState> {
<CallContainer /> <CallContainer />
<NonUrgentToastContainer /> <NonUrgentToastContainer />
<HostSignupContainer /> <HostSignupContainer />
{audioFeedArraysForCalls}
</MatrixClientContext.Provider> </MatrixClientContext.Provider>
); );
} }

View file

@ -1094,10 +1094,24 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
private leaveRoomWarnings(roomId: string) { private leaveRoomWarnings(roomId: string) {
const roomToLeave = MatrixClientPeg.get().getRoom(roomId); const roomToLeave = MatrixClientPeg.get().getRoom(roomId);
const isSpace = roomToLeave?.isSpaceRoom(); const isSpace = SettingsStore.getValue("feature_spaces") && roomToLeave?.isSpaceRoom();
// Show a warning if there are additional complications. // Show a warning if there are additional complications.
const joinRules = roomToLeave.currentState.getStateEvents('m.room.join_rules', '');
const warnings = []; const warnings = [];
const memberCount = roomToLeave.currentState.getJoinedMemberCount();
if (memberCount === 1) {
warnings.push((
<span className="warning" key="only_member_warning">
{' '/* Whitespace, otherwise the sentences get smashed together */ }
{ _t("You are the only person here. " +
"If you leave, no one will be able to join in the future, including you.") }
</span>
));
return warnings;
}
const joinRules = roomToLeave.currentState.getStateEvents('m.room.join_rules', '');
if (joinRules) { if (joinRules) {
const rule = joinRules.getContent().join_rule; const rule = joinRules.getContent().join_rule;
if (rule !== "public") { if (rule !== "public") {
@ -1119,7 +1133,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
const roomToLeave = MatrixClientPeg.get().getRoom(roomId); const roomToLeave = MatrixClientPeg.get().getRoom(roomId);
const warnings = this.leaveRoomWarnings(roomId); const warnings = this.leaveRoomWarnings(roomId);
const isSpace = roomToLeave?.isSpaceRoom(); const isSpace = SettingsStore.getValue("feature_spaces") && roomToLeave?.isSpaceRoom();
Modal.createTrackedDialog(isSpace ? "Leave space" : "Leave room", '', QuestionDialog, { Modal.createTrackedDialog(isSpace ? "Leave space" : "Leave room", '', QuestionDialog, {
title: isSpace ? _t("Leave space") : _t("Leave room"), title: isSpace ? _t("Leave space") : _t("Leave room"),
description: ( description: (

View file

@ -427,8 +427,10 @@ export default class MessagePanel extends React.Component {
// we get a new DOM node (restarting the animation) when the ghost // we get a new DOM node (restarting the animation) when the ghost
// moves to a different event. // moves to a different event.
return ( return (
<li key={"_readuptoghost_"+eventId} <li
className="mx_RoomView_myReadMarker_container"> key={"_readuptoghost_"+eventId}
className="mx_RoomView_myReadMarker_container"
>
{ hr } { hr }
</li> </li>
); );
@ -659,6 +661,7 @@ export default class MessagePanel extends React.Component {
showReactions={this.props.showReactions} showReactions={this.props.showReactions}
layout={this.props.layout} layout={this.props.layout}
enableFlair={this.props.enableFlair} enableFlair={this.props.enableFlair}
showReadReceipts={this.props.showReadReceipts}
/> />
</TileErrorBoundary> </TileErrorBoundary>
</li>, </li>,
@ -1013,13 +1016,13 @@ class CreationGrouper {
ret.push( ret.push(
<EventListSummary <EventListSummary
key="roomcreationsummary" key="roomcreationsummary"
events={this.events} events={this.events}
onToggle={panel._onHeightChanged} // Update scroll state onToggle={panel._onHeightChanged} // Update scroll state
summaryMembers={[ev.sender]} summaryMembers={[ev.sender]}
summaryText={summaryText} summaryText={summaryText}
> >
{ eventTiles } { eventTiles }
</EventListSummary>, </EventListSummary>,
); );
@ -1221,11 +1224,11 @@ class MemberGrouper {
ret.push( ret.push(
<MemberEventListSummary key={key} <MemberEventListSummary key={key}
events={this.events} events={this.events}
onToggle={panel._onHeightChanged} // Update scroll state onToggle={panel._onHeightChanged} // Update scroll state
startExpanded={highlightInMels} startExpanded={highlightInMels}
> >
{ eventTiles } { eventTiles }
</MemberEventListSummary>, </MemberEventListSummary>,
); );

View file

@ -35,6 +35,7 @@ import {Action} from "../../dispatcher/actions";
import RoomSummaryCard from "../views/right_panel/RoomSummaryCard"; import RoomSummaryCard from "../views/right_panel/RoomSummaryCard";
import WidgetCard from "../views/right_panel/WidgetCard"; import WidgetCard from "../views/right_panel/WidgetCard";
import {replaceableComponent} from "../../utils/replaceableComponent"; import {replaceableComponent} from "../../utils/replaceableComponent";
import SettingsStore from "../../settings/SettingsStore";
@replaceableComponent("structures.RightPanel") @replaceableComponent("structures.RightPanel")
export default class RightPanel extends React.Component { export default class RightPanel extends React.Component {
@ -85,7 +86,9 @@ export default class RightPanel extends React.Component {
return RightPanelPhases.GroupMemberList; return RightPanelPhases.GroupMemberList;
} }
return rps.groupPanelPhase; return rps.groupPanelPhase;
} else if (this.props.room?.isSpaceRoom() && !RIGHT_PANEL_SPACE_PHASES.includes(rps.roomPanelPhase)) { } else if (SettingsStore.getValue("feature_spaces") && this.props.room?.isSpaceRoom()
&& !RIGHT_PANEL_SPACE_PHASES.includes(rps.roomPanelPhase)
) {
return RightPanelPhases.SpaceMemberList; return RightPanelPhases.SpaceMemberList;
} else if (userForPanel) { } else if (userForPanel) {
// XXX FIXME AAAAAARGH: What is going on with this class!? It takes some of its state // XXX FIXME AAAAAARGH: What is going on with this class!? It takes some of its state

View file

@ -17,6 +17,8 @@ limitations under the License.
import * as React from "react"; import * as React from "react";
import { createRef } from "react"; import { createRef } from "react";
import classNames from "classnames"; import classNames from "classnames";
import { Room } from "matrix-js-sdk/src/models/room";
import defaultDispatcher from "../../dispatcher/dispatcher"; import defaultDispatcher from "../../dispatcher/dispatcher";
import { _t } from "../../languageHandler"; import { _t } from "../../languageHandler";
import { ActionPayload } from "../../dispatcher/payloads"; import { ActionPayload } from "../../dispatcher/payloads";
@ -25,8 +27,8 @@ import { Action } from "../../dispatcher/actions";
import RoomListStore from "../../stores/room-list/RoomListStore"; import RoomListStore from "../../stores/room-list/RoomListStore";
import { NameFilterCondition } from "../../stores/room-list/filters/NameFilterCondition"; import { NameFilterCondition } from "../../stores/room-list/filters/NameFilterCondition";
import { getKeyBindingsManager, RoomListAction } from "../../KeyBindingsManager"; import { getKeyBindingsManager, RoomListAction } from "../../KeyBindingsManager";
import {replaceableComponent} from "../../utils/replaceableComponent"; import { replaceableComponent } from "../../utils/replaceableComponent";
import SpaceStore, {UPDATE_SELECTED_SPACE} from "../../stores/SpaceStore"; import SpaceStore, { UPDATE_SELECTED_SPACE, UPDATE_TOP_LEVEL_SPACES } from "../../stores/SpaceStore";
interface IProps { interface IProps {
isMinimized: boolean; isMinimized: boolean;
@ -40,6 +42,7 @@ interface IProps {
interface IState { interface IState {
query: string; query: string;
focused: boolean; focused: boolean;
inSpaces: boolean;
} }
@replaceableComponent("structures.RoomSearch") @replaceableComponent("structures.RoomSearch")
@ -54,11 +57,13 @@ export default class RoomSearch extends React.PureComponent<IProps, IState> {
this.state = { this.state = {
query: "", query: "",
focused: false, focused: false,
inSpaces: false,
}; };
this.dispatcherRef = defaultDispatcher.register(this.onAction); this.dispatcherRef = defaultDispatcher.register(this.onAction);
// clear filter when changing spaces, in future we may wish to maintain a filter per-space // clear filter when changing spaces, in future we may wish to maintain a filter per-space
SpaceStore.instance.on(UPDATE_SELECTED_SPACE, this.clearInput); SpaceStore.instance.on(UPDATE_SELECTED_SPACE, this.clearInput);
SpaceStore.instance.on(UPDATE_TOP_LEVEL_SPACES, this.onSpaces);
} }
public componentDidUpdate(prevProps: Readonly<IProps>, prevState: Readonly<IState>): void { public componentDidUpdate(prevProps: Readonly<IProps>, prevState: Readonly<IState>): void {
@ -79,8 +84,15 @@ export default class RoomSearch extends React.PureComponent<IProps, IState> {
public componentWillUnmount() { public componentWillUnmount() {
defaultDispatcher.unregister(this.dispatcherRef); defaultDispatcher.unregister(this.dispatcherRef);
SpaceStore.instance.off(UPDATE_SELECTED_SPACE, this.clearInput); SpaceStore.instance.off(UPDATE_SELECTED_SPACE, this.clearInput);
SpaceStore.instance.off(UPDATE_TOP_LEVEL_SPACES, this.onSpaces);
} }
private onSpaces = (spaces: Room[]) => {
this.setState({
inSpaces: spaces.length > 0,
});
};
private onAction = (payload: ActionPayload) => { private onAction = (payload: ActionPayload) => {
if (payload.action === 'view_room' && payload.clear_search) { if (payload.action === 'view_room' && payload.clear_search) {
this.clearInput(); this.clearInput();
@ -152,6 +164,11 @@ export default class RoomSearch extends React.PureComponent<IProps, IState> {
'mx_RoomSearch_inputExpanded': this.state.query || this.state.focused, 'mx_RoomSearch_inputExpanded': this.state.query || this.state.focused,
}); });
let placeholder = _t("Filter");
if (this.state.inSpaces) {
placeholder = _t("Filter all spaces");
}
let icon = ( let icon = (
<div className='mx_RoomSearch_icon' /> <div className='mx_RoomSearch_icon' />
); );
@ -165,7 +182,7 @@ export default class RoomSearch extends React.PureComponent<IProps, IState> {
onBlur={this.onBlur} onBlur={this.onBlur}
onChange={this.onChange} onChange={this.onChange}
onKeyDown={this.onKeyDown} onKeyDown={this.onKeyDown}
placeholder={_t("Filter")} placeholder={placeholder}
autoComplete="off" autoComplete="off"
/> />
); );

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2015-2020 The Matrix.org Foundation C.I.C. Copyright 2015-2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -20,16 +20,20 @@ import { _t, _td } from '../../languageHandler';
import {MatrixClientPeg} from '../../MatrixClientPeg'; import {MatrixClientPeg} from '../../MatrixClientPeg';
import Resend from '../../Resend'; import Resend from '../../Resend';
import dis from '../../dispatcher/dispatcher'; import dis from '../../dispatcher/dispatcher';
import {messageForResourceLimitError, messageForSendError} from '../../utils/ErrorUtils'; import {messageForResourceLimitError} from '../../utils/ErrorUtils';
import {Action} from "../../dispatcher/actions"; import {Action} from "../../dispatcher/actions";
import {replaceableComponent} from "../../utils/replaceableComponent"; import {replaceableComponent} from "../../utils/replaceableComponent";
import {EventStatus} from "matrix-js-sdk/src/models/event"; import {EventStatus} from "matrix-js-sdk/src/models/event";
import NotificationBadge from "../views/rooms/NotificationBadge";
import {StaticNotificationState} from "../../stores/notifications/StaticNotificationState";
import AccessibleButton from "../views/elements/AccessibleButton";
import InlineSpinner from "../views/elements/InlineSpinner";
const STATUS_BAR_HIDDEN = 0; const STATUS_BAR_HIDDEN = 0;
const STATUS_BAR_EXPANDED = 1; const STATUS_BAR_EXPANDED = 1;
const STATUS_BAR_EXPANDED_LARGE = 2; const STATUS_BAR_EXPANDED_LARGE = 2;
function getUnsentMessages(room) { export function getUnsentMessages(room) {
if (!room) { return []; } if (!room) { return []; }
return room.getPendingEvents().filter(function(ev) { return room.getPendingEvents().filter(function(ev) {
return ev.status === EventStatus.NOT_SENT; return ev.status === EventStatus.NOT_SENT;
@ -76,6 +80,7 @@ export default class RoomStatusBar extends React.Component {
syncState: MatrixClientPeg.get().getSyncState(), syncState: MatrixClientPeg.get().getSyncState(),
syncStateData: MatrixClientPeg.get().getSyncStateData(), syncStateData: MatrixClientPeg.get().getSyncStateData(),
unsentMessages: getUnsentMessages(this.props.room), unsentMessages: getUnsentMessages(this.props.room),
isResending: false,
}; };
componentDidMount() { componentDidMount() {
@ -109,7 +114,10 @@ export default class RoomStatusBar extends React.Component {
}; };
_onResendAllClick = () => { _onResendAllClick = () => {
Resend.resendUnsentEvents(this.props.room); Resend.resendUnsentEvents(this.props.room).then(() => {
this.setState({isResending: false});
});
this.setState({isResending: true});
dis.fire(Action.FocusComposer); dis.fire(Action.FocusComposer);
}; };
@ -120,9 +128,10 @@ export default class RoomStatusBar extends React.Component {
_onRoomLocalEchoUpdated = (event, room, oldEventId, oldStatus) => { _onRoomLocalEchoUpdated = (event, room, oldEventId, oldStatus) => {
if (room.roomId !== this.props.room.roomId) return; if (room.roomId !== this.props.room.roomId) return;
const messages = getUnsentMessages(this.props.room);
this.setState({ this.setState({
unsentMessages: getUnsentMessages(this.props.room), unsentMessages: messages,
isResending: messages.length > 0 && this.state.isResending,
}); });
}; };
@ -141,7 +150,7 @@ export default class RoomStatusBar extends React.Component {
_getSize() { _getSize() {
if (this._shouldShowConnectionError()) { if (this._shouldShowConnectionError()) {
return STATUS_BAR_EXPANDED; return STATUS_BAR_EXPANDED;
} else if (this.state.unsentMessages.length > 0) { } else if (this.state.unsentMessages.length > 0 || this.state.isResending) {
return STATUS_BAR_EXPANDED_LARGE; return STATUS_BAR_EXPANDED_LARGE;
} }
return STATUS_BAR_HIDDEN; return STATUS_BAR_HIDDEN;
@ -162,7 +171,6 @@ export default class RoomStatusBar extends React.Component {
_getUnsentMessageContent() { _getUnsentMessageContent() {
const unsentMessages = this.state.unsentMessages; const unsentMessages = this.state.unsentMessages;
if (!unsentMessages.length) return null;
let title; let title;
@ -192,89 +200,92 @@ export default class RoomStatusBar extends React.Component {
} else if (resourceLimitError) { } else if (resourceLimitError) {
title = messageForResourceLimitError( title = messageForResourceLimitError(
resourceLimitError.data.limit_type, resourceLimitError.data.limit_type,
resourceLimitError.data.admin_contact, { resourceLimitError.data.admin_contact,
'monthly_active_user': _td( {
"Your message wasn't sent because this homeserver has hit its Monthly Active User Limit. " + 'monthly_active_user': _td(
"Please <a>contact your service administrator</a> to continue using the service.", "Your message wasn't sent because this homeserver has hit its Monthly Active User Limit. " +
), "Please <a>contact your service administrator</a> to continue using the service.",
'hs_disabled': _td( ),
"Your message wasn't sent because this homeserver has been blocked by it's administrator. " + 'hs_disabled': _td(
"Please <a>contact your service administrator</a> to continue using the service.", "Your message wasn't sent because this homeserver has been blocked by it's administrator. " +
), "Please <a>contact your service administrator</a> to continue using the service.",
'': _td( ),
"Your message wasn't sent because this homeserver has exceeded a resource limit. " + '': _td(
"Please <a>contact your service administrator</a> to continue using the service.", "Your message wasn't sent because this homeserver has exceeded a resource limit. " +
), "Please <a>contact your service administrator</a> to continue using the service.",
}); ),
} else if ( },
unsentMessages.length === 1 && );
unsentMessages[0].error &&
unsentMessages[0].error.data &&
unsentMessages[0].error.data.error
) {
title = messageForSendError(unsentMessages[0].error.data) || unsentMessages[0].error.data.error;
} else { } else {
title = _t('%(count)s of your messages have not been sent.', { count: unsentMessages.length }); title = _t('Some of your messages have not been sent');
} }
const content = _t("%(count)s <resendText>Resend all</resendText> or <cancelText>cancel all</cancelText> " + let buttonRow = <>
"now. You can also select individual messages to resend or cancel.", <AccessibleButton onClick={this._onCancelAllClick} className="mx_RoomStatusBar_unsentCancelAllBtn">
{ count: unsentMessages.length }, {_t("Delete all")}
{ </AccessibleButton>
'resendText': (sub) => <AccessibleButton onClick={this._onResendAllClick} className="mx_RoomStatusBar_unsentResendAllBtn">
<a className="mx_RoomStatusBar_resend_link" key="resend" onClick={this._onResendAllClick}>{ sub }</a>, {_t("Retry all")}
'cancelText': (sub) => </AccessibleButton>
<a className="mx_RoomStatusBar_resend_link" key="cancel" onClick={this._onCancelAllClick}>{ sub }</a>, </>;
}, if (this.state.isResending) {
); buttonRow = <>
<InlineSpinner w={20} h={20} />
{/* span for css */}
<span>{_t("Sending")}</span>
</>;
}
return <div className="mx_RoomStatusBar_connectionLostBar"> return <>
<img src={require("../../../res/img/feather-customised/warning-triangle.svg")} width="24" height="24" title={_t("Warning")} alt="" /> <div className="mx_RoomStatusBar mx_RoomStatusBar_unsentMessages">
<div> <div role="alert">
<div className="mx_RoomStatusBar_connectionLostBar_title"> <div className="mx_RoomStatusBar_unsentBadge">
{ title } <NotificationBadge
</div> notification={StaticNotificationState.RED_EXCLAMATION}
<div className="mx_RoomStatusBar_connectionLostBar_desc"> />
{ content } </div>
<div>
<div className="mx_RoomStatusBar_unsentTitle">
{ title }
</div>
<div className="mx_RoomStatusBar_unsentDescription">
{ _t("You can select all or individual messages to retry or delete") }
</div>
</div>
<div className="mx_RoomStatusBar_unsentButtonBar">
{buttonRow}
</div>
</div> </div>
</div> </div>
</div>; </>;
} }
// return suitable content for the main (text) part of the status bar. render() {
_getContent() {
if (this._shouldShowConnectionError()) { if (this._shouldShowConnectionError()) {
return ( return (
<div className="mx_RoomStatusBar_connectionLostBar"> <div className="mx_RoomStatusBar">
<img src={require("../../../res/img/feather-customised/warning-triangle.svg")} width="24" height="24" title="/!\ " alt="/!\ " /> <div role="alert">
<div> <div className="mx_RoomStatusBar_connectionLostBar">
<div className="mx_RoomStatusBar_connectionLostBar_title"> <img src={require("../../../res/img/feather-customised/warning-triangle.svg")} width="24"
{ _t('Connectivity to the server has been lost.') } height="24" title="/!\ " alt="/!\ " />
</div> <div>
<div className="mx_RoomStatusBar_connectionLostBar_desc"> <div className="mx_RoomStatusBar_connectionLostBar_title">
{ _t('Sent messages will be stored until your connection has returned.') } {_t('Connectivity to the server has been lost.')}
</div>
<div className="mx_RoomStatusBar_connectionLostBar_desc">
{_t('Sent messages will be stored until your connection has returned.')}
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
); );
} }
if (this.state.unsentMessages.length > 0) { if (this.state.unsentMessages.length > 0 || this.state.isResending) {
return this._getUnsentMessageContent(); return this._getUnsentMessageContent();
} }
return null; return null;
} }
render() {
const content = this._getContent();
return (
<div className="mx_RoomStatusBar">
<div role="alert">
{ content }
</div>
</div>
);
}
} }

View file

@ -190,6 +190,9 @@ export interface IState {
rejectError?: Error; rejectError?: Error;
hasPinnedWidgets?: boolean; hasPinnedWidgets?: boolean;
dragCounter: number; dragCounter: number;
// whether or not a spaces context switch brought us here,
// if it did we don't want the room to be marked as read as soon as it is loaded.
wasContextSwitch?: boolean;
} }
@replaceableComponent("structures.RoomView") @replaceableComponent("structures.RoomView")
@ -326,6 +329,7 @@ export default class RoomView extends React.Component<IProps, IState> {
shouldPeek: this.state.matrixClientIsReady && RoomViewStore.shouldPeek(), shouldPeek: this.state.matrixClientIsReady && RoomViewStore.shouldPeek(),
showingPinned: SettingsStore.getValue("PinnedEvents.isOpen", roomId), showingPinned: SettingsStore.getValue("PinnedEvents.isOpen", roomId),
showReadReceipts: SettingsStore.getValue("showReadReceipts", roomId), showReadReceipts: SettingsStore.getValue("showReadReceipts", roomId),
wasContextSwitch: RoomViewStore.getWasContextSwitch(),
}; };
if (!initial && this.state.shouldPeek && !newState.shouldPeek) { if (!initial && this.state.shouldPeek && !newState.shouldPeek) {
@ -1746,7 +1750,10 @@ export default class RoomView extends React.Component<IProps, IState> {
} }
const myMembership = this.state.room.getMyMembership(); const myMembership = this.state.room.getMyMembership();
if (myMembership === "invite" && !this.state.room.isSpaceRoom()) { // SpaceRoomView handles invites itself if (myMembership === "invite"
// SpaceRoomView handles invites itself
&& (!SettingsStore.getValue("feature_spaces") || !this.state.room.isSpaceRoom())
) {
if (this.state.joining || this.state.rejecting) { if (this.state.joining || this.state.rejecting) {
return ( return (
<ErrorBoundary> <ErrorBoundary>
@ -1888,7 +1895,7 @@ export default class RoomView extends React.Component<IProps, IState> {
room={this.state.room} room={this.state.room}
/> />
); );
if (!this.state.canPeek && !this.state.room?.isSpaceRoom()) { if (!this.state.canPeek && (!SettingsStore.getValue("feature_spaces") || !this.state.room?.isSpaceRoom())) {
return ( return (
<div className="mx_RoomView"> <div className="mx_RoomView">
{ previewBar } { previewBar }
@ -2014,6 +2021,7 @@ export default class RoomView extends React.Component<IProps, IState> {
timelineSet={this.state.room.getUnfilteredTimelineSet()} timelineSet={this.state.room.getUnfilteredTimelineSet()}
showReadReceipts={this.state.showReadReceipts} showReadReceipts={this.state.showReadReceipts}
manageReadReceipts={!this.state.isPeeking} manageReadReceipts={!this.state.isPeeking}
sendReadReceiptOnLoad={!this.state.wasContextSwitch}
manageReadMarkers={!this.state.isPeeking} manageReadMarkers={!this.state.isPeeking}
hidden={hideMessagePanel} hidden={hideMessagePanel}
highlightedEventId={highlightedEventId} highlightedEventId={highlightedEventId}

View file

@ -525,7 +525,7 @@ export default class ScrollPanel extends React.Component {
*/ */
scrollRelative = mult => { scrollRelative = mult => {
const scrollNode = this._getScrollNode(); const scrollNode = this._getScrollNode();
const delta = mult * scrollNode.clientHeight * 0.5; const delta = mult * scrollNode.clientHeight * 0.9;
scrollNode.scrollBy(0, delta); scrollNode.scrollBy(0, delta);
this._saveScrollState(); this._saveScrollState();
}; };
@ -884,16 +884,20 @@ export default class ScrollPanel extends React.Component {
// give the <ol> an explicit role=list because Safari+VoiceOver seems to think an ordered-list with // give the <ol> an explicit role=list because Safari+VoiceOver seems to think an ordered-list with
// list-style-type: none; is no longer a list // list-style-type: none; is no longer a list
return (<AutoHideScrollbar wrappedRef={this._collectScroll} return (
<AutoHideScrollbar
wrappedRef={this._collectScroll}
onScroll={this.onScroll} onScroll={this.onScroll}
className={`mx_ScrollPanel ${this.props.className}`} style={this.props.style}> className={`mx_ScrollPanel ${this.props.className}`}
{ this.props.fixedChildren } style={this.props.style}
<div className="mx_RoomView_messageListWrapper"> >
<ol ref={this._itemlist} className="mx_RoomView_MessageList" aria-live="polite" role="list"> { this.props.fixedChildren }
{ this.props.children } <div className="mx_RoomView_messageListWrapper">
</ol> <ol ref={this._itemlist} className="mx_RoomView_MessageList" aria-live="polite" role="list">
</div> { this.props.children }
</AutoHideScrollbar> </ol>
); </div>
</AutoHideScrollbar>
);
} }
} }

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React, {useMemo, useState} from "react"; import React, {ReactNode, useMemo, useState} from "react";
import {Room} from "matrix-js-sdk/src/models/room"; import {Room} from "matrix-js-sdk/src/models/room";
import {MatrixClient} from "matrix-js-sdk/src/client"; import {MatrixClient} from "matrix-js-sdk/src/client";
import {EventType, RoomType} from "matrix-js-sdk/src/@types/event"; import {EventType, RoomType} from "matrix-js-sdk/src/@types/event";
@ -24,7 +24,7 @@ import {sortBy} from "lodash";
import {MatrixClientPeg} from "../../MatrixClientPeg"; import {MatrixClientPeg} from "../../MatrixClientPeg";
import dis from "../../dispatcher/dispatcher"; import dis from "../../dispatcher/dispatcher";
import {_t} from "../../languageHandler"; import {_t} from "../../languageHandler";
import AccessibleButton from "../views/elements/AccessibleButton"; import AccessibleButton, {ButtonEvent} from "../views/elements/AccessibleButton";
import BaseDialog from "../views/dialogs/BaseDialog"; import BaseDialog from "../views/dialogs/BaseDialog";
import Spinner from "../views/elements/Spinner"; import Spinner from "../views/elements/Spinner";
import SearchBox from "./SearchBox"; import SearchBox from "./SearchBox";
@ -39,11 +39,14 @@ import {mediaFromMxc} from "../../customisations/Media";
import InfoTooltip from "../views/elements/InfoTooltip"; import InfoTooltip from "../views/elements/InfoTooltip";
import TextWithTooltip from "../views/elements/TextWithTooltip"; import TextWithTooltip from "../views/elements/TextWithTooltip";
import {useStateToggle} from "../../hooks/useStateToggle"; import {useStateToggle} from "../../hooks/useStateToggle";
import {getOrder} from "../../stores/SpaceStore";
import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton";
interface IHierarchyProps { interface IHierarchyProps {
space: Room; space: Room;
initialText?: string; initialText?: string;
refreshToken?: any; refreshToken?: any;
additionalButtons?: ReactNode;
showRoom(room: ISpaceSummaryRoom, viaServers?: string[], autoJoin?: boolean): void; showRoom(room: ISpaceSummaryRoom, viaServers?: string[], autoJoin?: boolean): void;
} }
@ -106,8 +109,16 @@ const Tile: React.FC<ITileProps> = ({
const cliRoom = cli.getRoom(room.room_id); const cliRoom = cli.getRoom(room.room_id);
const myMembership = cliRoom?.getMyMembership(); const myMembership = cliRoom?.getMyMembership();
const onPreviewClick = () => onViewRoomClick(false); const onPreviewClick = (ev: ButtonEvent) => {
const onJoinClick = () => onViewRoomClick(true); ev.preventDefault();
ev.stopPropagation();
onViewRoomClick(false);
}
const onJoinClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
onViewRoomClick(true);
}
let button; let button;
if (myMembership === "join") { if (myMembership === "join") {
@ -136,7 +147,7 @@ const Tile: React.FC<ITileProps> = ({
let url: string; let url: string;
if (room.avatar_url) { if (room.avatar_url) {
url = mediaFromMxc(room.avatar_url).getSquareThumbnailHttp(Math.floor(20 * window.devicePixelRatio)); url = mediaFromMxc(room.avatar_url).getSquareThumbnailHttp(20);
} }
let description = _t("%(count)s members", { count: room.num_joined_members }); let description = _t("%(count)s members", { count: room.num_joined_members });
@ -254,7 +265,11 @@ export const HierarchyLevel = ({
const space = cli.getRoom(spaceId); const space = cli.getRoom(spaceId);
const hasPermissions = space?.currentState.maySendStateEvent(EventType.SpaceChild, cli.getUserId()); const hasPermissions = space?.currentState.maySendStateEvent(EventType.SpaceChild, cli.getUserId());
const sortedChildren = sortBy([...(relations.get(spaceId)?.values() || [])], ev => ev.content.order || null); const children = Array.from(relations.get(spaceId)?.values() || []);
const sortedChildren = sortBy(children, ev => {
// XXX: Space Summary API doesn't give the child origin_server_ts but once it does we should use it for sorting
return getOrder(ev.content.order, null, ev.state_key);
});
const [subspaces, childRooms] = sortedChildren.reduce((result, ev: ISpaceSummaryEvent) => { const [subspaces, childRooms] = sortedChildren.reduce((result, ev: ISpaceSummaryEvent) => {
const roomId = ev.state_key; const roomId = ev.state_key;
if (!rooms.has(roomId)) return result; if (!rooms.has(roomId)) return result;
@ -312,11 +327,12 @@ export const HierarchyLevel = ({
// mutate argument refreshToken to force a reload // mutate argument refreshToken to force a reload
export const useSpaceSummary = (cli: MatrixClient, space: Room, refreshToken?: any): [ export const useSpaceSummary = (cli: MatrixClient, space: Room, refreshToken?: any): [
null,
ISpaceSummaryRoom[], ISpaceSummaryRoom[],
Map<string, Map<string, ISpaceSummaryEvent>>, Map<string, Map<string, ISpaceSummaryEvent>>?,
Map<string, Set<string>>, Map<string, Set<string>>?,
Map<string, Set<string>>, Map<string, Set<string>>?,
] | [] => { ] | [Error] => {
// TODO pagination // TODO pagination
return useAsyncMemo(async () => { return useAsyncMemo(async () => {
try { try {
@ -336,13 +352,12 @@ export const useSpaceSummary = (cli: MatrixClient, space: Room, refreshToken?: a
} }
}); });
return [data.rooms as ISpaceSummaryRoom[], parentChildRelations, viaMap, childParentRelations]; return [null, data.rooms as ISpaceSummaryRoom[], parentChildRelations, viaMap, childParentRelations];
} catch (e) { } catch (e) {
console.error(e); // TODO console.error(e); // TODO
return [e];
} }
}, [space, refreshToken], [undefined]);
return [];
}, [space, refreshToken], []);
}; };
export const SpaceHierarchy: React.FC<IHierarchyProps> = ({ export const SpaceHierarchy: React.FC<IHierarchyProps> = ({
@ -350,6 +365,7 @@ export const SpaceHierarchy: React.FC<IHierarchyProps> = ({
initialText = "", initialText = "",
showRoom, showRoom,
refreshToken, refreshToken,
additionalButtons,
children, children,
}) => { }) => {
const cli = MatrixClientPeg.get(); const cli = MatrixClientPeg.get();
@ -358,7 +374,7 @@ export const SpaceHierarchy: React.FC<IHierarchyProps> = ({
const [selected, setSelected] = useState(new Map<string, Set<string>>()); // Map<parentId, Set<childId>> const [selected, setSelected] = useState(new Map<string, Set<string>>()); // Map<parentId, Set<childId>>
const [rooms, parentChildMap, viaMap, childParentMap] = useSpaceSummary(cli, space, refreshToken); const [summaryError, rooms, parentChildMap, viaMap, childParentMap] = useSpaceSummary(cli, space, refreshToken);
const roomsMap = useMemo(() => { const roomsMap = useMemo(() => {
if (!rooms) return null; if (!rooms) return null;
@ -397,6 +413,10 @@ export const SpaceHierarchy: React.FC<IHierarchyProps> = ({
const [removing, setRemoving] = useState(false); const [removing, setRemoving] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
if (summaryError) {
return <p>{_t("Your server does not support showing space hierarchies.")}</p>;
}
let content; let content;
if (roomsMap) { if (roomsMap) {
const numRooms = Array.from(roomsMap.values()).filter(r => r.room_type !== RoomType.Space).length; const numRooms = Array.from(roomsMap.values()).filter(r => r.room_type !== RoomType.Space).length;
@ -411,78 +431,83 @@ export const SpaceHierarchy: React.FC<IHierarchyProps> = ({
countsStr = _t("%(count)s rooms", { count: numRooms, numSpaces }); countsStr = _t("%(count)s rooms", { count: numRooms, numSpaces });
} }
let editSection; let manageButtons;
if (space.getMyMembership() === "join" && space.currentState.maySendStateEvent(EventType.SpaceChild, userId)) { if (space.getMyMembership() === "join" && space.currentState.maySendStateEvent(EventType.SpaceChild, userId)) {
const selectedRelations = Array.from(selected.keys()).flatMap(parentId => { const selectedRelations = Array.from(selected.keys()).flatMap(parentId => {
return [...selected.get(parentId).values()].map(childId => [parentId, childId]) as [string, string][]; return [...selected.get(parentId).values()].map(childId => [parentId, childId]) as [string, string][];
}); });
let buttons; const selectionAllSuggested = selectedRelations.every(([parentId, childId]) => {
if (selectedRelations.length) { return parentChildMap.get(parentId)?.get(childId)?.content.suggested;
const selectionAllSuggested = selectedRelations.every(([parentId, childId]) => { });
return parentChildMap.get(parentId)?.get(childId)?.content.suggested;
});
const disabled = removing || saving; const disabled = !selectedRelations.length || removing || saving;
buttons = <> let Button: React.ComponentType<React.ComponentProps<typeof AccessibleButton>> = AccessibleButton;
<AccessibleButton let props = {};
onClick={async () => { if (!selectedRelations.length) {
setRemoving(true); Button = AccessibleTooltipButton;
try { props = {
for (const [parentId, childId] of selectedRelations) { tooltip: _t("Select a room below first"),
await cli.sendStateEvent(parentId, EventType.SpaceChild, {}, childId); yOffset: -40,
parentChildMap.get(parentId).get(childId).content = {}; };
parentChildMap.set(parentId, new Map(parentChildMap.get(parentId)));
}
} catch (e) {
setError(_t("Failed to remove some rooms. Try again later"));
}
setRemoving(false);
}}
kind="danger_outline"
disabled={disabled}
>
{ removing ? _t("Removing...") : _t("Remove") }
</AccessibleButton>
<AccessibleButton
onClick={async () => {
setSaving(true);
try {
for (const [parentId, childId] of selectedRelations) {
const suggested = !selectionAllSuggested;
const existingContent = parentChildMap.get(parentId)?.get(childId)?.content;
if (!existingContent || existingContent.suggested === suggested) continue;
const content = {
...existingContent,
suggested: !selectionAllSuggested,
};
await cli.sendStateEvent(parentId, EventType.SpaceChild, content, childId);
parentChildMap.get(parentId).get(childId).content = content;
parentChildMap.set(parentId, new Map(parentChildMap.get(parentId)));
}
} catch (e) {
setError("Failed to update some suggestions. Try again later");
}
setSaving(false);
}}
kind="primary_outline"
disabled={disabled}
>
{ saving
? _t("Saving...")
: (selectionAllSuggested ? _t("Mark as not suggested") : _t("Mark as suggested"))
}
</AccessibleButton>
</>;
} }
editSection = <span> manageButtons = <>
{ buttons } <Button
</span>; {...props}
onClick={async () => {
setRemoving(true);
try {
for (const [parentId, childId] of selectedRelations) {
await cli.sendStateEvent(parentId, EventType.SpaceChild, {}, childId);
parentChildMap.get(parentId).get(childId).content = {};
parentChildMap.set(parentId, new Map(parentChildMap.get(parentId)));
}
} catch (e) {
setError(_t("Failed to remove some rooms. Try again later"));
}
setRemoving(false);
}}
kind="danger_outline"
disabled={disabled}
>
{ removing ? _t("Removing...") : _t("Remove") }
</Button>
<Button
{...props}
onClick={async () => {
setSaving(true);
try {
for (const [parentId, childId] of selectedRelations) {
const suggested = !selectionAllSuggested;
const existingContent = parentChildMap.get(parentId)?.get(childId)?.content;
if (!existingContent || existingContent.suggested === suggested) continue;
const content = {
...existingContent,
suggested: !selectionAllSuggested,
};
await cli.sendStateEvent(parentId, EventType.SpaceChild, content, childId);
parentChildMap.get(parentId).get(childId).content = content;
parentChildMap.set(parentId, new Map(parentChildMap.get(parentId)));
}
} catch (e) {
setError("Failed to update some suggestions. Try again later");
}
setSaving(false);
}}
kind="primary_outline"
disabled={disabled}
>
{ saving
? _t("Saving...")
: (selectionAllSuggested ? _t("Mark as not suggested") : _t("Mark as suggested"))
}
</Button>
</>;
} }
let results; let results;
@ -528,7 +553,10 @@ export const SpaceHierarchy: React.FC<IHierarchyProps> = ({
content = <> content = <>
<div className="mx_SpaceRoomDirectory_listHeader"> <div className="mx_SpaceRoomDirectory_listHeader">
{ countsStr } { countsStr }
{ editSection } <span>
{ additionalButtons }
{ manageButtons }
</span>
</div> </div>
{ error && <div className="mx_SpaceRoomDirectory_error"> { error && <div className="mx_SpaceRoomDirectory_error">
{ error } { error }
@ -538,10 +566,8 @@ export const SpaceHierarchy: React.FC<IHierarchyProps> = ({
{ children } { children }
</AutoHideScrollbar> </AutoHideScrollbar>
</>; </>;
} else if (!rooms) {
content = <Spinner />;
} else { } else {
content = <p>{_t("Your server does not support showing space hierarchies.")}</p>; content = <Spinner />;
} }
// TODO loading state/error state // TODO loading state/error state

View file

@ -51,6 +51,15 @@ import MemberAvatar from "../views/avatars/MemberAvatar";
import {useStateToggle} from "../../hooks/useStateToggle"; import {useStateToggle} from "../../hooks/useStateToggle";
import SpaceStore from "../../stores/SpaceStore"; import SpaceStore from "../../stores/SpaceStore";
import FacePile from "../views/elements/FacePile"; import FacePile from "../views/elements/FacePile";
import {AddExistingToSpace} from "../views/dialogs/AddExistingToSpaceDialog";
import {sleep} from "../../utils/promise";
import {calculateRoomVia} from "../../utils/permalinks/Permalinks";
import {ChevronFace, ContextMenuButton, useContextMenu} from "./ContextMenu";
import IconizedContextMenu, {
IconizedContextMenuOption,
IconizedContextMenuOptionList,
} from "../views/context_menus/IconizedContextMenu";
import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton";
interface IProps { interface IProps {
space: Room; space: Room;
@ -214,6 +223,67 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) =>
</div>; </div>;
}; };
const SpaceLandingAddButton = ({ space, onNewRoomAdded }) => {
const cli = useContext(MatrixClientContext);
const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu();
let contextMenu;
if (menuDisplayed) {
const rect = handle.current.getBoundingClientRect();
contextMenu = <IconizedContextMenu
left={rect.left + window.pageXOffset + 0}
top={rect.bottom + window.pageYOffset + 8}
chevronFace={ChevronFace.None}
onFinished={closeMenu}
className="mx_RoomTile_contextMenu"
compact
>
<IconizedContextMenuOptionList first>
<IconizedContextMenuOption
label={_t("Create new room")}
iconClassName="mx_RoomList_iconPlus"
onClick={async (e) => {
e.preventDefault();
e.stopPropagation();
closeMenu();
if (await showCreateNewRoom(cli, space)) {
onNewRoomAdded();
}
}}
/>
<IconizedContextMenuOption
label={_t("Add existing room")}
iconClassName="mx_RoomList_iconHash"
onClick={async (e) => {
e.preventDefault();
e.stopPropagation();
closeMenu();
const [added] = await showAddExistingRooms(cli, space);
if (added) {
onNewRoomAdded();
}
}}
/>
</IconizedContextMenuOptionList>
</IconizedContextMenu>;
}
return <>
<ContextMenuButton
kind="primary"
inputRef={handle}
onClick={openMenu}
isExpanded={menuDisplayed}
label={_t("Add")}
>
{ _t("Add") }
</ContextMenuButton>
{ contextMenu }
</>;
};
const SpaceLanding = ({ space }) => { const SpaceLanding = ({ space }) => {
const cli = useContext(MatrixClientContext); const cli = useContext(MatrixClientContext);
const myMembership = useMyRoomMembership(space); const myMembership = useMyRoomMembership(space);
@ -238,32 +308,20 @@ const SpaceLanding = ({ space }) => {
const [refreshToken, forceUpdate] = useStateToggle(false); const [refreshToken, forceUpdate] = useStateToggle(false);
let addRoomButtons; let addRoomButton;
if (canAddRooms) { if (canAddRooms) {
addRoomButtons = <React.Fragment> addRoomButton = <SpaceLandingAddButton space={space} onNewRoomAdded={forceUpdate} />;
<AccessibleButton className="mx_SpaceRoomView_landing_addButton" onClick={async () => {
const [added] = await showAddExistingRooms(cli, space);
if (added) {
forceUpdate();
}
}}>
{ _t("Add existing rooms & spaces") }
</AccessibleButton>
<AccessibleButton className="mx_SpaceRoomView_landing_createButton" onClick={() => {
showCreateNewRoom(cli, space);
}}>
{ _t("Create a new room") }
</AccessibleButton>
</React.Fragment>;
} }
let settingsButton; let settingsButton;
if (shouldShowSpaceSettings(cli, space)) { if (shouldShowSpaceSettings(cli, space)) {
settingsButton = <AccessibleButton className="mx_SpaceRoomView_landing_settingsButton" onClick={() => { settingsButton = <AccessibleTooltipButton
showSpaceSettings(cli, space); className="mx_SpaceRoomView_landing_settingsButton"
}}> onClick={() => {
{ _t("Settings") } showSpaceSettings(cli, space);
</AccessibleButton>; }}
title={_t("Settings")}
/>;
} }
const onMembersClick = () => { const onMembersClick = () => {
@ -290,17 +348,19 @@ const SpaceLanding = ({ space }) => {
<SpaceInfo space={space} /> <SpaceInfo space={space} />
<FacePile room={space} onlyKnownUsers={false} numShown={7} onClick={onMembersClick} /> <FacePile room={space} onlyKnownUsers={false} numShown={7} onClick={onMembersClick} />
{ inviteButton } { inviteButton }
{ settingsButton }
</div> </div>
<div className="mx_SpaceRoomView_landing_topic"> <div className="mx_SpaceRoomView_landing_topic">
<RoomTopic room={space} /> <RoomTopic room={space} />
</div> </div>
<hr /> <hr />
<div className="mx_SpaceRoomView_landing_adminButtons">
{ addRoomButtons }
{ settingsButton }
</div>
<SpaceHierarchy space={space} showRoom={showRoom} refreshToken={refreshToken} /> <SpaceHierarchy
space={space}
showRoom={showRoom}
refreshToken={refreshToken}
additionalButtons={addRoomButton}
/>
</div>; </div>;
}; };
@ -354,7 +414,7 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => {
let buttonLabel = _t("Skip for now"); let buttonLabel = _t("Skip for now");
if (roomNames.some(name => name.trim())) { if (roomNames.some(name => name.trim())) {
onClick = onNextClick; onClick = onNextClick;
buttonLabel = busy ? _t("Creating rooms...") : _t("Continue") buttonLabel = busy ? _t("Creating rooms...") : _t("Continue");
} }
return <div> return <div>
@ -376,6 +436,74 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => {
</div>; </div>;
}; };
const SpaceAddExistingRooms = ({ space, onFinished }) => {
const [selectedToAdd, setSelectedToAdd] = useState(new Set<Room>());
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
let onClick = onFinished;
let buttonLabel = _t("Skip for now");
if (selectedToAdd.size > 0) {
onClick = async () => {
setBusy(true);
for (const room of selectedToAdd) {
const via = calculateRoomVia(room);
try {
await SpaceStore.instance.addRoomToSpace(space, room.roomId, via).catch(async e => {
if (e.errcode === "M_LIMIT_EXCEEDED") {
await sleep(e.data.retry_after_ms);
return SpaceStore.instance.addRoomToSpace(space, room.roomId, via); // retry
}
throw e;
});
} catch (e) {
console.error("Failed to add rooms to space", e);
setError(_t("Failed to add rooms to space"));
break;
}
}
setBusy(false);
};
buttonLabel = busy ? _t("Adding...") : _t("Add");
}
return <div>
<h1>{ _t("What do you want to organise?") }</h1>
<div className="mx_SpaceRoomView_description">
{ _t("Pick rooms or conversations to add. This is just a space for you, " +
"no one will be informed. You can add more later.") }
</div>
{ error && <div className="mx_SpaceRoomView_errorText">{ error }</div> }
<AddExistingToSpace
space={space}
selected={selectedToAdd}
onChange={(checked, room) => {
if (checked) {
selectedToAdd.add(room);
} else {
selectedToAdd.delete(room);
}
setSelectedToAdd(new Set(selectedToAdd));
}}
/>
<div className="mx_SpaceRoomView_buttons">
<AccessibleButton
kind="primary"
disabled={busy}
onClick={onClick}
>
{ buttonLabel }
</AccessibleButton>
</div>
</div>;
};
const SpaceSetupPublicShare = ({ space, onFinished }) => { const SpaceSetupPublicShare = ({ space, onFinished }) => {
return <div className="mx_SpaceRoomView_publicShare"> return <div className="mx_SpaceRoomView_publicShare">
<h1>{ _t("Share %(name)s", { name: space.name }) }</h1> <h1>{ _t("Share %(name)s", { name: space.name }) }</h1>
@ -659,7 +787,7 @@ export default class SpaceRoomView extends React.PureComponent<IProps, IState> {
return <SpaceSetupPrivateScope return <SpaceSetupPrivateScope
space={this.props.space} space={this.props.space}
onFinished={(invite: boolean) => { onFinished={(invite: boolean) => {
this.setState({ phase: invite ? Phase.PrivateInvite : Phase.PrivateCreateRooms }); this.setState({ phase: invite ? Phase.PrivateInvite : Phase.PrivateExistingRooms });
}} }}
/>; />;
case Phase.PrivateInvite: case Phase.PrivateInvite:
@ -675,6 +803,11 @@ export default class SpaceRoomView extends React.PureComponent<IProps, IState> {
"You can add more later too, including already existing ones.")} "You can add more later too, including already existing ones.")}
onFinished={() => this.setState({ phase: Phase.Landing })} onFinished={() => this.setState({ phase: Phase.Landing })}
/>; />;
case Phase.PrivateExistingRooms:
return <SpaceAddExistingRooms
space={this.props.space}
onFinished={() => this.setState({ phase: Phase.Landing })}
/>;
} }
} }

View file

@ -68,6 +68,7 @@ class TimelinePanel extends React.Component {
showReadReceipts: PropTypes.bool, showReadReceipts: PropTypes.bool,
// Enable managing RRs and RMs. These require the timelineSet to have a room. // Enable managing RRs and RMs. These require the timelineSet to have a room.
manageReadReceipts: PropTypes.bool, manageReadReceipts: PropTypes.bool,
sendReadReceiptOnLoad: PropTypes.bool,
manageReadMarkers: PropTypes.bool, manageReadMarkers: PropTypes.bool,
// true to give the component a 'display: none' style. // true to give the component a 'display: none' style.
@ -126,6 +127,7 @@ class TimelinePanel extends React.Component {
// event tile heights. (See _unpaginateEvents) // event tile heights. (See _unpaginateEvents)
timelineCap: Number.MAX_VALUE, timelineCap: Number.MAX_VALUE,
className: 'mx_RoomView_messagePanel', className: 'mx_RoomView_messagePanel',
sendReadReceiptOnLoad: true,
}; };
constructor(props) { constructor(props) {
@ -785,8 +787,10 @@ class TimelinePanel extends React.Component {
return; return;
} }
const lastDisplayedEvent = this.state.events[lastDisplayedIndex]; const lastDisplayedEvent = this.state.events[lastDisplayedIndex];
this._setReadMarker(lastDisplayedEvent.getId(), this._setReadMarker(
lastDisplayedEvent.getTs()); lastDisplayedEvent.getId(),
lastDisplayedEvent.getTs(),
);
// the read-marker should become invisible, so that if the user scrolls // the read-marker should become invisible, so that if the user scrolls
// down, they don't see it. // down, they don't see it.
@ -872,7 +876,7 @@ class TimelinePanel extends React.Component {
// The messagepanel knows where the RM is, so we must have loaded // The messagepanel knows where the RM is, so we must have loaded
// the relevant event. // the relevant event.
this._messagePanel.current.scrollToEvent(this.state.readMarkerEventId, this._messagePanel.current.scrollToEvent(this.state.readMarkerEventId,
0, 1/3); 0, 1/3);
return; return;
} }
@ -1044,12 +1048,14 @@ class TimelinePanel extends React.Component {
} }
if (eventId) { if (eventId) {
this._messagePanel.current.scrollToEvent(eventId, pixelOffset, this._messagePanel.current.scrollToEvent(eventId, pixelOffset,
offsetBase); offsetBase);
} else { } else {
this._messagePanel.current.scrollToBottom(); this._messagePanel.current.scrollToBottom();
} }
this.sendReadReceipt(); if (this.props.sendReadReceiptOnLoad) {
this.sendReadReceipt();
}
}); });
}; };
@ -1418,8 +1424,8 @@ class TimelinePanel extends React.Component {
['PREPARED', 'CATCHUP'].includes(this.state.clientSyncState) ['PREPARED', 'CATCHUP'].includes(this.state.clientSyncState)
); );
const events = this.state.firstVisibleEventIndex const events = this.state.firstVisibleEventIndex
? this.state.events.slice(this.state.firstVisibleEventIndex) ? this.state.events.slice(this.state.firstVisibleEventIndex)
: this.state.events; : this.state.events;
return ( return (
<MessagePanel <MessagePanel
ref={this._messagePanel} ref={this._messagePanel}

View file

@ -74,6 +74,7 @@ interface IState {
export default class UserMenu extends React.Component<IProps, IState> { export default class UserMenu extends React.Component<IProps, IState> {
private dispatcherRef: string; private dispatcherRef: string;
private themeWatcherRef: string; private themeWatcherRef: string;
private dndWatcherRef: string;
private buttonRef: React.RefObject<HTMLButtonElement> = createRef(); private buttonRef: React.RefObject<HTMLButtonElement> = createRef();
private tagStoreRef: fbEmitter.EventSubscription; private tagStoreRef: fbEmitter.EventSubscription;
@ -89,6 +90,9 @@ export default class UserMenu extends React.Component<IProps, IState> {
if (SettingsStore.getValue("feature_spaces")) { if (SettingsStore.getValue("feature_spaces")) {
SpaceStore.instance.on(UPDATE_SELECTED_SPACE, this.onSelectedSpaceUpdate); SpaceStore.instance.on(UPDATE_SELECTED_SPACE, this.onSelectedSpaceUpdate);
} }
// Force update is the easiest way to trigger the UI update (we don't store state for this)
this.dndWatcherRef = SettingsStore.watchSetting("doNotDisturb", null, () => this.forceUpdate());
} }
private get hasHomePage(): boolean { private get hasHomePage(): boolean {
@ -103,6 +107,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
public componentWillUnmount() { public componentWillUnmount() {
if (this.themeWatcherRef) SettingsStore.unwatchSetting(this.themeWatcherRef); if (this.themeWatcherRef) SettingsStore.unwatchSetting(this.themeWatcherRef);
if (this.dndWatcherRef) SettingsStore.unwatchSetting(this.dndWatcherRef);
if (this.dispatcherRef) defaultDispatcher.unregister(this.dispatcherRef); if (this.dispatcherRef) defaultDispatcher.unregister(this.dispatcherRef);
OwnProfileStore.instance.off(UPDATE_EVENT, this.onProfileUpdate); OwnProfileStore.instance.off(UPDATE_EVENT, this.onProfileUpdate);
this.tagStoreRef.remove(); this.tagStoreRef.remove();
@ -288,6 +293,12 @@ export default class UserMenu extends React.Component<IProps, IState> {
this.setState({contextMenuPosition: null}); // also close the menu this.setState({contextMenuPosition: null}); // also close the menu
}; };
private onDndToggle = (ev) => {
ev.stopPropagation();
const current = SettingsStore.getValue("doNotDisturb");
SettingsStore.setValue("doNotDisturb", null, SettingLevel.DEVICE, !current);
};
private renderContextMenu = (): React.ReactNode => { private renderContextMenu = (): React.ReactNode => {
if (!this.state.contextMenuPosition) return null; if (!this.state.contextMenuPosition) return null;
@ -534,6 +545,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
{/* masked image in CSS */} {/* masked image in CSS */}
</span> </span>
); );
let dnd;
if (this.state.selectedSpace) { if (this.state.selectedSpace) {
name = ( name = (
<div className="mx_UserMenu_doubleName"> <div className="mx_UserMenu_doubleName">
@ -560,6 +572,16 @@ export default class UserMenu extends React.Component<IProps, IState> {
</div> </div>
); );
isPrototype = true; isPrototype = true;
} else if (SettingsStore.getValue("feature_dnd")) {
const isDnd = SettingsStore.getValue("doNotDisturb");
dnd = <AccessibleButton
onClick={this.onDndToggle}
className={classNames({
"mx_UserMenu_dnd": true,
"mx_UserMenu_dnd_noisy": !isDnd,
"mx_UserMenu_dnd_muted": isDnd,
})}
/>;
} }
if (this.props.isMinimized) { if (this.props.isMinimized) {
name = null; name = null;
@ -595,6 +617,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
/> />
</span> </span>
{name} {name}
{dnd}
{buttons} {buttons}
</div> </div>
</ContextMenuButton> </ContextMenuButton>

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2015, 2016, 2017, 2018, 2019 The Matrix.org Foundation C.I.C. Copyright 2015-2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -94,7 +94,7 @@ interface IState {
// be seeing. // be seeing.
serverIsAlive: boolean; serverIsAlive: boolean;
serverErrorIsFatal: boolean; serverErrorIsFatal: boolean;
serverDeadError: string; serverDeadError?: ReactNode;
} }
/* /*

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2015, 2016, 2017, 2018, 2019, 2020 The Matrix.org Foundation C.I.C. Copyright 2015-2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -95,7 +95,7 @@ interface IState {
// be seeing. // be seeing.
serverIsAlive: boolean; serverIsAlive: boolean;
serverErrorIsFatal: boolean; serverErrorIsFatal: boolean;
serverDeadError: string; serverDeadError?: ReactNode;
// Our matrix client - part of state because we can't render the UI auth // Our matrix client - part of state because we can't render the UI auth
// component without it. // component without it.

View file

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

View file

@ -169,7 +169,7 @@ export class PasswordAuthEntry extends React.Component {
{ submitButtonOrSpinner } { submitButtonOrSpinner }
</div> </div>
</form> </form>
{ errorSection } { errorSection }
</div> </div>
); );
} }
@ -375,7 +375,7 @@ export class TermsAuthEntry extends React.Component {
if (this.props.showContinue !== false) { if (this.props.showContinue !== false) {
// XXX: button classes // XXX: button classes
submitButton = <button className="mx_InteractiveAuthEntryComponents_termsSubmit mx_GeneralButton" submitButton = <button className="mx_InteractiveAuthEntryComponents_termsSubmit mx_GeneralButton"
onClick={this._trySubmit} disabled={!allChecked}>{_t("Accept")}</button>; onClick={this._trySubmit} disabled={!allChecked}>{_t("Accept")}</button>;
} }
return ( return (

View file

@ -68,8 +68,8 @@ export default class MemberAvatar extends React.Component<IProps, IState> {
let imageUrl = null; let imageUrl = null;
if (props.member.getMxcAvatarUrl()) { if (props.member.getMxcAvatarUrl()) {
imageUrl = mediaFromMxc(props.member.getMxcAvatarUrl()).getThumbnailOfSourceHttp( imageUrl = mediaFromMxc(props.member.getMxcAvatarUrl()).getThumbnailOfSourceHttp(
Math.floor(props.width * window.devicePixelRatio), props.width,
Math.floor(props.height * window.devicePixelRatio), props.height,
props.resizeMethod, props.resizeMethod,
); );
} }

View file

@ -93,8 +93,8 @@ export default class RoomAvatar extends React.Component<IProps, IState> {
let oobAvatar = null; let oobAvatar = null;
if (props.oobData.avatarUrl) { if (props.oobData.avatarUrl) {
oobAvatar = mediaFromMxc(props.oobData.avatarUrl).getThumbnailOfSourceHttp( oobAvatar = mediaFromMxc(props.oobData.avatarUrl).getThumbnailOfSourceHttp(
Math.floor(props.width * window.devicePixelRatio), props.width,
Math.floor(props.height * window.devicePixelRatio), props.height,
props.resizeMethod, props.resizeMethod,
); );
} }
@ -109,12 +109,7 @@ export default class RoomAvatar extends React.Component<IProps, IState> {
private static getRoomAvatarUrl(props: IProps): string { private static getRoomAvatarUrl(props: IProps): string {
if (!props.room) return null; if (!props.room) return null;
return Avatar.avatarUrlForRoom( return Avatar.avatarUrlForRoom(props.room, props.width, props.height, props.resizeMethod);
props.room,
Math.floor(props.width * window.devicePixelRatio),
Math.floor(props.height * window.devicePixelRatio),
props.resizeMethod,
);
} }
private onRoomAvatarClick = () => { private onRoomAvatarClick = () => {

View file

@ -1,8 +1,6 @@
/* /*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2018 New Vector Ltd
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
Copyright 2019 The Matrix.org Foundation C.I.C. Copyright 2015, 2016, 2018, 2019, 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -34,7 +32,7 @@ import {MenuItem} from "../../structures/ContextMenu";
import {EventType} from "matrix-js-sdk/src/@types/event"; import {EventType} from "matrix-js-sdk/src/@types/event";
import {replaceableComponent} from "../../../utils/replaceableComponent"; import {replaceableComponent} from "../../../utils/replaceableComponent";
function canCancel(eventStatus) { export function canCancel(eventStatus) {
return eventStatus === EventStatus.QUEUED || eventStatus === EventStatus.NOT_SENT; return eventStatus === EventStatus.QUEUED || eventStatus === EventStatus.NOT_SENT;
} }
@ -80,8 +78,10 @@ export default class MessageContextMenu extends React.Component {
// We explicitly decline to show the redact option on ACL events as it has a potential // We explicitly decline to show the redact option on ACL events as it has a potential
// to obliterate the room - https://github.com/matrix-org/synapse/issues/4042 // to obliterate the room - https://github.com/matrix-org/synapse/issues/4042
// Similarly for encryption events, since redacting them "breaks everything"
const canRedact = room.currentState.maySendRedactionForEvent(this.props.mxEvent, cli.credentials.userId) const canRedact = room.currentState.maySendRedactionForEvent(this.props.mxEvent, cli.credentials.userId)
&& this.props.mxEvent.getType() !== EventType.RoomServerAcl; && this.props.mxEvent.getType() !== EventType.RoomServerAcl
&& this.props.mxEvent.getType() !== EventType.RoomEncryption;
let canPin = room.currentState.mayClientSendStateEvent('m.room.pinned_events', cli); let canPin = room.currentState.mayClientSendStateEvent('m.room.pinned_events', cli);
// HACK: Intentionally say we can't pin if the user doesn't want to use the functionality // HACK: Intentionally say we can't pin if the user doesn't want to use the functionality
@ -98,21 +98,6 @@ export default class MessageContextMenu extends React.Component {
return content.pinned && Array.isArray(content.pinned) && content.pinned.includes(this.props.mxEvent.getId()); return content.pinned && Array.isArray(content.pinned) && content.pinned.includes(this.props.mxEvent.getId());
} }
onResendClick = () => {
Resend.resend(this.props.mxEvent);
this.closeMenu();
};
onResendEditClick = () => {
Resend.resend(this.props.mxEvent.replacingEvent());
this.closeMenu();
};
onResendRedactionClick = () => {
Resend.resend(this.props.mxEvent.localRedactionEvent());
this.closeMenu();
};
onResendReactionsClick = () => { onResendReactionsClick = () => {
for (const reaction of this._getUnsentReactions()) { for (const reaction of this._getUnsentReactions()) {
Resend.resend(reaction); Resend.resend(reaction);
@ -170,29 +155,6 @@ export default class MessageContextMenu extends React.Component {
this.closeMenu(); this.closeMenu();
}; };
onCancelSendClick = () => {
const mxEvent = this.props.mxEvent;
const editEvent = mxEvent.replacingEvent();
const redactEvent = mxEvent.localRedactionEvent();
const pendingReactions = this._getPendingReactions();
if (editEvent && canCancel(editEvent.status)) {
Resend.removeFromQueue(editEvent);
}
if (redactEvent && canCancel(redactEvent.status)) {
Resend.removeFromQueue(redactEvent);
}
if (pendingReactions.length) {
for (const reaction of pendingReactions) {
Resend.removeFromQueue(reaction);
}
}
if (canCancel(mxEvent.status)) {
Resend.removeFromQueue(this.props.mxEvent);
}
this.closeMenu();
};
onForwardClick = () => { onForwardClick = () => {
if (this.props.onCloseDialog) this.props.onCloseDialog(); if (this.props.onCloseDialog) this.props.onCloseDialog();
dis.dispatch({ dis.dispatch({
@ -285,20 +247,9 @@ export default class MessageContextMenu extends React.Component {
const me = cli.getUserId(); const me = cli.getUserId();
const mxEvent = this.props.mxEvent; const mxEvent = this.props.mxEvent;
const eventStatus = mxEvent.status; const eventStatus = mxEvent.status;
const editStatus = mxEvent.replacingEvent() && mxEvent.replacingEvent().status;
const redactStatus = mxEvent.localRedactionEvent() && mxEvent.localRedactionEvent().status;
const unsentReactionsCount = this._getUnsentReactions().length; const unsentReactionsCount = this._getUnsentReactions().length;
const pendingReactionsCount = this._getPendingReactions().length;
const allowCancel = canCancel(mxEvent.status) ||
canCancel(editStatus) ||
canCancel(redactStatus) ||
pendingReactionsCount !== 0;
let resendButton;
let resendEditButton;
let resendReactionsButton; let resendReactionsButton;
let resendRedactionButton;
let redactButton; let redactButton;
let cancelButton;
let forwardButton; let forwardButton;
let pinButton; let pinButton;
let unhidePreviewButton; let unhidePreviewButton;
@ -309,22 +260,6 @@ export default class MessageContextMenu extends React.Component {
// status is SENT before remote-echo, null after // status is SENT before remote-echo, null after
const isSent = !eventStatus || eventStatus === EventStatus.SENT; const isSent = !eventStatus || eventStatus === EventStatus.SENT;
if (!mxEvent.isRedacted()) { if (!mxEvent.isRedacted()) {
if (eventStatus === EventStatus.NOT_SENT) {
resendButton = (
<MenuItem className="mx_MessageContextMenu_field" onClick={this.onResendClick}>
{ _t('Resend') }
</MenuItem>
);
}
if (editStatus === EventStatus.NOT_SENT) {
resendEditButton = (
<MenuItem className="mx_MessageContextMenu_field" onClick={this.onResendEditClick}>
{ _t('Resend edit') }
</MenuItem>
);
}
if (unsentReactionsCount !== 0) { if (unsentReactionsCount !== 0) {
resendReactionsButton = ( resendReactionsButton = (
<MenuItem className="mx_MessageContextMenu_field" onClick={this.onResendReactionsClick}> <MenuItem className="mx_MessageContextMenu_field" onClick={this.onResendReactionsClick}>
@ -334,14 +269,6 @@ export default class MessageContextMenu extends React.Component {
} }
} }
if (redactStatus === EventStatus.NOT_SENT) {
resendRedactionButton = (
<MenuItem className="mx_MessageContextMenu_field" onClick={this.onResendRedactionClick}>
{ _t('Resend removal') }
</MenuItem>
);
}
if (isSent && this.state.canRedact) { if (isSent && this.state.canRedact) {
redactButton = ( redactButton = (
<MenuItem className="mx_MessageContextMenu_field" onClick={this.onRedactClick}> <MenuItem className="mx_MessageContextMenu_field" onClick={this.onRedactClick}>
@ -350,14 +277,6 @@ export default class MessageContextMenu extends React.Component {
); );
} }
if (allowCancel) {
cancelButton = (
<MenuItem className="mx_MessageContextMenu_field" onClick={this.onCancelSendClick}>
{ _t('Cancel Sending') }
</MenuItem>
);
}
if (isContentActionable(mxEvent)) { if (isContentActionable(mxEvent)) {
forwardButton = ( forwardButton = (
<MenuItem className="mx_MessageContextMenu_field" onClick={this.onForwardClick}> <MenuItem className="mx_MessageContextMenu_field" onClick={this.onForwardClick}>
@ -433,7 +352,7 @@ export default class MessageContextMenu extends React.Component {
> >
{ _t('Source URL') } { _t('Source URL') }
</MenuItem> </MenuItem>
); );
} }
if (this.props.collapseReplyThread) { if (this.props.collapseReplyThread) {
@ -455,12 +374,8 @@ export default class MessageContextMenu extends React.Component {
return ( return (
<div className="mx_MessageContextMenu"> <div className="mx_MessageContextMenu">
{ resendButton }
{ resendEditButton }
{ resendReactionsButton } { resendReactionsButton }
{ resendRedactionButton }
{ redactButton } { redactButton }
{ cancelButton }
{ forwardButton } { forwardButton }
{ pinButton } { pinButton }
{ viewSourceButton } { viewSourceButton }

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React, {useState} from "react"; import React, {useContext, useMemo, useState} from "react";
import classNames from "classnames"; import classNames from "classnames";
import {Room} from "matrix-js-sdk/src/models/room"; import {Room} from "matrix-js-sdk/src/models/room";
import {MatrixClient} from "matrix-js-sdk/src/client"; import {MatrixClient} from "matrix-js-sdk/src/client";
@ -29,10 +29,13 @@ import RoomAvatar from "../avatars/RoomAvatar";
import {getDisplayAliasForRoom} from "../../../Rooms"; import {getDisplayAliasForRoom} from "../../../Rooms";
import AccessibleButton from "../elements/AccessibleButton"; import AccessibleButton from "../elements/AccessibleButton";
import AutoHideScrollbar from "../../structures/AutoHideScrollbar"; import AutoHideScrollbar from "../../structures/AutoHideScrollbar";
import {allSettled} from "../../../utils/promise"; import {sleep} from "../../../utils/promise";
import DMRoomMap from "../../../utils/DMRoomMap"; import DMRoomMap from "../../../utils/DMRoomMap";
import {calculateRoomVia} from "../../../utils/permalinks/Permalinks"; import {calculateRoomVia} from "../../../utils/permalinks/Permalinks";
import StyledCheckbox from "../elements/StyledCheckbox"; import StyledCheckbox from "../elements/StyledCheckbox";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import {sortRooms} from "../../../stores/room-list/algorithms/tag-sorting/RecentAlgorithm";
import ProgressBar from "../elements/ProgressBar";
interface IProps extends IDialogProps { interface IProps extends IDialogProps {
matrixClient: MatrixClient; matrixClient: MatrixClient;
@ -41,43 +44,128 @@ interface IProps extends IDialogProps {
} }
const Entry = ({ room, checked, onChange }) => { const Entry = ({ room, checked, onChange }) => {
return <div className="mx_AddExistingToSpaceDialog_entry"> return <label className="mx_AddExistingToSpace_entry">
<RoomAvatar room={room} height={32} width={32} /> <RoomAvatar room={room} height={32} width={32} />
<span className="mx_AddExistingToSpaceDialog_entry_name">{ room.name }</span> <span className="mx_AddExistingToSpace_entry_name">{ room.name }</span>
<StyledCheckbox onChange={(e) => onChange(e.target.checked)} checked={checked} /> <StyledCheckbox
onChange={onChange ? (e) => onChange(e.target.checked) : null}
checked={checked}
disabled={!onChange}
/>
</label>;
};
interface IAddExistingToSpaceProps {
space: Room;
selected: Set<Room>;
onChange(checked: boolean, room: Room): void;
}
export const AddExistingToSpace: React.FC<IAddExistingToSpaceProps> = ({ space, selected, onChange }) => {
const cli = useContext(MatrixClientContext);
const visibleRooms = useMemo(() => sortRooms(cli.getVisibleRooms()), [cli]);
const [query, setQuery] = useState("");
const lcQuery = query.toLowerCase();
const existingSubspaces = SpaceStore.instance.getChildSpaces(space.roomId);
const existingSubspacesSet = new Set(existingSubspaces);
const existingRoomsSet = new Set(SpaceStore.instance.getChildRooms(space.roomId));
const joinRule = space.getJoinRule();
const [spaces, rooms, dms] = visibleRooms.reduce((arr, room) => {
if (room.getMyMembership() !== "join") return arr;
if (!room.name.toLowerCase().includes(lcQuery)) return arr;
if (room.isSpaceRoom()) {
if (room !== space && !existingSubspacesSet.has(room)) {
arr[0].push(room);
}
} else if (!existingRoomsSet.has(room)) {
if (!DMRoomMap.shared().getUserIdForRoomId(room.roomId)) {
arr[1].push(room);
} else if (joinRule !== "public") {
// Only show DMs for non-public spaces as they make very little sense in spaces other than "Just Me" ones.
arr[2].push(room);
}
}
return arr;
}, [[], [], []]);
return <div className="mx_AddExistingToSpace">
<SearchBox
className="mx_textinput_icon mx_textinput_search"
placeholder={ _t("Filter your rooms and spaces") }
onSearch={setQuery}
autoComplete={true}
autoFocus={true}
/>
<AutoHideScrollbar className="mx_AddExistingToSpace_content" id="mx_AddExistingToSpace">
{ rooms.length > 0 ? (
<div className="mx_AddExistingToSpace_section">
<h3>{ _t("Rooms") }</h3>
{ rooms.map(room => {
return <Entry
key={room.roomId}
room={room}
checked={selected.has(room)}
onChange={onChange ? (checked) => {
onChange(checked, room);
} : null}
/>;
}) }
</div>
) : undefined }
{ spaces.length > 0 ? (
<div className="mx_AddExistingToSpace_section mx_AddExistingToSpace_section_spaces">
<h3>{ _t("Spaces") }</h3>
{ spaces.map(space => {
return <Entry
key={space.roomId}
room={space}
checked={selected.has(space)}
onChange={onChange ? (checked) => {
onChange(checked, space);
} : null}
/>;
}) }
</div>
) : null }
{ dms.length > 0 ? (
<div className="mx_AddExistingToSpace_section">
<h3>{ _t("Direct Messages") }</h3>
{ dms.map(room => {
return <Entry
key={room.roomId}
room={room}
checked={selected.has(room)}
onChange={onChange ? (checked) => {
onChange(checked, room);
} : null}
/>;
}) }
</div>
) : null }
{ spaces.length + rooms.length + dms.length < 1 ? <span className="mx_AddExistingToSpace_noResults">
{ _t("No results") }
</span> : undefined }
</AutoHideScrollbar>
</div>; </div>;
}; };
const AddExistingToSpaceDialog: React.FC<IProps> = ({ matrixClient: cli, space, onCreateRoomClick, onFinished }) => { const AddExistingToSpaceDialog: React.FC<IProps> = ({ matrixClient: cli, space, onCreateRoomClick, onFinished }) => {
const [query, setQuery] = useState("");
const lcQuery = query.toLowerCase();
const [selectedSpace, setSelectedSpace] = useState(space); const [selectedSpace, setSelectedSpace] = useState(space);
const existingSubspaces = SpaceStore.instance.getChildSpaces(space.roomId);
const [selectedToAdd, setSelectedToAdd] = useState(new Set<Room>()); const [selectedToAdd, setSelectedToAdd] = useState(new Set<Room>());
const existingSubspaces = SpaceStore.instance.getChildSpaces(space.roomId); const [progress, setProgress] = useState<number>(null);
const existingSubspacesSet = new Set(existingSubspaces); const [error, setError] = useState<Error>(null);
const spaces = SpaceStore.instance.getSpaces().filter(s => {
return !existingSubspacesSet.has(s) // not already in space
&& space !== s // not the top-level space
&& selectedSpace !== s // not the selected space
&& s.name.toLowerCase().includes(lcQuery); // contains query
});
const existingRooms = SpaceStore.instance.getChildRooms(space.roomId);
const existingRoomsSet = new Set(existingRooms);
const rooms = cli.getVisibleRooms().filter(room => {
return !existingRoomsSet.has(room) // not already in space
&& !room.isSpaceRoom() // not a space itself
&& room.name.toLowerCase().includes(lcQuery) // contains query
&& !DMRoomMap.shared().getUserIdForRoomId(room.roomId); // not a DM
});
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
let spaceOptionSection; let spaceOptionSection;
if (existingSubspacesSet.size > 0) { if (existingSubspaces.length > 0) {
const options = [space, ...existingSubspaces].map((space) => { const options = [space, ...existingSubspaces].map((space) => {
const classes = classNames("mx_AddExistingToSpaceDialog_dropdownOption", { const classes = classNames("mx_AddExistingToSpaceDialog_dropdownOption", {
mx_AddExistingToSpaceDialog_dropdownOptionActive: space === selectedSpace, mx_AddExistingToSpaceDialog_dropdownOptionActive: space === selectedSpace,
@ -114,95 +202,106 @@ const AddExistingToSpaceDialog: React.FC<IProps> = ({ matrixClient: cli, space,
</div> </div>
</React.Fragment>; </React.Fragment>;
return <BaseDialog const addRooms = async () => {
title={title} setError(null);
className="mx_AddExistingToSpaceDialog" setProgress(0);
contentId="mx_AddExistingToSpaceDialog"
onFinished={onFinished}
fixedWidth={false}
>
{ error && <div className="mx_AddExistingToSpaceDialog_errorText">{ error }</div> }
<SearchBox let error;
className="mx_textinput_icon mx_textinput_search"
placeholder={ _t("Filter your rooms and spaces") }
onSearch={setQuery}
autoComplete={true}
/>
<AutoHideScrollbar className="mx_AddExistingToSpaceDialog_content" id="mx_AddExistingToSpaceDialog">
{ rooms.length > 0 ? (
<div className="mx_AddExistingToSpaceDialog_section">
<h3>{ _t("Rooms") }</h3>
{ rooms.map(room => {
return <Entry
key={room.roomId}
room={room}
checked={selectedToAdd.has(room)}
onChange={(checked) => {
if (checked) {
selectedToAdd.add(room);
} else {
selectedToAdd.delete(room);
}
setSelectedToAdd(new Set(selectedToAdd));
}}
/>;
}) }
</div>
) : undefined }
{ spaces.length > 0 ? ( for (const room of selectedToAdd) {
<div className="mx_AddExistingToSpaceDialog_section mx_AddExistingToSpaceDialog_section_spaces"> const via = calculateRoomVia(room);
<h3>{ _t("Spaces") }</h3> try {
{ spaces.map(space => { await SpaceStore.instance.addRoomToSpace(space, room.roomId, via).catch(async e => {
return <Entry if (e.errcode === "M_LIMIT_EXCEEDED") {
key={space.roomId} await sleep(e.data.retry_after_ms);
room={space} return SpaceStore.instance.addRoomToSpace(space, room.roomId, via); // retry
checked={selectedToAdd.has(space)} }
onChange={(checked) => {
if (checked) {
selectedToAdd.add(space);
} else {
selectedToAdd.delete(space);
}
setSelectedToAdd(new Set(selectedToAdd));
}}
/>;
}) }
</div>
) : null }
{ spaces.length + rooms.length < 1 ? <span className="mx_AddExistingToSpaceDialog_noResults"> throw e;
{ _t("No results") } });
</span> : undefined } setProgress(i => i + 1);
</AutoHideScrollbar> } catch (e) {
console.error("Failed to add rooms to space", e);
setError(error = e);
break;
}
}
<div className="mx_AddExistingToSpaceDialog_footer"> if (!error) {
onFinished(true);
}
};
const busy = progress !== null;
let footer;
if (error) {
footer = <>
<img
src={require("../../../../res/img/element-icons/warning-badge.svg")}
height="24"
width="24"
alt=""
/>
<span className="mx_AddExistingToSpaceDialog_error">
<div className="mx_AddExistingToSpaceDialog_errorHeading">{ _t("Not all selected were added") }</div>
<div className="mx_AddExistingToSpaceDialog_errorCaption">{ _t("Try again") }</div>
</span>
<AccessibleButton className="mx_AddExistingToSpaceDialog_retryButton" onClick={addRooms}>
{ _t("Retry") }
</AccessibleButton>
</>;
} else if (busy) {
footer = <span>
<ProgressBar value={progress} max={selectedToAdd.size} />
<div className="mx_AddExistingToSpaceDialog_progressText">
{ _t("Adding rooms... (%(progress)s out of %(count)s)", {
count: selectedToAdd.size,
progress,
}) }
</div>
</span>;
} else {
footer = <>
<span> <span>
<div>{ _t("Don't want to add an existing room?") }</div> <div>{ _t("Want to add a new room instead?") }</div>
<AccessibleButton onClick={() => onCreateRoomClick(cli, space)} kind="link"> <AccessibleButton onClick={() => onCreateRoomClick(cli, space)} kind="link">
{ _t("Create a new room") } { _t("Create a new room") }
</AccessibleButton> </AccessibleButton>
</span> </span>
<AccessibleButton <AccessibleButton kind="primary" disabled={selectedToAdd.size < 1} onClick={addRooms}>
kind="primary" { _t("Add") }
disabled={busy || selectedToAdd.size < 1}
onClick={async () => {
setBusy(true);
try {
await allSettled(Array.from(selectedToAdd).map((room) =>
SpaceStore.instance.addRoomToSpace(space, room.roomId, calculateRoomVia(room))));
onFinished(true);
} catch (e) {
console.error("Failed to add rooms to space", e);
setError(_t("Failed to add rooms to space"));
}
setBusy(false);
}}
>
{ busy ? _t("Adding...") : _t("Add") }
</AccessibleButton> </AccessibleButton>
</>;
}
return <BaseDialog
title={title}
className="mx_AddExistingToSpaceDialog"
contentId="mx_AddExistingToSpace"
onFinished={onFinished}
fixedWidth={false}
>
<MatrixClientContext.Provider value={cli}>
<AddExistingToSpace
space={space}
selected={selectedToAdd}
onChange={!busy && !error ? (checked, room) => {
if (checked) {
selectedToAdd.add(room);
} else {
selectedToAdd.delete(room);
}
setSelectedToAdd(new Set(selectedToAdd));
} : null}
/>
</MatrixClientContext.Provider>
<div className="mx_AddExistingToSpaceDialog_footer">
{ footer }
</div> </div>
</BaseDialog>; </BaseDialog>;
}; };

View file

@ -184,7 +184,7 @@ export default class BugReportDialog extends React.Component {
return ( return (
<BaseDialog className="mx_BugReportDialog" onFinished={this._onCancel} <BaseDialog className="mx_BugReportDialog" onFinished={this._onCancel}
title={_t('Submit debug logs')} title={_t('Submit debug logs')}
contentId='mx_Dialog_content' contentId='mx_Dialog_content'
> >
<div className="mx_Dialog_content" id='mx_Dialog_content'> <div className="mx_Dialog_content" id='mx_Dialog_content'>

View file

@ -95,7 +95,7 @@ export default class ChangelogDialog extends React.Component {
description={content} description={content}
button={_t("Update")} button={_t("Update")}
onFinished={this.props.onFinished} onFinished={this.props.onFinished}
/> />
); );
} }
} }

View file

@ -39,9 +39,12 @@ export default class ConfirmWipeDeviceDialog extends React.Component {
const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
return ( return (
<BaseDialog className='mx_ConfirmWipeDeviceDialog' hasCancel={true} <BaseDialog
onFinished={this.props.onFinished} className='mx_ConfirmWipeDeviceDialog'
title={_t("Clear all data in this session?")}> hasCancel={true}
onFinished={this.props.onFinished}
title={_t("Clear all data in this session?")}
>
<div className='mx_ConfirmWipeDeviceDialog_content'> <div className='mx_ConfirmWipeDeviceDialog_content'>
<p> <p>
{_t( {_t(

View file

@ -70,8 +70,16 @@ class GenericEditor extends React.PureComponent {
} }
textInput(id, label) { textInput(id, label) {
return <Field id={id} label={label} size="42" autoFocus={true} type="text" autoComplete="on" return <Field
value={this.state[id]} onChange={this._onChange} />; id={id}
label={label}
size="42"
autoFocus={true}
type="text"
autoComplete="on"
value={this.state[id]}
onChange={this._onChange}
/>;
} }
} }
@ -155,7 +163,7 @@ export class SendCustomEvent extends GenericEditor {
<br /> <br />
<Field id="evContent" label={_t("Event Content")} type="text" className="mx_DevTools_textarea" <Field id="evContent" label={_t("Event Content")} type="text" className="mx_DevTools_textarea"
autoComplete="off" value={this.state.evContent} onChange={this._onChange} element="textarea" /> autoComplete="off" value={this.state.evContent} onChange={this._onChange} element="textarea" />
</div> </div>
<div className="mx_Dialog_buttons"> <div className="mx_Dialog_buttons">
<button onClick={this.onBack}>{ _t('Back') }</button> <button onClick={this.onBack}>{ _t('Back') }</button>
@ -239,7 +247,7 @@ class SendAccountData extends GenericEditor {
<br /> <br />
<Field id="evContent" label={_t("Event Content")} type="text" className="mx_DevTools_textarea" <Field id="evContent" label={_t("Event Content")} type="text" className="mx_DevTools_textarea"
autoComplete="off" value={this.state.evContent} onChange={this._onChange} element="textarea" /> autoComplete="off" value={this.state.evContent} onChange={this._onChange} element="textarea" />
</div> </div>
<div className="mx_Dialog_buttons"> <div className="mx_Dialog_buttons">
<button onClick={this.onBack}>{ _t('Back') }</button> <button onClick={this.onBack}>{ _t('Back') }</button>
@ -315,15 +323,15 @@ class FilteredList extends React.PureComponent {
const TruncatedList = sdk.getComponent("elements.TruncatedList"); const TruncatedList = sdk.getComponent("elements.TruncatedList");
return <div> return <div>
<Field label={_t('Filter results')} autoFocus={true} size={64} <Field label={_t('Filter results')} autoFocus={true} size={64}
type="text" autoComplete="off" value={this.props.query} onChange={this.onQuery} type="text" autoComplete="off" value={this.props.query} onChange={this.onQuery}
className="mx_TextInputDialog_input mx_DevTools_RoomStateExplorer_query" className="mx_TextInputDialog_input mx_DevTools_RoomStateExplorer_query"
// force re-render so that autoFocus is applied when this component is re-used // force re-render so that autoFocus is applied when this component is re-used
key={this.props.children[0] ? this.props.children[0].key : ''} /> key={this.props.children[0] ? this.props.children[0].key : ''} />
<TruncatedList getChildren={this.getChildren} <TruncatedList getChildren={this.getChildren}
getChildCount={this.getChildCount} getChildCount={this.getChildCount}
truncateAt={this.state.truncateAt} truncateAt={this.state.truncateAt}
createOverflowElement={this.createOverflowElement} /> createOverflowElement={this.createOverflowElement} />
</div>; </div>;
} }
} }
@ -647,7 +655,7 @@ function VerificationRequest({txnId, request}) {
/* Note that request.timeout is a getter, so its value changes */ /* Note that request.timeout is a getter, so its value changes */
const id = setInterval(() => { const id = setInterval(() => {
setRequestTimeout(request.timeout); setRequestTimeout(request.timeout);
}, 500); }, 500);
return () => { clearInterval(id); }; return () => { clearInterval(id); };
@ -941,35 +949,35 @@ class SettingsExplorer extends React.Component {
/> />
<table> <table>
<thead> <thead>
<tr> <tr>
<th>{_t("Setting ID")}</th> <th>{_t("Setting ID")}</th>
<th>{_t("Value")}</th> <th>{_t("Value")}</th>
<th>{_t("Value in this room")}</th> <th>{_t("Value in this room")}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{allSettings.map(i => ( {allSettings.map(i => (
<tr key={i}> <tr key={i}>
<td> <td>
<a href="" onClick={(e) => this.onViewClick(e, i)}> <a href="" onClick={(e) => this.onViewClick(e, i)}>
<code>{i}</code> <code>{i}</code>
</a> </a>
<a href="" onClick={(e) => this.onEditClick(e, i)} <a href="" onClick={(e) => this.onEditClick(e, i)}
className='mx_DevTools_SettingsExplorer_edit' className='mx_DevTools_SettingsExplorer_edit'
> >
</a> </a>
</td> </td>
<td> <td>
<code>{this.renderSettingValue(SettingsStore.getValue(i))}</code> <code>{this.renderSettingValue(SettingsStore.getValue(i))}</code>
</td> </td>
<td> <td>
<code> <code>
{this.renderSettingValue(SettingsStore.getValue(i, room.roomId))} {this.renderSettingValue(SettingsStore.getValue(i, room.roomId))}
</code> </code>
</td> </td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
</div> </div>
@ -998,11 +1006,11 @@ class SettingsExplorer extends React.Component {
<div> <div>
<table> <table>
<thead> <thead>
<tr> <tr>
<th>{_t("Level")}</th> <th>{_t("Level")}</th>
<th>{_t("Settable at global")}</th> <th>{_t("Settable at global")}</th>
<th>{_t("Settable at room")}</th> <th>{_t("Settable at room")}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{LEVEL_ORDER.map(lvl => ( {LEVEL_ORDER.map(lvl => (

View file

@ -130,7 +130,7 @@ export default class IncomingSasDialog extends React.Component {
const oppProfile = this.state.opponentProfile; const oppProfile = this.state.opponentProfile;
if (oppProfile) { if (oppProfile) {
const url = oppProfile.avatar_url const url = oppProfile.avatar_url
? mediaFromMxc(oppProfile.avatar_url).getSquareThumbnailHttp(Math.floor(48 * window.devicePixelRatio)) ? mediaFromMxc(oppProfile.avatar_url).getSquareThumbnailHttp(48)
: null; : null;
profile = <div className="mx_IncomingSasDialog_opponentProfile"> profile = <div className="mx_IncomingSasDialog_opponentProfile">
<BaseAvatar name={oppProfile.displayname} <BaseAvatar name={oppProfile.displayname}

View file

@ -42,9 +42,12 @@ export default class IntegrationsDisabledDialog extends React.Component {
const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
return ( return (
<BaseDialog className='mx_IntegrationsDisabledDialog' hasCancel={true} <BaseDialog
onFinished={this.props.onFinished} className='mx_IntegrationsDisabledDialog'
title={_t("Integrations are disabled")}> hasCancel={true}
onFinished={this.props.onFinished}
title={_t("Integrations are disabled")}
>
<div className='mx_IntegrationsDisabledDialog_content'> <div className='mx_IntegrationsDisabledDialog_content'>
<p>{_t("Enable 'Manage Integrations' in Settings to do this.")}</p> <p>{_t("Enable 'Manage Integrations' in Settings to do this.")}</p>
</div> </div>

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