Merge remote-tracking branch 'upstream/develop' into compact-reply-rendering

This commit is contained in:
Tulir Asokan 2021-05-01 15:37:32 +03:00
commit 09f9916916
293 changed files with 8602 additions and 3053 deletions

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,121 @@
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)

View file

@ -1,6 +1,6 @@
{ {
"name": "matrix-react-sdk", "name": "matrix-react-sdk",
"version": "3.18.0", "version": "3.19.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",
@ -160,6 +159,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",
@ -189,6 +189,12 @@
}, },
"transformIgnorePatterns": [ "transformIgnorePatterns": [
"/node_modules/(?!matrix-js-sdk).+$" "/node_modules/(?!matrix-js-sdk).+$"
],
"collectCoverageFrom": [
"<rootDir>/src/**/*.{js,ts,tsx}"
],
"coverageReporters": [
"text"
] ]
} }
} }

View file

@ -250,6 +250,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

@ -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 {

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,63 @@ 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: 2px 8px;
} font-weight: normal;
}
> span { & + .mx_AccessibleButton {
margin-left: auto; margin-left: 16px;
} }
} }
.mx_SpaceRoomDirectory_error { > span {
position: relative; margin-left: auto;
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 { .mx_SpaceRoomDirectory_error {
content: ""; position: relative;
position: absolute; font-weight: $font-semi-bold;
height: 16px; color: $notice-primary-color;
width: 16px; font-size: $font-15px;
left: 0; line-height: $font-18px;
background-image: url("$(res)/img/element-icons/warning-badge.svg"); 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");
} }
} }
} }

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,45 +224,15 @@ $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');
}
}
} }
} }

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;
@ -100,11 +160,6 @@ limitations under the License.
} }
} }
.mx_SearchBox {
margin: 0;
flex-grow: 0;
}
.mx_AddExistingToSpaceDialog_errorText { .mx_AddExistingToSpaceDialog_errorText {
font-weight: $font-semi-bold; font-weight: $font-semi-bold;
font-size: $font-12px; font-size: $font-12px;
@ -113,52 +168,8 @@ limitations under the License.
margin-bottom: 28px; margin-bottom: 28px;
} }
.mx_AddExistingToSpaceDialog_content { .mx_AddExistingToSpace {
flex-grow: 1; display: contents;
.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 {
@ -189,8 +200,4 @@ limitations under the License.
padding: 0; padding: 0;
} }
} }
.mx_FormButton {
padding: 8px 22px;
}
} }

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

@ -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

@ -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

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,44 +35,40 @@ 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: 7px; // distance from left edge of waveform container (container has some margin too)
border-radius: 12px; background-color: $muted-fg-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_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 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; padding-left: 16px; // +10px for the live circle, +6px for regular padding
&::before { &::before {
animation: recording-pulse 2s infinite; animation: recording-pulse 2s infinite;
content: ''; content: '';
background-color: $voice-record-live-circle-color; background-color: $voice-record-live-circle-color;
width: 10px; width: 10px;
height: 10px; height: 10px;
position: absolute; position: absolute;
left: 8px; left: 8px;
top: 16px; // vertically center top: 16px; // vertically center
border-radius: 10px; border-radius: 10px;
} }
.mx_Waveform_bar {
background-color: $voice-record-waveform-fg-color;
}
.mx_Clock {
padding-right: 8px; // isolate from waveform
padding-left: 10px; // isolate from live circle
width: 42px; // we're not using a monospace font, so fake it
} }
} }

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: $primary-bg-color;
&::before {
content: '';
position: absolute; // sizing varies by icon
background-color: $muted-fg-color;
mask-repeat: no-repeat;
mask-size: contain;
}
&.mx_PlayPauseButton_disabled::before {
opacity: 0.5;
}
&.mx_PlayPauseButton_play::before {
width: 13px;
height: 16px;
top: 8px; // center
left: 12px; // center
mask-image: url('$(res)/img/element-icons/play.svg');
}
&.mx_PlayPauseButton_pause::before {
width: 10px;
height: 12px;
top: 10px; // center
left: 11px; // center
mask-image: url('$(res)/img/element-icons/pause.svg');
}
}

View file

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

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 {

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

@ -63,6 +63,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 +87,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 +112,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;
// ******************** // ********************

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;
// ******************** // ********************

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;
// ******************** // ********************
@ -194,6 +196,7 @@ $voice-record-stop-border-color: #E3E8F0;
$voice-record-stop-symbol-color: #ff4b55; $voice-record-stop-symbol-color: #ff4b55;
$voice-record-waveform-bg-color: #E3E8F0; $voice-record-waveform-bg-color: #E3E8F0;
$voice-record-waveform-fg-color: $muted-fg-color; $voice-record-waveform-fg-color: $muted-fg-color;
$voice-record-waveform-incomplete-fg-color: #C1C6CD;
$voice-record-live-circle-color: #ff4b55; $voice-record-live-circle-color: #ff4b55;
$roomtile-preview-color: #9e9e9e; $roomtile-preview-color: #9e9e9e;

View file

@ -91,6 +91,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 +120,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 +167,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;
// ******************** // ********************
@ -184,6 +186,7 @@ $voice-record-stop-border-color: #E3E8F0;
$voice-record-stop-symbol-color: #ff4b55; // $warning-color, but without letting people change it in themes $voice-record-stop-symbol-color: #ff4b55; // $warning-color, but without letting people change it in themes
$voice-record-waveform-bg-color: #E3E8F0; $voice-record-waveform-bg-color: #E3E8F0;
$voice-record-waveform-fg-color: $muted-fg-color; $voice-record-waveform-fg-color: $muted-fg-color;
$voice-record-waveform-incomplete-fg-color: #C1C6CD;
$voice-record-live-circle-color: #ff4b55; // $warning-color, but without letting people change it in themes $voice-record-live-circle-color: #ff4b55; // $warning-color, but without letting people change it in themes
$roomtile-preview-color: $secondary-fg-color; $roomtile-preview-color: $secondary-fg-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 {VoiceRecording} from "../voice/VoiceRecording"; 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 VoiceRecording; 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
@ -129,4 +135,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

@ -27,11 +27,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 +40,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 {

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,8 @@ 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 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';
@ -167,6 +168,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 +185,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 +396,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;
@ -500,6 +517,42 @@ export default class CallHandler {
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);
dis.dispatch({
action: Action.CallChangeRoom,
call,
});
}
}
});
} }
private async logCallStats(call: MatrixCall, mappedRoomId: string) { private async logCallStats(call: MatrixCall, mappedRoomId: string) {
@ -551,7 +604,7 @@ export default class CallHandler {
} }
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}`,
@ -639,7 +692,7 @@ 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);
if (transferee) { if (transferee) {
@ -673,7 +726,7 @@ export default class CallHandler {
call.placeScreenSharingCall( call.placeScreenSharingCall(
remoteElement, remoteElement,
localElement, localElement,
async () : Promise<DesktopCapturerSource> => { 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;
@ -772,7 +825,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;

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

@ -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

@ -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

@ -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

@ -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

@ -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();

View file

@ -1096,8 +1096,22 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
const roomToLeave = MatrixClientPeg.get().getRoom(roomId); const roomToLeave = MatrixClientPeg.get().getRoom(roomId);
const isSpace = roomToLeave?.isSpaceRoom(); const isSpace = 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") {

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>
); );
@ -1014,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>,
); );
@ -1222,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

@ -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";
@ -26,7 +28,7 @@ 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) {
@ -2014,6 +2018,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

@ -136,7 +136,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 });
@ -312,11 +312,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 +337,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> = ({
@ -358,7 +358,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 +397,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;
@ -538,10 +542,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,9 @@ 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 {allSettled} from "../../utils/promise";
import {calculateRoomVia} from "../../utils/permalinks/Permalinks";
interface IProps { interface IProps {
space: Room; space: Room;
@ -354,7 +357,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 +379,65 @@ 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 () => {
// TODO rate limiting
setBusy(true);
try {
await allSettled(Array.from(selectedToAdd).map((room) =>
SpaceStore.instance.addRoomToSpace(space, room.roomId, calculateRoomVia(room))));
onFinished(true);
} catch (e) {
console.error("Failed to add rooms to space", e);
setError(_t("Failed to add rooms to space"));
}
setBusy(false);
};
buttonLabel = busy ? _t("Adding...") : _t("Add");
}
return <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 +721,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 +737,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

@ -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;
} }
@ -98,21 +96,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 +153,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 +245,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 +258,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 +267,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 +275,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 +350,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 +372,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, 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";
@ -33,6 +33,7 @@ import {allSettled} 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";
interface IProps extends IDialogProps { interface IProps extends IDialogProps {
matrixClient: MatrixClient; matrixClient: MatrixClient;
@ -41,43 +42,122 @@ 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={(e) => onChange(e.target.checked)} checked={checked} />
</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 [query, setQuery] = useState("");
const lcQuery = query.toLowerCase();
const existingSubspaces = SpaceStore.instance.getChildSpaces(space.roomId);
const existingSubspacesSet = new Set(existingSubspaces);
const existingRoomsSet = new Set(SpaceStore.instance.getChildRooms(space.roomId));
const joinRule = space.getJoinRule();
const [spaces, rooms, dms] = cli.getVisibleRooms().reduce((arr, room) => {
if (room.getMyMembership() !== "join") return arr;
if (!room.name.toLowerCase().includes(lcQuery)) return arr;
if (room.isSpaceRoom()) {
if (room !== space && !existingSubspacesSet.has(room)) {
arr[0].push(room);
}
} else if (!existingRoomsSet.has(room)) {
if (!DMRoomMap.shared().getUserIdForRoomId(room.roomId)) {
arr[1].push(room);
} else if (joinRule !== "public") {
// Only show DMs for non-public spaces as they make very little sense in spaces other than "Just Me" ones.
arr[2].push(room);
}
}
return arr;
}, [[], [], []]);
return <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={(checked) => {
onChange(checked, room);
}}
/>;
}) }
</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={(checked) => {
onChange(checked, space);
}}
/>;
}) }
</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={(checked) => {
onChange(checked, room);
}}
/>;
}) }
</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 [selectedToAdd, setSelectedToAdd] = useState(new Set<Room>());
const existingSubspaces = SpaceStore.instance.getChildSpaces(space.roomId); const existingSubspaces = SpaceStore.instance.getChildSpaces(space.roomId);
const existingSubspacesSet = new Set(existingSubspaces); const [selectedToAdd, setSelectedToAdd] = useState(new Set<Room>());
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 [busy, setBusy] = useState(false);
const [error, setError] = useState(""); 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,
@ -117,65 +197,26 @@ const AddExistingToSpaceDialog: React.FC<IProps> = ({ matrixClient: cli, space,
return <BaseDialog return <BaseDialog
title={title} title={title}
className="mx_AddExistingToSpaceDialog" className="mx_AddExistingToSpaceDialog"
contentId="mx_AddExistingToSpaceDialog" contentId="mx_AddExistingToSpace"
onFinished={onFinished} onFinished={onFinished}
fixedWidth={false} fixedWidth={false}
> >
{ error && <div className="mx_AddExistingToSpaceDialog_errorText">{ error }</div> } { error && <div className="mx_AddExistingToSpaceDialog_errorText">{ error }</div> }
<SearchBox <MatrixClientContext.Provider value={cli}>
className="mx_textinput_icon mx_textinput_search" <AddExistingToSpace
placeholder={ _t("Filter your rooms and spaces") } space={space}
onSearch={setQuery} selected={selectedToAdd}
autoComplete={true} onChange={(checked, room) => {
/> if (checked) {
<AutoHideScrollbar className="mx_AddExistingToSpaceDialog_content" id="mx_AddExistingToSpaceDialog"> selectedToAdd.add(room);
{ rooms.length > 0 ? ( } else {
<div className="mx_AddExistingToSpaceDialog_section"> selectedToAdd.delete(room);
<h3>{ _t("Rooms") }</h3> }
{ rooms.map(room => { setSelectedToAdd(new Set(selectedToAdd));
return <Entry }}
key={room.roomId} />
room={room} </MatrixClientContext.Provider>
checked={selectedToAdd.has(room)}
onChange={(checked) => {
if (checked) {
selectedToAdd.add(room);
} else {
selectedToAdd.delete(room);
}
setSelectedToAdd(new Set(selectedToAdd));
}}
/>;
}) }
</div>
) : undefined }
{ spaces.length > 0 ? (
<div className="mx_AddExistingToSpaceDialog_section mx_AddExistingToSpaceDialog_section_spaces">
<h3>{ _t("Spaces") }</h3>
{ spaces.map(space => {
return <Entry
key={space.roomId}
room={space}
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">
{ _t("No results") }
</span> : undefined }
</AutoHideScrollbar>
<div className="mx_AddExistingToSpaceDialog_footer"> <div className="mx_AddExistingToSpaceDialog_footer">
<span> <span>
@ -189,6 +230,7 @@ const AddExistingToSpaceDialog: React.FC<IProps> = ({ matrixClient: cli, space,
kind="primary" kind="primary"
disabled={busy || selectedToAdd.size < 1} disabled={busy || selectedToAdd.size < 1}
onClick={async () => { onClick={async () => {
// TODO rate limiting
setBusy(true); setBusy(true);
try { try {
await allSettled(Array.from(selectedToAdd).map((room) => await allSettled(Array.from(selectedToAdd).map((room) =>

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>

View file

@ -37,9 +37,12 @@ export default class IntegrationsImpossibleDialog extends React.Component {
const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
return ( return (
<BaseDialog className='mx_IntegrationsImpossibleDialog' hasCancel={false} <BaseDialog
onFinished={this.props.onFinished} className='mx_IntegrationsImpossibleDialog'
title={_t("Integrations not allowed")}> hasCancel={false}
onFinished={this.props.onFinished}
title={_t("Integrations not allowed")}
>
<div className='mx_IntegrationsImpossibleDialog_content'> <div className='mx_IntegrationsImpossibleDialog_content'>
<p> <p>
{_t( {_t(

View file

@ -24,7 +24,7 @@ export default function KeySignatureUploadFailedDialog({
source, source,
continuation, continuation,
onFinished, onFinished,
}) { }) {
const RETRIES = 2; const RETRIES = 2;
const BaseDialog = sdk.getComponent('dialogs.BaseDialog'); const BaseDialog = sdk.getComponent('dialogs.BaseDialog');
const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
@ -84,10 +84,10 @@ export default function KeySignatureUploadFailedDialog({
} else { } else {
body = (<div> body = (<div>
{success ? {success ?
<span>{_t("Upload completed")}</span> : <span>{_t("Upload completed")}</span> :
cancelled ? cancelled ?
<span>{_t("Cancelled signature upload")}</span> : <span>{_t("Cancelled signature upload")}</span> :
<span>{_t("Unable to upload")}</span>} <span>{_t("Unable to upload")}</span>}
<DialogButtons <DialogButtons
primaryButton={_t("OK")} primaryButton={_t("OK")}
hasCancel={false} hasCancel={false}

View file

@ -164,8 +164,12 @@ export default class MessageEditHistoryDialog extends React.PureComponent {
} }
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
return ( return (
<BaseDialog className='mx_MessageEditHistoryDialog' hasCancel={true} <BaseDialog
onFinished={this.props.onFinished} title={_t("Message edits")}> className='mx_MessageEditHistoryDialog'
hasCancel={true}
onFinished={this.props.onFinished}
title={_t("Message edits")}
>
{content} {content}
</BaseDialog> </BaseDialog>
); );

View file

@ -116,8 +116,12 @@ export default class RoomSettingsDialog extends React.Component {
const roomName = MatrixClientPeg.get().getRoom(this.props.roomId).name; const roomName = MatrixClientPeg.get().getRoom(this.props.roomId).name;
return ( return (
<BaseDialog className='mx_RoomSettingsDialog' hasCancel={true} <BaseDialog
onFinished={this.props.onFinished} title={_t("Room Settings - %(roomName)s", {roomName})}> className='mx_RoomSettingsDialog'
hasCancel={true}
onFinished={this.props.onFinished}
title={_t("Room Settings - %(roomName)s", {roomName})}
>
<div className='mx_SettingsDialog_content'> <div className='mx_SettingsDialog_content'>
<TabbedView tabs={this._getTabs()} /> <TabbedView tabs={this._getTabs()} />
</div> </div>

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.
@ -110,7 +110,7 @@ export default class ServerPickerDialog extends React.PureComponent<IProps, ISta
console.error(e); console.error(e);
const stateForError = AutoDiscoveryUtils.authComponentStateForError(e); const stateForError = AutoDiscoveryUtils.authComponentStateForError(e);
if (stateForError.isFatalError) { if (stateForError.serverErrorIsFatal) {
let error = _t("Unable to validate homeserver"); let error = _t("Unable to validate homeserver");
if (e.translatedMessage) { if (e.translatedMessage) {
error = e.translatedMessage; error = e.translatedMessage;
@ -168,7 +168,7 @@ export default class ServerPickerDialog extends React.PureComponent<IProps, ISta
text = _t("Matrix.org is the biggest public homeserver in the world, so its a good place for many."); text = _t("Matrix.org is the biggest public homeserver in the world, so its a good place for many.");
} }
let defaultServerName = this.defaultServer.hsName; let defaultServerName: React.ReactNode = this.defaultServer.hsName;
if (this.defaultServer.hsNameIsDifferent) { if (this.defaultServer.hsNameIsDifferent) {
defaultServerName = ( defaultServerName = (
<TextWithTooltip class="mx_Login_underlinedServerName" tooltip={this.defaultServer.hsUrl}> <TextWithTooltip class="mx_Login_underlinedServerName" tooltip={this.defaultServer.hsUrl}>

View file

@ -36,7 +36,7 @@ export default class SeshatResetDialog extends React.PureComponent<IDialogProps>
{_t("You most likely do not want to reset your event index store")} {_t("You most likely do not want to reset your event index store")}
<br /> <br />
{_t("If you do, please note that none of your messages will be deleted, " + {_t("If you do, please note that none of your messages will be deleted, " +
"but the search experience might be degraded for a few moments" + "but the search experience might be degraded for a few moments " +
"whilst the index is recreated", "whilst the index is recreated",
)} )}
</p> </p>

View file

@ -98,7 +98,7 @@ export default class SessionRestoreErrorDialog extends React.Component {
"may be incompatible with this version. Close this window and return " + "may be incompatible with this version. Close this window and return " +
"to the more recent version.", "to the more recent version.",
{ brand }, { brand },
) }</p> ) }</p>
<p>{ _t( <p>{ _t(
"Clearing your browser's storage may fix the problem, but will sign you " + "Clearing your browser's storage may fix the problem, but will sign you " +

View file

@ -45,10 +45,12 @@ export default class StorageEvictedDialog extends React.Component {
let logRequest; let logRequest;
if (SdkConfig.get().bug_report_endpoint_url) { if (SdkConfig.get().bug_report_endpoint_url) {
logRequest = _t( logRequest = _t(
"To help us prevent this in future, please <a>send us logs</a>.", {}, "To help us prevent this in future, please <a>send us logs</a>.",
{ {},
a: text => <a href="#" onClick={this._sendBugReport}>{text}</a>, {
}); a: text => <a href="#" onClick={this._sendBugReport}>{text}</a>,
},
);
} }
return ( return (

View file

@ -155,8 +155,12 @@ export default class UserSettingsDialog extends React.Component {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
return ( return (
<BaseDialog className='mx_UserSettingsDialog' hasCancel={true} <BaseDialog
onFinished={this.props.onFinished} title={_t("Settings")}> className='mx_UserSettingsDialog'
hasCancel={true}
onFinished={this.props.onFinished}
title={_t("Settings")}
>
<div className='mx_SettingsDialog_content'> <div className='mx_SettingsDialog_content'>
<TabbedView tabs={this._getTabs()} initialTabId={this.props.initialTabId} /> <TabbedView tabs={this._getTabs()} initialTabId={this.props.initialTabId} />
</div> </div>

View file

@ -52,11 +52,13 @@ export default class VerificationRequestDialog extends React.Component {
const title = request && request.isSelfVerification ? const title = request && request.isSelfVerification ?
_t("Verify other login") : _t("Verification Request"); _t("Verify other login") : _t("Verification Request");
return <BaseDialog className="mx_InfoDialog" onFinished={this.props.onFinished} return <BaseDialog
contentId="mx_Dialog_content" className="mx_InfoDialog"
title={title} onFinished={this.props.onFinished}
hasCancel={true} contentId="mx_Dialog_content"
> title={title}
hasCancel={true}
>
<EncryptionPanel <EncryptionPanel
layout="dialog" layout="dialog"
verificationRequest={this.props.verificationRequest} verificationRequest={this.props.verificationRequest}

View file

@ -70,9 +70,12 @@ export default class WidgetOpenIDPermissionsDialog extends React.Component {
const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
return ( return (
<BaseDialog className='mx_WidgetOpenIDPermissionsDialog' hasCancel={true} <BaseDialog
onFinished={this.props.onFinished} className='mx_WidgetOpenIDPermissionsDialog'
title={_t("Allow this widget to verify your identity")}> hasCancel={true}
onFinished={this.props.onFinished}
title={_t("Allow this widget to verify your identity")}
>
<div className='mx_WidgetOpenIDPermissionsDialog_content'> <div className='mx_WidgetOpenIDPermissionsDialog_content'>
<p> <p>
{_t("The widget will verify your user ID, but won't be able to perform actions for you:")} {_t("The widget will verify your user ID, but won't be able to perform actions for you:")}

View file

@ -40,10 +40,11 @@ export default class ConfirmDestroyCrossSigningDialog extends React.Component {
return ( return (
<BaseDialog <BaseDialog
className='mx_ConfirmDestroyCrossSigningDialog' className='mx_ConfirmDestroyCrossSigningDialog'
hasCancel={true} hasCancel={true}
onFinished={this.props.onFinished} onFinished={this.props.onFinished}
title={_t("Destroy cross-signing keys?")}> title={_t("Destroy cross-signing keys?")}
>
<div className='mx_ConfirmDestroyCrossSigningDialog_content'> <div className='mx_ConfirmDestroyCrossSigningDialog_content'>
<p> <p>
{_t( {_t(

View file

@ -373,21 +373,24 @@ export default class RestoreKeyBackupDialog extends React.PureComponent {
{_t( {_t(
"If you've forgotten your Security Phrase you can "+ "If you've forgotten your Security Phrase you can "+
"<button1>use your Security Key</button1> or " + "<button1>use your Security Key</button1> or " +
"<button2>set up new recovery options</button2>" "<button2>set up new recovery options</button2>",
, {}, { {},
button1: s => <AccessibleButton className="mx_linkButton" {
element="span" button1: s => <AccessibleButton
onClick={this._onUseRecoveryKeyClick} className="mx_linkButton"
> element="span"
{s} onClick={this._onUseRecoveryKeyClick}
</AccessibleButton>, >
button2: s => <AccessibleButton className="mx_linkButton" {s}
element="span" </AccessibleButton>,
onClick={this._onResetRecoveryClick} button2: s => <AccessibleButton
> className="mx_linkButton"
{s} element="span"
</AccessibleButton>, onClick={this._onResetRecoveryClick}
})} >
{s}
</AccessibleButton>,
})}
</div>; </div>;
} else { } else {
title = _t("Enter Security Key"); title = _t("Enter Security Key");
@ -435,15 +438,17 @@ export default class RestoreKeyBackupDialog extends React.PureComponent {
</div> </div>
{_t( {_t(
"If you've forgotten your Security Key you can "+ "If you've forgotten your Security Key you can "+
"<button>set up new recovery options</button>" "<button>set up new recovery options</button>",
, {}, { {},
button: s => <AccessibleButton className="mx_linkButton" {
element="span" button: s => <AccessibleButton className="mx_linkButton"
onClick={this._onResetRecoveryClick} element="span"
> onClick={this._onResetRecoveryClick}
{s} >
</AccessibleButton>, {s}
})} </AccessibleButton>,
},
)}
</div>; </div>;
} }
@ -452,9 +457,9 @@ export default class RestoreKeyBackupDialog extends React.PureComponent {
onFinished={this.props.onFinished} onFinished={this.props.onFinished}
title={title} title={title}
> >
<div className='mx_RestoreKeyBackupDialog_content'> <div className='mx_RestoreKeyBackupDialog_content'>
{content} {content}
</div> </div>
</BaseDialog> </BaseDialog>
); );
} }

View file

@ -70,8 +70,8 @@ export default class ActionButton extends React.Component {
} }
const icon = this.props.iconPath ? const icon = this.props.iconPath ?
(<TintableSvg src={this.props.iconPath} width={this.props.size} height={this.props.size} />) : (<TintableSvg src={this.props.iconPath} width={this.props.size} height={this.props.size} />) :
undefined; undefined;
const classNames = ["mx_RoleButton"]; const classNames = ["mx_RoleButton"];
if (this.props.className) { if (this.props.className) {

View file

@ -109,7 +109,7 @@ export default class AppTile extends React.Component {
const childContentProtocol = u.protocol; const childContentProtocol = u.protocol;
if (parentContentProtocol === 'https:' && childContentProtocol !== 'https:') { if (parentContentProtocol === 'https:' && childContentProtocol !== 'https:') {
console.warn("Refusing to load mixed-content app:", console.warn("Refusing to load mixed-content app:",
parentContentProtocol, childContentProtocol, window.location, this.props.app.url); parentContentProtocol, childContentProtocol, window.location, this.props.app.url);
return true; return true;
} }
return false; return false;

View file

@ -65,12 +65,18 @@ export class EditableItem extends React.Component {
<span className="mx_EditableItem_promptText"> <span className="mx_EditableItem_promptText">
{_t("Are you sure?")} {_t("Are you sure?")}
</span> </span>
<AccessibleButton onClick={this._onActuallyRemove} kind="primary_sm" <AccessibleButton
className="mx_EditableItem_confirmBtn"> onClick={this._onActuallyRemove}
kind="primary_sm"
className="mx_EditableItem_confirmBtn"
>
{_t("Yes")} {_t("Yes")}
</AccessibleButton> </AccessibleButton>
<AccessibleButton onClick={this._onDontRemove} kind="danger_sm" <AccessibleButton
className="mx_EditableItem_confirmBtn"> onClick={this._onDontRemove}
kind="danger_sm"
className="mx_EditableItem_confirmBtn"
>
{_t("No")} {_t("No")}
</AccessibleButton> </AccessibleButton>
</div> </div>
@ -121,11 +127,15 @@ export default class EditableItemList extends React.Component {
_renderNewItemField() { _renderNewItemField() {
return ( return (
<form onSubmit={this._onItemAdded} autoComplete="off" <form
noValidate={true} className="mx_EditableItemList_newItem"> onSubmit={this._onItemAdded}
autoComplete="off"
noValidate={true}
className="mx_EditableItemList_newItem"
>
<Field label={this.props.placeholder} type="text" <Field label={this.props.placeholder} type="text"
autoComplete="off" value={this.props.newItem || ""} onChange={this._onNewItemChanged} autoComplete="off" value={this.props.newItem || ""} onChange={this._onNewItemChanged}
list={this.props.suggestionsListId} /> list={this.props.suggestionsListId} />
<AccessibleButton onClick={this._onItemAdded} kind="primary" type="submit" disabled={!this.props.newItem}> <AccessibleButton onClick={this._onItemAdded} kind="primary" type="submit" disabled={!this.props.newItem}>
{_t("Add")} {_t("Add")}
</AccessibleButton> </AccessibleButton>

View file

@ -221,13 +221,15 @@ export default class EditableText extends React.Component {
</div>; </div>;
} else { } else {
// show the content editable div, but manually manage its contents as react and contentEditable don't play nice together // show the content editable div, but manually manage its contents as react and contentEditable don't play nice together
editableEl = <div ref={this._editable_div} editableEl = <div
contentEditable={true} ref={this._editable_div}
className={className} contentEditable={true}
onKeyDown={this.onKeyDown} className={className}
onKeyUp={this.onKeyUp} onKeyDown={this.onKeyDown}
onFocus={this.onFocus} onKeyUp={this.onKeyUp}
onBlur={this.onBlur} />; onFocus={this.onFocus}
onBlur={this.onBlur}
/>;
} }
return editableEl; return editableEl;

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, { HTMLAttributes } from "react"; import React, { HTMLAttributes, ReactNode, useContext } from "react";
import { Room } from "matrix-js-sdk/src/models/room"; import { Room } from "matrix-js-sdk/src/models/room";
import { RoomMember } from "matrix-js-sdk/src/models/room-member"; import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { sortBy } from "lodash"; import { sortBy } from "lodash";
@ -24,6 +24,7 @@ import { _t } from "../../../languageHandler";
import DMRoomMap from "../../../utils/DMRoomMap"; import DMRoomMap from "../../../utils/DMRoomMap";
import TextWithTooltip from "../elements/TextWithTooltip"; import TextWithTooltip from "../elements/TextWithTooltip";
import { useRoomMembers } from "../../../hooks/useRoomMembers"; import { useRoomMembers } from "../../../hooks/useRoomMembers";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
const DEFAULT_NUM_FACES = 5; const DEFAULT_NUM_FACES = 5;
@ -36,6 +37,7 @@ interface IProps extends HTMLAttributes<HTMLSpanElement> {
const isKnownMember = (member: RoomMember) => !!DMRoomMap.shared().getDMRoomsForUserId(member.userId)?.length; const isKnownMember = (member: RoomMember) => !!DMRoomMap.shared().getDMRoomsForUserId(member.userId)?.length;
const FacePile = ({ room, onlyKnownUsers = true, numShown = DEFAULT_NUM_FACES, ...props }: IProps) => { const FacePile = ({ room, onlyKnownUsers = true, numShown = DEFAULT_NUM_FACES, ...props }: IProps) => {
const cli = useContext(MatrixClientContext);
let members = useRoomMembers(room); let members = useRoomMembers(room);
// sort users with an explicit avatar first // sort users with an explicit avatar first
@ -46,21 +48,42 @@ const FacePile = ({ room, onlyKnownUsers = true, numShown = DEFAULT_NUM_FACES, .
// sort known users first // sort known users first
iteratees.unshift(member => isKnownMember(member)); iteratees.unshift(member => isKnownMember(member));
} }
if (members.length < 1) return null;
const shownMembers = sortBy(members, iteratees).slice(0, numShown); // exclude ourselves from the shown members list
const shownMembers = sortBy(members.filter(m => m.userId !== cli.getUserId()), iteratees).slice(0, numShown);
if (shownMembers.length < 1) return null;
// We reverse the order of the shown faces in CSS to simplify their visual overlap,
// reverse members in tooltip order to make the order between the two match up.
const commaSeparatedMembers = shownMembers.map(m => m.rawDisplayName).reverse().join(", ");
let tooltip: ReactNode;
if (props.onClick) {
tooltip = <div>
<div className="mx_Tooltip_title">
{ _t("View all %(count)s members", { count: members.length }) }
</div>
<div className="mx_Tooltip_sub">
{ _t("Including %(commaSeparatedMembers)s", { commaSeparatedMembers }) }
</div>
</div>;
} else {
tooltip = _t("%(count)s members including %(commaSeparatedMembers)s", {
count: members.length,
commaSeparatedMembers,
});
}
return <div {...props} className="mx_FacePile"> return <div {...props} className="mx_FacePile">
<div className="mx_FacePile_faces"> <TextWithTooltip class="mx_FacePile_faces" tooltip={tooltip} tooltipProps={{ yOffset: 32 }}>
{ shownMembers.map(member => { { members.length > numShown ? <span className="mx_FacePile_face mx_FacePile_more" /> : null }
return <TextWithTooltip key={member.userId} tooltip={member.name}> { shownMembers.map(m =>
<MemberAvatar member={member} width={28} height={28} /> <MemberAvatar key={m.userId} member={m} width={28} height={28} className="mx_FacePile_face" /> )}
</TextWithTooltip>; </TextWithTooltip>
}) } { onlyKnownUsers && <span className="mx_FacePile_summary">
</div>
{ onlyKnownUsers && <span>
{ _t("%(count)s people you know have already joined", { count: members.length }) } { _t("%(count)s people you know have already joined", { count: members.length }) }
</span> } </span> }
</div> </div>;
}; };
export default FacePile; export default FacePile;

View file

@ -262,7 +262,7 @@ export default class Field extends React.PureComponent<PropShapes, IState> {
tooltipClassName={classNames("mx_Field_tooltip", tooltipClassName)} tooltipClassName={classNames("mx_Field_tooltip", tooltipClassName)}
visible={(this.state.focused && this.props.forceTooltipVisible) || this.state.feedbackVisible} visible={(this.state.focused && this.props.forceTooltipVisible) || this.state.feedbackVisible}
label={tooltipContent || this.state.feedback} label={tooltipContent || this.state.feedback}
forceOnRight alignment={Tooltip.Alignment.Right}
/>; />;
} }

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