Merge branch 'develop' of github.com:matrix-org/matrix-react-sdk into t3chguy/fix/19010

 Conflicts:
	src/components/structures/SpaceRoomView.tsx
	src/components/views/rooms/MemberList.tsx
	src/components/views/rooms/RoomList.tsx
This commit is contained in:
Michael Telatynski 2021-10-14 11:05:29 +01:00
commit 020eca6922
177 changed files with 6615 additions and 2121 deletions

View file

@ -1,3 +1,70 @@
Changes in [3.32.1](https://github.com/vector-im/element-desktop/releases/tag/v3.32.1) (2021-10-12)
===================================================================================================
## 🐛 Bug Fixes
* Upgrade to matrix-js-sdk#14.0.1
Changes in [3.32.0](https://github.com/vector-im/element-desktop/releases/tag/v3.32.0) (2021-10-11)
===================================================================================================
## ✨ Features
* Decrease profile button touch target ([\#6900](https://github.com/matrix-org/matrix-react-sdk/pull/6900)). Contributed by [ColonisationCaptain](https://github.com/ColonisationCaptain).
* Don't let click events propagate out of context menus ([\#6892](https://github.com/matrix-org/matrix-react-sdk/pull/6892)).
* Allow closing Dropdown via its chevron ([\#6885](https://github.com/matrix-org/matrix-react-sdk/pull/6885)). Fixes vector-im/element-web#19030 and vector-im/element-web#19030.
* Improve AUX panel behaviour ([\#6699](https://github.com/matrix-org/matrix-react-sdk/pull/6699)). Fixes vector-im/element-web#18787 and vector-im/element-web#18787. Contributed by [SimonBrandner](https://github.com/SimonBrandner).
* A nicer opening animation for the Image View ([\#6454](https://github.com/matrix-org/matrix-react-sdk/pull/6454)). Fixes vector-im/element-web#18186 and vector-im/element-web#18186. Contributed by [SimonBrandner](https://github.com/SimonBrandner).
## 🐛 Bug Fixes
* [Release] Fix space hierarchy pagination ([\#6910](https://github.com/matrix-org/matrix-react-sdk/pull/6910)).
* Fix leaving space via other client leaving you in undefined-land ([\#6891](https://github.com/matrix-org/matrix-react-sdk/pull/6891)). Fixes vector-im/element-web#18455 and vector-im/element-web#18455.
* Handle newer voice message encrypted event format for chat export ([\#6893](https://github.com/matrix-org/matrix-react-sdk/pull/6893)). Contributed by [jaiwanth-v](https://github.com/jaiwanth-v).
* Fix pagination when filtering space hierarchy ([\#6876](https://github.com/matrix-org/matrix-react-sdk/pull/6876)). Fixes vector-im/element-web#19235 and vector-im/element-web#19235.
* Fix spaces null-guard breaking the dispatcher settings watching ([\#6886](https://github.com/matrix-org/matrix-react-sdk/pull/6886)). Fixes vector-im/element-web#19223 and vector-im/element-web#19223.
* Fix space children without specific `order` being sorted after those with one ([\#6878](https://github.com/matrix-org/matrix-react-sdk/pull/6878)). Fixes vector-im/element-web#19192 and vector-im/element-web#19192.
* Ensure that sub-spaces aren't considered for notification badges ([\#6881](https://github.com/matrix-org/matrix-react-sdk/pull/6881)). Fixes vector-im/element-web#18975 and vector-im/element-web#18975.
* Fix timeline autoscroll with non-standard DPI settings. ([\#6880](https://github.com/matrix-org/matrix-react-sdk/pull/6880)). Fixes vector-im/element-web#18984 and vector-im/element-web#18984.
* Pluck out JoinRuleSettings styles so they apply in space settings too ([\#6879](https://github.com/matrix-org/matrix-react-sdk/pull/6879)). Fixes vector-im/element-web#19164 and vector-im/element-web#19164.
* Null guard around the matrixClient in SpaceStore ([\#6874](https://github.com/matrix-org/matrix-react-sdk/pull/6874)).
* Fix issue (https ([\#6871](https://github.com/matrix-org/matrix-react-sdk/pull/6871)). Fixes vector-im/element-web#19138 and vector-im/element-web#19138. Contributed by [psrpinto](https://github.com/psrpinto).
* Fix pills being cut off in message bubble layout ([\#6865](https://github.com/matrix-org/matrix-react-sdk/pull/6865)). Fixes vector-im/element-web#18627 and vector-im/element-web#18627. Contributed by [robintown](https://github.com/robintown).
* Fix space admin check false positive on multiple admins ([\#6824](https://github.com/matrix-org/matrix-react-sdk/pull/6824)).
* Fix the User View ([\#6860](https://github.com/matrix-org/matrix-react-sdk/pull/6860)). Fixes vector-im/element-web#19158 and vector-im/element-web#19158.
* Fix spacing for message composer buttons ([\#6852](https://github.com/matrix-org/matrix-react-sdk/pull/6852)). Fixes vector-im/element-web#18999 and vector-im/element-web#18999.
* Always show root event of a thread in room's timeline ([\#6842](https://github.com/matrix-org/matrix-react-sdk/pull/6842)). Fixes vector-im/element-web#19016 and vector-im/element-web#19016.
Changes in [3.32.0-rc.2](https://github.com/vector-im/element-desktop/releases/tag/v3.32.0-rc.2) (2021-10-08)
=============================================================================================================
## 🐛 Bug Fixes
* [Release] Fix space hierarchy pagination ([\#6910](https://github.com/matrix-org/matrix-react-sdk/pull/6910)).
Changes in [3.32.0-rc.1](https://github.com/vector-im/element-desktop/releases/tag/v3.32.0-rc.1) (2021-10-04)
=============================================================================================================
## ✨ Features
* Decrease profile button touch target ([\#6900](https://github.com/matrix-org/matrix-react-sdk/pull/6900)). Contributed by [ColonisationCaptain](https://github.com/ColonisationCaptain).
* Don't let click events propagate out of context menus ([\#6892](https://github.com/matrix-org/matrix-react-sdk/pull/6892)).
* Allow closing Dropdown via its chevron ([\#6885](https://github.com/matrix-org/matrix-react-sdk/pull/6885)). Fixes vector-im/element-web#19030 and vector-im/element-web#19030.
* Improve AUX panel behaviour ([\#6699](https://github.com/matrix-org/matrix-react-sdk/pull/6699)). Fixes vector-im/element-web#18787 and vector-im/element-web#18787. Contributed by [SimonBrandner](https://github.com/SimonBrandner).
* A nicer opening animation for the Image View ([\#6454](https://github.com/matrix-org/matrix-react-sdk/pull/6454)). Fixes vector-im/element-web#18186 and vector-im/element-web#18186. Contributed by [SimonBrandner](https://github.com/SimonBrandner).
## 🐛 Bug Fixes
* Fix leaving space via other client leaving you in undefined-land ([\#6891](https://github.com/matrix-org/matrix-react-sdk/pull/6891)). Fixes vector-im/element-web#18455 and vector-im/element-web#18455.
* Handle newer voice message encrypted event format for chat export ([\#6893](https://github.com/matrix-org/matrix-react-sdk/pull/6893)). Contributed by [jaiwanth-v](https://github.com/jaiwanth-v).
* Fix pagination when filtering space hierarchy ([\#6876](https://github.com/matrix-org/matrix-react-sdk/pull/6876)). Fixes vector-im/element-web#19235 and vector-im/element-web#19235.
* Fix spaces null-guard breaking the dispatcher settings watching ([\#6886](https://github.com/matrix-org/matrix-react-sdk/pull/6886)). Fixes vector-im/element-web#19223 and vector-im/element-web#19223.
* Fix space children without specific `order` being sorted after those with one ([\#6878](https://github.com/matrix-org/matrix-react-sdk/pull/6878)). Fixes vector-im/element-web#19192 and vector-im/element-web#19192.
* Ensure that sub-spaces aren't considered for notification badges ([\#6881](https://github.com/matrix-org/matrix-react-sdk/pull/6881)). Fixes vector-im/element-web#18975 and vector-im/element-web#18975.
* Fix timeline autoscroll with non-standard DPI settings. ([\#6880](https://github.com/matrix-org/matrix-react-sdk/pull/6880)). Fixes vector-im/element-web#18984 and vector-im/element-web#18984.
* Pluck out JoinRuleSettings styles so they apply in space settings too ([\#6879](https://github.com/matrix-org/matrix-react-sdk/pull/6879)). Fixes vector-im/element-web#19164 and vector-im/element-web#19164.
* Null guard around the matrixClient in SpaceStore ([\#6874](https://github.com/matrix-org/matrix-react-sdk/pull/6874)).
* Fix issue (https ([\#6871](https://github.com/matrix-org/matrix-react-sdk/pull/6871)). Fixes vector-im/element-web#19138 and vector-im/element-web#19138. Contributed by [psrpinto](https://github.com/psrpinto).
* Fix pills being cut off in message bubble layout ([\#6865](https://github.com/matrix-org/matrix-react-sdk/pull/6865)). Fixes vector-im/element-web#18627 and vector-im/element-web#18627. Contributed by [robintown](https://github.com/robintown).
* Fix space admin check false positive on multiple admins ([\#6824](https://github.com/matrix-org/matrix-react-sdk/pull/6824)).
* Fix the User View ([\#6860](https://github.com/matrix-org/matrix-react-sdk/pull/6860)). Fixes vector-im/element-web#19158 and vector-im/element-web#19158.
* Fix spacing for message composer buttons ([\#6852](https://github.com/matrix-org/matrix-react-sdk/pull/6852)). Fixes vector-im/element-web#18999 and vector-im/element-web#18999.
Changes in [3.31.0](https://github.com/vector-im/element-desktop/releases/tag/v3.31.0) (2021-09-27) Changes in [3.31.0](https://github.com/vector-im/element-desktop/releases/tag/v3.31.0) (2021-09-27)
=================================================================================================== ===================================================================================================

View file

@ -1,6 +1,6 @@
{ {
"name": "matrix-react-sdk", "name": "matrix-react-sdk",
"version": "3.31.0", "version": "3.32.1",
"description": "SDK for matrix.org using React", "description": "SDK for matrix.org using React",
"author": "matrix.org", "author": "matrix.org",
"repository": { "repository": {
@ -25,9 +25,9 @@
"bin": { "bin": {
"reskindex": "scripts/reskindex.js" "reskindex": "scripts/reskindex.js"
}, },
"main": "./src/index.js", "main": "./src/index.ts",
"matrix_src_main": "./src/index.js", "matrix_src_main": "./src/index.ts",
"matrix_lib_main": "./lib/index.js", "matrix_lib_main": "./lib/index.ts",
"matrix_lib_typings": "./lib/index.d.ts", "matrix_lib_typings": "./lib/index.d.ts",
"scripts": { "scripts": {
"prepublishOnly": "yarn build", "prepublishOnly": "yarn build",
@ -79,6 +79,7 @@
"highlight.js": "^10.5.0", "highlight.js": "^10.5.0",
"html-entities": "^1.4.0", "html-entities": "^1.4.0",
"is-ip": "^3.1.0", "is-ip": "^3.1.0",
"jszip": "^3.7.0",
"katex": "^0.12.0", "katex": "^0.12.0",
"linkifyjs": "^2.1.9", "linkifyjs": "^2.1.9",
"lodash": "^4.17.20", "lodash": "^4.17.20",
@ -133,6 +134,8 @@
"@types/counterpart": "^0.18.1", "@types/counterpart": "^0.18.1",
"@types/css-font-loading-module": "^0.0.6", "@types/css-font-loading-module": "^0.0.6",
"@types/diff-match-patch": "^1.0.32", "@types/diff-match-patch": "^1.0.32",
"@types/enzyme": "^3.10.9",
"@types/file-saver": "^2.0.3",
"@types/flux": "^3.1.9", "@types/flux": "^3.1.9",
"@types/jest": "^26.0.20", "@types/jest": "^26.0.20",
"@types/linkifyjs": "^2.1.3", "@types/linkifyjs": "^2.1.3",
@ -156,6 +159,7 @@
"chokidar": "^3.5.1", "chokidar": "^3.5.1",
"concurrently": "^5.3.0", "concurrently": "^5.3.0",
"enzyme": "^3.11.0", "enzyme": "^3.11.0",
"enzyme-to-json": "^3.6.2",
"eslint": "7.18.0", "eslint": "7.18.0",
"eslint-config-google": "^0.14.0", "eslint-config-google": "^0.14.0",
"eslint-plugin-matrix-org": "github:matrix-org/eslint-plugin-matrix-org#2306b3d4da4eba908b256014b979f1d3d43d2945", "eslint-plugin-matrix-org": "github:matrix-org/eslint-plugin-matrix-org#2306b3d4da4eba908b256014b979f1d3d43d2945",
@ -166,9 +170,11 @@
"jest-canvas-mock": "^2.3.0", "jest-canvas-mock": "^2.3.0",
"jest-environment-jsdom-sixteen": "^1.0.3", "jest-environment-jsdom-sixteen": "^1.0.3",
"jest-fetch-mock": "^3.0.3", "jest-fetch-mock": "^3.0.3",
"jest-raw-loader": "^1.0.1",
"matrix-mock-request": "^1.2.3", "matrix-mock-request": "^1.2.3",
"matrix-react-test-utils": "^0.2.3", "matrix-react-test-utils": "^0.2.3",
"matrix-web-i18n": "github:matrix-org/matrix-web-i18n", "matrix-web-i18n": "github:matrix-org/matrix-web-i18n",
"raw-loader": "^4.0.2",
"react-test-renderer": "^17.0.2", "react-test-renderer": "^17.0.2",
"rimraf": "^3.0.2", "rimraf": "^3.0.2",
"rrweb-snapshot": "1.1.7", "rrweb-snapshot": "1.1.7",
@ -182,6 +188,7 @@
"@types/react": "17.0.14" "@types/react": "17.0.14"
}, },
"jest": { "jest": {
"snapshotSerializers": ["enzyme-to-json/serializer"],
"testEnvironment": "./__test-utils__/environment.js", "testEnvironment": "./__test-utils__/environment.js",
"testMatch": [ "testMatch": [
"<rootDir>/test/**/*-test.[jt]s?(x)" "<rootDir>/test/**/*-test.[jt]s?(x)"
@ -199,6 +206,7 @@
"decoderWorker\\.min\\.wasm": "<rootDir>/__mocks__/empty.js", "decoderWorker\\.min\\.wasm": "<rootDir>/__mocks__/empty.js",
"waveWorker\\.min\\.js": "<rootDir>/__mocks__/empty.js", "waveWorker\\.min\\.js": "<rootDir>/__mocks__/empty.js",
"workers/(.+)\\.worker\\.ts": "<rootDir>/__mocks__/workerMock.js", "workers/(.+)\\.worker\\.ts": "<rootDir>/__mocks__/workerMock.js",
"^!!raw-loader!.*": "jest-raw-loader",
"RecorderWorklet": "<rootDir>/__mocks__/empty.js" "RecorderWorklet": "<rootDir>/__mocks__/empty.js"
}, },
"transformIgnorePatterns": [ "transformIgnorePatterns": [

View file

@ -39,6 +39,7 @@
@import "./structures/_ViewSource.scss"; @import "./structures/_ViewSource.scss";
@import "./structures/auth/_CompleteSecurity.scss"; @import "./structures/auth/_CompleteSecurity.scss";
@import "./structures/auth/_Login.scss"; @import "./structures/auth/_Login.scss";
@import "./structures/auth/_SetupEncryptionBody.scss";
@import "./views/audio_messages/_AudioPlayer.scss"; @import "./views/audio_messages/_AudioPlayer.scss";
@import "./views/audio_messages/_PlayPauseButton.scss"; @import "./views/audio_messages/_PlayPauseButton.scss";
@import "./views/audio_messages/_PlaybackContainer.scss"; @import "./views/audio_messages/_PlaybackContainer.scss";
@ -73,6 +74,7 @@
@import "./views/dialogs/_ChangelogDialog.scss"; @import "./views/dialogs/_ChangelogDialog.scss";
@import "./views/dialogs/_ChatCreateOrReuseChatDialog.scss"; @import "./views/dialogs/_ChatCreateOrReuseChatDialog.scss";
@import "./views/dialogs/_CommunityPrototypeInviteDialog.scss"; @import "./views/dialogs/_CommunityPrototypeInviteDialog.scss";
@import "./views/dialogs/_ConfirmSpaceUserActionDialog.scss";
@import "./views/dialogs/_ConfirmUserActionDialog.scss"; @import "./views/dialogs/_ConfirmUserActionDialog.scss";
@import "./views/dialogs/_CreateCommunityPrototypeDialog.scss"; @import "./views/dialogs/_CreateCommunityPrototypeDialog.scss";
@import "./views/dialogs/_CreateGroupDialog.scss"; @import "./views/dialogs/_CreateGroupDialog.scss";
@ -82,6 +84,7 @@
@import "./views/dialogs/_DeactivateAccountDialog.scss"; @import "./views/dialogs/_DeactivateAccountDialog.scss";
@import "./views/dialogs/_DevtoolsDialog.scss"; @import "./views/dialogs/_DevtoolsDialog.scss";
@import "./views/dialogs/_EditCommunityPrototypeDialog.scss"; @import "./views/dialogs/_EditCommunityPrototypeDialog.scss";
@import "./views/dialogs/_ExportDialog.scss";
@import "./views/dialogs/_FeedbackDialog.scss"; @import "./views/dialogs/_FeedbackDialog.scss";
@import "./views/dialogs/_ForwardDialog.scss"; @import "./views/dialogs/_ForwardDialog.scss";
@import "./views/dialogs/_GenericFeatureFeedbackDialog.scss"; @import "./views/dialogs/_GenericFeatureFeedbackDialog.scss";
@ -268,6 +271,7 @@
@import "./views/settings/tabs/user/_SecurityUserSettingsTab.scss"; @import "./views/settings/tabs/user/_SecurityUserSettingsTab.scss";
@import "./views/settings/tabs/user/_VoiceUserSettingsTab.scss"; @import "./views/settings/tabs/user/_VoiceUserSettingsTab.scss";
@import "./views/spaces/_SpaceBasicSettings.scss"; @import "./views/spaces/_SpaceBasicSettings.scss";
@import "./views/spaces/_SpaceChildrenPicker.scss";
@import "./views/spaces/_SpaceCreateMenu.scss"; @import "./views/spaces/_SpaceCreateMenu.scss";
@import "./views/spaces/_SpacePublicShare.scss"; @import "./views/spaces/_SpacePublicShare.scss";
@import "./views/terms/_InlineTermsAgreement.scss"; @import "./views/terms/_InlineTermsAgreement.scss";

View file

@ -34,4 +34,5 @@ limitations under the License.
z-index: 0; z-index: 0;
pointer-events: none; pointer-events: none;
overflow: hidden; overflow: hidden;
user-select: none;
} }

View file

@ -422,7 +422,7 @@ $SpaceRoomViewInnerWidth: 428px;
.mx_SpaceRoomView_inviteTeammates { .mx_SpaceRoomView_inviteTeammates {
// XXX remove this when spaces leaves Beta // XXX remove this when spaces leaves Beta
.mx_SpaceRoomView_inviteTeammates_betaDisclaimer { .mx_SpaceRoomView_inviteTeammates_betaDisclaimer {
padding: 58px 16px 16px; padding: 16px;
position: relative; position: relative;
border-radius: 8px; border-radius: 8px;
background-color: $header-panel-bg-color; background-color: $header-panel-bg-color;
@ -465,8 +465,13 @@ $SpaceRoomViewInnerWidth: 428px;
} }
} }
.mx_SpaceRoomView_inviteTeammates_inviteDialogButton::before { .mx_SpaceRoomView_inviteTeammates_inviteDialogButton {
color: $accent-color;
&::before {
mask-image: url('$(res)/img/element-icons/room/invite.svg'); mask-image: url('$(res)/img/element-icons/room/invite.svg');
background-color: $accent-color;
}
} }
} }
} }

View file

@ -122,7 +122,7 @@ limitations under the License.
float: right; float: right;
font-size: $font-12px; font-size: $font-12px;
line-height: $font-22px; line-height: $font-22px;
color: $muted-fg-color; color: $secondary-content;
} }
} }

View file

@ -33,6 +33,19 @@ limitations under the License.
margin: 0 auto; margin: 0 auto;
} }
.mx_CompleteSecurity_skip {
mask: url('$(res)/img/feather-customised/cancel.svg');
mask-repeat: no-repeat;
mask-position: center;
mask-size: cover;
width: 18px;
height: 18px;
background-color: $dialog-close-fg-color;
cursor: pointer;
position: absolute;
right: 24px;
}
.mx_CompleteSecurity_body { .mx_CompleteSecurity_body {
font-size: $font-15px; font-size: $font-15px;
} }

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2019 The Matrix.org Foundation C.I.C. Copyright 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.
@ -14,9 +14,11 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { createContext } from "react"; .mx_SetupEncryptionBody_reset {
import { MatrixClient } from "matrix-js-sdk/src/client"; color: $light-fg-color;
margin-top: $font-14px;
const MatrixClientContext = createContext<MatrixClient>(undefined); a.mx_SetupEncryptionBody_reset_link:is(:link, :hover, :visited) {
MatrixClientContext.displayName = "MatrixClientContext"; color: $warning-color;
export default MatrixClientContext; }
}

View file

@ -39,7 +39,7 @@ limitations under the License.
&.mx_Waveform_bar_100pct { &.mx_Waveform_bar_100pct {
// Small animation to remove the mechanical feel of progress // Small animation to remove the mechanical feel of progress
transition: background-color 250ms ease; transition: background-color 250ms ease;
background-color: $message-body-panel-fg-color; background-color: $secondary-content;
} }
} }
} }

View file

@ -58,10 +58,6 @@ limitations under the License.
background-color: $authpage-body-bg-color; background-color: $authpage-body-bg-color;
} }
.mx_Field label {
color: $authpage-primary-color;
}
.mx_Field_labelAlwaysTopLeft label, .mx_Field_labelAlwaysTopLeft label,
.mx_Field select + label /* Always show a select's label on top to not collide with the value */, .mx_Field select + label /* Always show a select's label on top to not collide with the value */,
.mx_Field input:focus + label, .mx_Field input:focus + label,

View file

@ -75,7 +75,7 @@ limitations under the License.
@mixin ProgressBarBorderRadius 8px; @mixin ProgressBarBorderRadius 8px;
} }
.mx_AddExistingToSpace_progressText { .mx_AddExistingToSpaceDialog_progressText {
margin-top: 8px; margin-top: 8px;
font-size: $font-15px; font-size: $font-15px;
line-height: $font-24px; line-height: $font-24px;

View file

@ -0,0 +1,66 @@
/*
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_ConfirmSpaceUserActionDialog_wrapper {
.mx_Dialog {
display: flex;
flex-direction: column;
padding: 24px 32px;
}
}
.mx_ConfirmSpaceUserActionDialog {
width: 440px;
display: flex;
flex-direction: column;
flex-wrap: nowrap;
height: 520px;
.mx_Dialog_content {
margin: 12px 0;
flex-grow: 1;
overflow-y: auto;
}
.mx_ConfirmUserActionDialog_reasonField {
margin-bottom: 12px;
}
.mx_ConfirmSpaceUserActionDialog_warning {
position: relative;
border-radius: 8px;
padding: 12px 8px 12px 42px;
background-color: $header-panel-bg-color;
font-size: $font-12px;
line-height: $font-15px;
color: $secondary-content;
&::before {
content: '';
position: absolute;
left: 10px;
top: calc(50% - 8px); // vertical centering
height: 16px;
width: 16px;
background-color: $secondary-content;
mask-repeat: no-repeat;
mask-size: contain;
mask-image: url('$(res)/img/element-icons/room/room-summary.svg');
mask-position: 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_ConfirmUserActionDialog .mx_Dialog_content { .mx_ConfirmUserActionDialog .mx_Dialog_content .mx_ConfirmUserActionDialog_user {
min-height: 48px; min-height: 48px;
margin-bottom: 24px; margin-bottom: 24px;
} }
@ -22,10 +22,10 @@ limitations under the License.
.mx_ConfirmUserActionDialog_avatar { .mx_ConfirmUserActionDialog_avatar {
float: left; float: left;
margin-right: 20px; margin-right: 20px;
margin-top: -2px;
} }
.mx_ConfirmUserActionDialog_name { .mx_ConfirmUserActionDialog_name {
padding-top: 2px;
font-size: $font-18px; font-size: $font-18px;
} }
@ -37,16 +37,4 @@ limitations under the License.
font-size: $font-14px; font-size: $font-14px;
color: $primary-content; color: $primary-content;
background-color: $background; background-color: $background;
border-radius: 3px;
border: solid 1px $input-border-color;
line-height: $font-36px;
padding-left: 16px;
padding-right: 16px;
padding-top: 1px;
padding-bottom: 1px;
margin-bottom: 24px;
width: 90%;
} }

View file

@ -74,6 +74,7 @@ limitations under the License.
font-size: $font-12px; font-size: $font-12px;
line-height: $font-15px; line-height: $font-15px;
color: $secondary-content; color: $secondary-content;
margin-top: -13px; // match height of buttons to prevent height changing
.mx_ProgressBar { .mx_ProgressBar {
height: 8px; height: 8px;

View file

@ -0,0 +1,91 @@
/*
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_ExportDialog {
.mx_ExportDialog_subheading {
font-size: $font-16px;
display: block;
font-family: $font-family;
font-weight: $font-semi-bold;
color: $primary-content;
margin-top: 18px;
margin-bottom: 12px;
}
&.mx_ExportDialog_Exporting {
.mx_ExportDialog_options {
pointer-events: none;
}
.mx_Field_select::before {
display: none;
}
.mx_RadioButton input[type="radio"]:checked + div > div {
background: $greyed-fg-color;
}
.mx_RadioButton input[type=radio]:checked + div {
border-color: unset;
}
.mx_Field_valid.mx_Field label,
.mx_Field_valid.mx_Field:focus-within label {
color: unset;
}
.mx_Field_valid.mx_Field, .mx_Field_valid.mx_Field:focus-within {
border-color: $input-border-color;
}
.mx_Checkbox input[type="checkbox"]:checked + label > .mx_Checkbox_background {
background: $greyed-fg-color;
border-color: $greyed-fg-color;
}
}
.mx_ExportDialog_progress {
.mx_Dialog_buttons {
margin-top: unset;
margin-left: 18px;
}
.mx_Spinner {
width: unset;
height: unset;
flex: unset;
margin-right: 10px;
}
display: flex;
flex-direction: row;
justify-content: flex-end;
align-items: center;
}
.mx_RadioButton > .mx_RadioButton_content {
margin-top: 5px;
margin-bottom: 5px;
}
.mx_Field {
width: 256px;
}
.mx_Field_postfix {
padding: 9px 10px;
}
}

View file

@ -28,7 +28,7 @@ limitations under the License.
.mx_InviteDialog_editor { .mx_InviteDialog_editor {
flex: 1; flex: 1;
width: 100%; // Needed to make the Field inside grow width: 100%; // Needed to make the Field inside grow
background-color: $user-tile-hover-bg-color; background-color: $header-panel-bg-color;
border-radius: 4px; border-radius: 4px;
min-height: 25px; min-height: 25px;
padding-left: 8px; padding-left: 8px;
@ -167,7 +167,7 @@ limitations under the License.
padding: 5px 10px; padding: 5px 10px;
&:hover { &:hover {
background-color: $user-tile-hover-bg-color; background-color: $header-panel-bg-color;
border-radius: 4px; border-radius: 4px;
} }
@ -395,7 +395,7 @@ limitations under the License.
left: -24px; left: -24px;
padding-left: 24px; padding-left: 24px;
padding-right: 24px; padding-right: 24px;
border-top: 1px solid $message-body-panel-bg-color; border-top: 1px solid $quinary-content;
display: flex; display: flex;
flex-direction: row; flex-direction: row;

View file

@ -27,33 +27,13 @@ limitations under the License.
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex-wrap: nowrap; flex-wrap: nowrap;
max-height: 520px; height: 520px;
.mx_Dialog_content { .mx_Dialog_content {
flex-grow: 1; flex-grow: 1;
margin: 0; margin: 0;
overflow-y: auto; overflow-y: auto;
.mx_RadioButton + .mx_RadioButton {
margin-top: 16px;
}
.mx_SearchBox {
// To match the space around the title
margin: 0 0 15px 0;
flex-grow: 0;
border-radius: 8px;
}
.mx_LeaveSpaceDialog_noResults {
display: block;
margin-top: 24px;
}
.mx_LeaveSpaceDialog_section {
margin: 16px 0;
}
.mx_LeaveSpaceDialog_section_warning { .mx_LeaveSpaceDialog_section_warning {
position: relative; position: relative;
border-radius: 8px; border-radius: 8px;

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,6 +17,22 @@ limitations under the License.
.mx_RoomUpgradeWarningDialog { .mx_RoomUpgradeWarningDialog {
max-width: 38vw; max-width: 38vw;
width: 38vw; width: 38vw;
.mx_RoomUpgradeWarningDialog_progress {
.mx_ProgressBar {
height: 8px;
width: 100%;
@mixin ProgressBarBorderRadius 8px;
}
.mx_RoomUpgradeWarningDialog_progressText {
margin-top: 8px;
font-size: $font-15px;
line-height: $font-24px;
color: $primary-content;
}
}
} }
.mx_RoomUpgradeWarningDialog .mx_SettingsFlag { .mx_RoomUpgradeWarningDialog .mx_SettingsFlag {

View file

@ -38,7 +38,7 @@ limitations under the License.
} }
& + .mx_SettingsTab_subheading { & + .mx_SettingsTab_subheading {
border-top: 1px solid $message-body-panel-bg-color; border-top: 1px solid $quinary-content;
margin-top: 0; margin-top: 0;
padding-top: 24px; padding-top: 24px;
} }

View file

@ -100,7 +100,6 @@ limitations under the License.
color 0.25s ease-out 0.1s, color 0.25s ease-out 0.1s,
transform 0.25s ease-out 0.1s, transform 0.25s ease-out 0.1s,
background-color 0.25s ease-out 0.1s; background-color 0.25s ease-out 0.1s;
color: $primary-content;
background-color: transparent; background-color: transparent;
font-size: $font-14px; font-size: $font-14px;
transform: translateY(0); transform: translateY(0);

View file

@ -63,7 +63,7 @@ limitations under the License.
cursor: pointer; cursor: pointer;
.mx_MFileBody_info_icon { .mx_MFileBody_info_icon {
background-color: $message-body-panel-icon-bg-color; background-color: $system;
border-radius: 20px; border-radius: 20px;
display: inline-block; display: inline-block;
width: 32px; width: 32px;
@ -78,7 +78,7 @@ limitations under the License.
mask-position: center; mask-position: center;
mask-size: cover; mask-size: cover;
mask-image: url('$(res)/img/element-icons/room/composer/attach.svg'); mask-image: url('$(res)/img/element-icons/room/composer/attach.svg');
background-color: $message-body-panel-icon-fg-color; background-color: $secondary-content;
width: 15px; width: 15px;
height: 15px; height: 15px;

View file

@ -18,11 +18,11 @@ limitations under the License.
// have unique styles). // have unique styles).
.mx_MediaBody { .mx_MediaBody {
background-color: $message-body-panel-bg-color; background-color: $quinary-content;
border-radius: 12px; border-radius: 12px;
max-width: 243px; // use max-width instead of width so it fits within right panels max-width: 243px; // use max-width instead of width so it fits within right panels
color: $message-body-panel-fg-color; color: $secondary-content;
font-size: $font-14px; font-size: $font-14px;
line-height: $font-24px; line-height: $font-24px;

View file

@ -243,3 +243,7 @@ limitations under the License.
.mx_RoomSummaryCard_icon_settings::before { .mx_RoomSummaryCard_icon_settings::before {
mask-image: url('$(res)/img/element-icons/settings.svg'); mask-image: url('$(res)/img/element-icons/settings.svg');
} }
.mx_RoomSummaryCard_icon_export::before {
mask-image: url('$(res)/img/element-icons/export.svg');
}

View file

@ -87,7 +87,7 @@ limitations under the License.
} }
.mx_VerificationPanel_QRPhase_startOption { .mx_VerificationPanel_QRPhase_startOption {
background-color: $user-tile-hover-bg-color; background-color: $header-panel-bg-color;
border-radius: 10px; border-radius: 10px;
flex: 1; flex: 1;
display: flex; display: flex;

View file

@ -252,6 +252,10 @@ limitations under the License.
mask-image: url('$(res)/img/element-icons/room/composer/attach.svg'); mask-image: url('$(res)/img/element-icons/room/composer/attach.svg');
} }
.mx_MessageComposer_poll::before {
mask-image: url('$(res)/img/element-icons/room/composer/poll.svg');
}
.mx_MessageComposer_voiceMessage::before { .mx_MessageComposer_voiceMessage::before {
mask-image: url('$(res)/img/voip/mic-on-mask.svg'); mask-image: url('$(res)/img/voip/mic-on-mask.svg');
} }

View file

@ -22,6 +22,12 @@ limitations under the License.
display: none; display: none;
} }
&:not(.mx_RoomSublist_minimized) {
.mx_RoomSublist_headerContainer {
height: auto;
}
}
.mx_RoomSublist_headerContainer { .mx_RoomSublist_headerContainer {
// Create a flexbox to make alignment easy // Create a flexbox to make alignment easy
display: flex; display: flex;
@ -41,9 +47,7 @@ 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;
// Allow the container to collapse on itself if its children height: 24px;
// are not in the normal document flow
max-height: 24px;
color: $roomlist-header-color; color: $roomlist-header-color;
.mx_RoomSublist_stickable { .mx_RoomSublist_stickable {
@ -172,14 +176,6 @@ limitations under the License.
} }
} }
// In the general case, we reserve space for each sublist header to prevent
// scroll jumps when they become sticky. However, that leaves a gap when
// scrolled to the top above the first sublist (whose header can only ever
// stick to top), so we make sure to exclude the first visible sublist.
&:not(.mx_RoomSublist_hidden) ~ .mx_RoomSublist .mx_RoomSublist_headerContainer {
height: 24px;
}
.mx_RoomSublist_resizeBox { .mx_RoomSublist_resizeBox {
position: relative; position: relative;
@ -395,7 +391,7 @@ limitations under the License.
.mx_RoomSublist_skeletonUI { .mx_RoomSublist_skeletonUI {
position: relative; position: relative;
margin-left: 4px; margin-left: 4px;
height: 288px; height: 240px;
&::before { &::before {
background: $roomsublist-skeleton-ui-bg; background: $roomsublist-skeleton-ui-bg;
@ -410,3 +406,8 @@ limitations under the License.
mask-image: url('$(res)/img/element-icons/roomlist/skeleton-ui.svg'); mask-image: url('$(res)/img/element-icons/roomlist/skeleton-ui.svg');
} }
} }
.mx_RoomSublist_minimized .mx_RoomSublist_skeletonUI {
width: 32px; // cut off the horizontal lines in the svg
margin-left: 10px; // align with sublist + buttons
}

View file

@ -17,7 +17,10 @@ limitations under the License.
.mx_DevicesPanel { .mx_DevicesPanel {
display: table; display: table;
table-layout: fixed; table-layout: fixed;
width: 880px; // Normally the panel is 880px, however this can easily overflow the container.
// TODO: Fix the table to not be squishy
width: auto;
max-width: 880px;
border-spacing: 10px; border-spacing: 10px;
} }

View file

@ -67,5 +67,7 @@ limitations under the License.
> .mx_AccessibleButton_kind_link { > .mx_AccessibleButton_kind_link {
padding-left: 0; // to align with left side padding-left: 0; // to align with left side
padding-right: 0;
margin-right: 10px;
} }
} }

View file

@ -14,13 +14,6 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
.mx_SecurityUserSettingsTab .mx_DevicesPanel {
// Normally the panel is 880px, however this can easily overflow the container.
// TODO: Fix the table to not be squishy
width: auto;
max-width: 880px;
}
.mx_SecurityUserSettingsTab_deviceInfo { .mx_SecurityUserSettingsTab_deviceInfo {
display: table; display: table;
padding-left: 0; padding-left: 0;

View file

@ -0,0 +1,35 @@
/*
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_SpaceChildrenPicker {
margin: 16px 0;
.mx_RadioButton + .mx_RadioButton {
margin-top: 16px;
}
.mx_SearchBox {
// To match the space around the title
margin: 0 0 15px 0;
flex-grow: 0;
border-radius: 8px;
}
.mx_SpaceChildrenPicker_noResults {
display: block;
margin-top: 24px;
}
}

View file

@ -0,0 +1,14 @@
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47716 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22ZM12.7071 17.7071C12.6112 17.803 12.5007 17.8753 12.3828 17.9241L11.2929 17.7071L11.2925 17.7067L7.2929 13.7071C6.90237 13.3166 6.90237 12.6834 7.2929 12.2929C7.68342 11.9024 8.31658 11.9024 8.70711 12.2929L11 14.5858L11 7C11 6.44771 11.4477 6 12 6C12.5523 6 13 6.44771 13 7L13 14.5858L15.2929 12.2929C15.6834 11.9024 16.3166 11.9024 16.7071 12.2929C17.0976 12.6834 17.0976 13.3166 16.7071 13.7071L12.7071 17.7071ZM12.3828 17.9241L11.295 17.7092C11.4758 17.8889 11.7249 18 12 18C12.1356 18 12.2649 17.973 12.3828 17.9241Z"
fill="#C1C6CD"
/>
</svg>

After

Width:  |  Height:  |  Size: 821 B

View file

@ -0,0 +1,5 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 9.5C3 9.22386 3.22386 9 3.5 9H6.5C6.77614 9 7 9.22386 7 9.5V22H3V9.5Z" fill="#C1C6CD"/>
<path d="M17 13.5C17 13.2239 17.2239 13 17.5 13H20.5C20.7761 13 21 13.2239 21 13.5V22H17V13.5Z" fill="#C1C6CD"/>
<path d="M10 2.5C10 2.22386 10.2239 2 10.5 2H13.5C13.7761 2 14 2.22386 14 2.5V22H10V2.5Z" fill="#C1C6CD"/>
</svg>

After

Width:  |  Height:  |  Size: 423 B

View file

@ -206,23 +206,13 @@ $kbd-border-color: #000000;
$tooltip-timeline-bg-color: $groupFilterPanel-bg-color; $tooltip-timeline-bg-color: $groupFilterPanel-bg-color;
$tooltip-timeline-fg-color: $primary-content; $tooltip-timeline-fg-color: $primary-content;
$interactive-tooltip-bg-color: $background;
$interactive-tooltip-fg-color: $primary-content;
$breadcrumb-placeholder-bg-color: #272c35; $breadcrumb-placeholder-bg-color: #272c35;
$user-tile-hover-bg-color: $header-panel-bg-color;
$message-body-panel-fg-color: $secondary-content;
$message-body-panel-bg-color: $quinary-content;
$message-body-panel-icon-bg-color: $system;
$message-body-panel-icon-fg-color: $secondary-content;
$voice-record-stop-border-color: $quaternary-content; $voice-record-stop-border-color: $quaternary-content;
$voice-record-waveform-incomplete-fg-color: $quaternary-content; $voice-record-waveform-incomplete-fg-color: $quaternary-content;
$voice-record-icon-color: $quaternary-content; $voice-record-icon-color: $quaternary-content;
$voice-playback-button-bg-color: $message-body-panel-icon-bg-color; $voice-playback-button-bg-color: $system;
$voice-playback-button-fg-color: $message-body-panel-icon-fg-color; $voice-playback-button-fg-color: $secondary-content;
// Appearance tab colors // Appearance tab colors
$appearance-tab-border-color: $room-highlight-color; $appearance-tab-border-color: $room-highlight-color;

View file

@ -202,18 +202,8 @@ $kbd-border-color: #000000;
$tooltip-timeline-bg-color: $groupFilterPanel-bg-color; $tooltip-timeline-bg-color: $groupFilterPanel-bg-color;
$tooltip-timeline-fg-color: #ffffff; $tooltip-timeline-fg-color: #ffffff;
$interactive-tooltip-bg-color: $base-color;
$interactive-tooltip-fg-color: #ffffff;
$breadcrumb-placeholder-bg-color: #272c35; $breadcrumb-placeholder-bg-color: #272c35;
$user-tile-hover-bg-color: $header-panel-bg-color;
$message-body-panel-fg-color: $secondary-fg-color;
$message-body-panel-bg-color: #394049;
$message-body-panel-icon-fg-color: $secondary-fg-color;
$message-body-panel-icon-bg-color: #21262C;
// See non-legacy dark for variable information // See non-legacy dark for variable information
$voice-record-stop-border-color: #6F7882; $voice-record-stop-border-color: #6F7882;
$voice-record-waveform-incomplete-fg-color: #6F7882; $voice-record-waveform-incomplete-fg-color: #6F7882;

View file

@ -12,9 +12,6 @@ $font-family: 'Nunito', 'Twemoji', 'Apple Color Emoji', 'Segoe UI Emoji', 'Arial
$monospace-font-family: 'Inconsolata', 'Twemoji', 'Apple Color Emoji', 'Segoe UI Emoji', 'Courier', monospace, 'Noto Color Emoji'; $monospace-font-family: 'Inconsolata', 'Twemoji', 'Apple Color Emoji', 'Segoe UI Emoji', 'Courier', monospace, 'Noto Color Emoji';
// Colors from Figma Compound https://www.figma.com/file/X4XTH9iS2KGJ2wFKDqkyed/Compound?node-id=557%3A0
$system: #F4F6FA;
// unified palette // unified palette
// try to use these colors when possible // try to use these colors when possible
$accent-color: #03b381; $accent-color: #03b381;
@ -32,12 +29,22 @@ $primary-bg-color: #ffffff;
$muted-fg-color: #61708b; // Commonly used in headings and relevant alt text $muted-fg-color: #61708b; // Commonly used in headings and relevant alt text
// Legacy theme backports // Legacy theme backports
$accent: #0DBD8B;
$alert: #FF5B55;
$links: #0086e6;
$primary-content: $primary-fg-color; $primary-content: $primary-fg-color;
$secondary-content: $secondary-fg-color; $secondary-content: $secondary-fg-color;
$tertiary-content: $tertiary-fg-color; $tertiary-content: $tertiary-fg-color;
$quaternary-content: #C1C6CD; $quaternary-content: #C1C6CD;
$quinary-content: #e3e8f0; $quinary-content: #e3e8f0;
$system: #F4F6FA;
$background: $primary-bg-color; $background: $primary-bg-color;
$panels: rgba($system, 0.9);
$panel-base: #8D97A5; // This color is not intended for use in the app
$panel-selected: rgba($tertiary-content, 0.3);
$panel-hover: rgba($tertiary-content, 0.1);
$panel-actions: rgba($tertiary-content, 0.2);
$space-nav: rgba($tertiary-content, 0.15);
// used for dialog box text // used for dialog box text
$light-fg-color: #747474; $light-fg-color: #747474;
@ -326,26 +333,16 @@ $kbd-border-color: $reaction-row-button-border-color;
$tooltip-timeline-bg-color: $groupFilterPanel-bg-color; $tooltip-timeline-bg-color: $groupFilterPanel-bg-color;
$tooltip-timeline-fg-color: #ffffff; $tooltip-timeline-fg-color: #ffffff;
$interactive-tooltip-bg-color: #27303a;
$interactive-tooltip-fg-color: #ffffff;
$breadcrumb-placeholder-bg-color: #e8eef5; $breadcrumb-placeholder-bg-color: #e8eef5;
$user-tile-hover-bg-color: $header-panel-bg-color;
$message-body-panel-fg-color: $secondary-fg-color;
$message-body-panel-bg-color: #E3E8F0;
$message-body-panel-icon-fg-color: $secondary-fg-color;
$message-body-panel-icon-bg-color: $system;
// See non-legacy _light for variable information // See non-legacy _light for variable information
$voice-record-stop-symbol-color: #ff4b55; $voice-record-stop-symbol-color: #ff4b55;
$voice-record-live-circle-color: #ff4b55; $voice-record-live-circle-color: #ff4b55;
$voice-record-stop-border-color: #E3E8F0; $voice-record-stop-border-color: #E3E8F0;
$voice-record-waveform-incomplete-fg-color: #C1C6CD; $voice-record-waveform-incomplete-fg-color: #C1C6CD;
$voice-record-icon-color: $tertiary-fg-color; $voice-record-icon-color: $tertiary-fg-color;
$voice-playback-button-bg-color: $message-body-panel-icon-bg-color; $voice-playback-button-bg-color: $system;
$voice-playback-button-fg-color: $message-body-panel-icon-fg-color; $voice-playback-button-fg-color: $secondary-content;
// FontSlider colors // FontSlider colors
$appearance-tab-border-color: $input-darker-bg-color; $appearance-tab-border-color: $input-darker-bg-color;

View file

@ -16,6 +16,25 @@ limitations under the License.
$font-family: var(--font-family, $font-family); $font-family: var(--font-family, $font-family);
$monospace-font-family: var(--font-family-monospace, $monospace-font-family); $monospace-font-family: var(--font-family-monospace, $monospace-font-family);
// Colors from Figma Compound https://www.figma.com/file/X4XTH9iS2KGJ2wFKDqkyed/Compound?node-id=559%3A741
$accent: var(--accent, $accent);
$alert: var(--alert, $alert);
$links: var(--links, $links);
$primary-content: var(--primary-content, $primary-content);
$secondary-content: var(--secondary-content, $secondary-content);
$tertiary-content: var(--tertiary-content, $tertiary-content);
$quaternary-content: var(--quaternary-content, $quaternary-content);
$quinary-content: var(--quinary-content, $quinary-content);
$system: var(--system, $system);
$background: var(--background, $background);
$panels: rgba($system, 0.9);
$panel-base: var(--panel-base, $tertiary-content); // This color is not intended for use in the app
$panel-selected: rgba($panel-base, 0.3);
$panel-hover: rgba($panel-base, 0.1);
$panel-actions: rgba($panel-base, 0.2);
$space-nav: rgba($panel-base, 0.1);
// //
// --accent-color // --accent-color
$accent-color: var(--accent-color); $accent-color: var(--accent-color);
@ -48,7 +67,6 @@ $roomheader-bg-color: var(--timeline-background-color);
$roomtile-selected-bg-color: var(--roomlist-highlights-color); $roomtile-selected-bg-color: var(--roomlist-highlights-color);
// //
// --sidebar-color // --sidebar-color
$interactive-tooltip-bg-color: var(--sidebar-color);
$groupFilterPanel-bg-color: var(--sidebar-color); $groupFilterPanel-bg-color: var(--sidebar-color);
$tooltip-timeline-bg-color: var(--sidebar-color); $tooltip-timeline-bg-color: var(--sidebar-color);
$dialog-backdrop-color: var(--sidebar-color-50pct); $dialog-backdrop-color: var(--sidebar-color-50pct);

View file

@ -326,18 +326,8 @@ $inverted-bg-color: #27303a;
$tooltip-timeline-bg-color: $inverted-bg-color; $tooltip-timeline-bg-color: $inverted-bg-color;
$tooltip-timeline-fg-color: $background; $tooltip-timeline-fg-color: $background;
$interactive-tooltip-bg-color: #27303a;
$interactive-tooltip-fg-color: $background;
$breadcrumb-placeholder-bg-color: #e8eef5; $breadcrumb-placeholder-bg-color: #e8eef5;
$user-tile-hover-bg-color: $header-panel-bg-color;
$message-body-panel-fg-color: $secondary-content;
$message-body-panel-bg-color: $quinary-content;
$message-body-panel-icon-bg-color: $system;
$message-body-panel-icon-fg-color: $secondary-content;
// These two don't change between themes. They are the $warning-color, but we don't // These two don't change between themes. They are the $warning-color, but we don't
// want custom themes to affect them by accident. // want custom themes to affect them by accident.
$voice-record-stop-symbol-color: #ff4b55; $voice-record-stop-symbol-color: #ff4b55;
@ -346,8 +336,8 @@ $voice-record-live-circle-color: #ff4b55;
$voice-record-stop-border-color: $quinary-content; $voice-record-stop-border-color: $quinary-content;
$voice-record-waveform-incomplete-fg-color: $quaternary-content; $voice-record-waveform-incomplete-fg-color: $quaternary-content;
$voice-record-icon-color: $tertiary-content; $voice-record-icon-color: $tertiary-content;
$voice-playback-button-bg-color: $message-body-panel-icon-bg-color; $voice-playback-button-bg-color: $system;
$voice-playback-button-fg-color: $message-body-panel-icon-fg-color; $voice-playback-button-fg-color: $secondary-content;
// FontSlider colors // FontSlider colors
$appearance-tab-border-color: $input-darker-bg-color; $appearance-tab-border-color: $input-darker-bg-color;

View file

@ -51,6 +51,7 @@ import { SetupEncryptionStore } from "../stores/SetupEncryptionStore";
import { RoomScrollStateStore } from "../stores/RoomScrollStateStore"; import { RoomScrollStateStore } from "../stores/RoomScrollStateStore";
import { ConsoleLogger, IndexedDBLogStore } from "../rageshake/rageshake"; import { ConsoleLogger, IndexedDBLogStore } from "../rageshake/rageshake";
import ActiveWidgetStore from "../stores/ActiveWidgetStore"; import ActiveWidgetStore from "../stores/ActiveWidgetStore";
import { Skinner } from "../Skinner";
/* eslint-disable @typescript-eslint/naming-convention */ /* eslint-disable @typescript-eslint/naming-convention */
@ -95,6 +96,7 @@ declare global {
mxSetupEncryptionStore?: SetupEncryptionStore; mxSetupEncryptionStore?: SetupEncryptionStore;
mxRoomScrollStateStore?: RoomScrollStateStore; mxRoomScrollStateStore?: RoomScrollStateStore;
mxActiveWidgetStore?: ActiveWidgetStore; mxActiveWidgetStore?: ActiveWidgetStore;
mxSkinner?: Skinner;
mxOnRecaptchaLoaded?: () => void; mxOnRecaptchaLoaded?: () => void;
electron?: Electron; electron?: Electron;
} }
@ -157,6 +159,10 @@ declare global {
setSinkId(outputId: string); setSinkId(outputId: string);
} }
interface HTMLStyleElement {
disabled?: boolean;
}
// Add Chrome-specific `instant` ScrollBehaviour // Add Chrome-specific `instant` ScrollBehaviour
type _ScrollBehavior = ScrollBehavior | "instant"; type _ScrollBehavior = ScrollBehavior | "instant";

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2020 The Matrix.org Foundation C.I.C. Copyright 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.
@ -14,11 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { declare module '!!raw-loader!*' {
ALL_MESSAGES, const contents: string;
ALL_MESSAGES_LOUD, export default contents;
MENTIONS_ONLY, }
MUTE,
} from "./RoomNotifs";
export type Volume = ALL_MESSAGES_LOUD | ALL_MESSAGES | MENTIONS_ONLY | MUTE;

View file

@ -17,13 +17,14 @@ limitations under the License.
*/ */
import { MatrixClientPeg } from './MatrixClientPeg'; import { MatrixClientPeg } from './MatrixClientPeg';
import * as sdk from './index';
import Modal from './Modal'; import Modal from './Modal';
import { _t } from './languageHandler'; import { _t } from './languageHandler';
import IdentityAuthClient from './IdentityAuthClient'; import IdentityAuthClient from './IdentityAuthClient';
import { SSOAuthEntry } from "./components/views/auth/InteractiveAuthEntryComponents"; import { SSOAuthEntry } from "./components/views/auth/InteractiveAuthEntryComponents";
import { IRequestMsisdnTokenResponse, IRequestTokenResponse } from "matrix-js-sdk/src";
import InteractiveAuthDialog from "./components/views/dialogs/InteractiveAuthDialog";
function getIdServerDomain() { function getIdServerDomain(): string {
return MatrixClientPeg.get().idBaseUrl.split("://")[1]; return MatrixClientPeg.get().idBaseUrl.split("://")[1];
} }
@ -40,10 +41,13 @@ function getIdServerDomain() {
* https://gist.github.com/jryans/839a09bf0c5a70e2f36ed990d50ed928 * https://gist.github.com/jryans/839a09bf0c5a70e2f36ed990d50ed928
*/ */
export default class AddThreepid { export default class AddThreepid {
private sessionId: string;
private submitUrl: string;
private clientSecret: string;
private bind: boolean;
constructor() { constructor() {
this.clientSecret = MatrixClientPeg.get().generateClientSecret(); this.clientSecret = MatrixClientPeg.get().generateClientSecret();
this.sessionId = null;
this.submitUrl = null;
} }
/** /**
@ -52,7 +56,7 @@ export default class AddThreepid {
* @param {string} emailAddress The email address to add * @param {string} emailAddress The email address to add
* @return {Promise} Resolves when the email has been sent. Then call checkEmailLinkClicked(). * @return {Promise} Resolves when the email has been sent. Then call checkEmailLinkClicked().
*/ */
addEmailAddress(emailAddress) { public addEmailAddress(emailAddress: string): Promise<IRequestTokenResponse> {
return MatrixClientPeg.get().requestAdd3pidEmailToken(emailAddress, this.clientSecret, 1).then((res) => { return MatrixClientPeg.get().requestAdd3pidEmailToken(emailAddress, this.clientSecret, 1).then((res) => {
this.sessionId = res.sid; this.sessionId = res.sid;
return res; return res;
@ -72,7 +76,7 @@ export default class AddThreepid {
* @param {string} emailAddress The email address to add * @param {string} emailAddress The email address to add
* @return {Promise} Resolves when the email has been sent. Then call checkEmailLinkClicked(). * @return {Promise} Resolves when the email has been sent. Then call checkEmailLinkClicked().
*/ */
async bindEmailAddress(emailAddress) { public async bindEmailAddress(emailAddress: string): Promise<IRequestTokenResponse> {
this.bind = true; this.bind = true;
if (await MatrixClientPeg.get().doesServerSupportSeparateAddAndBind()) { if (await MatrixClientPeg.get().doesServerSupportSeparateAddAndBind()) {
// For separate bind, request a token directly from the IS. // For separate bind, request a token directly from the IS.
@ -105,7 +109,7 @@ export default class AddThreepid {
* @param {string} phoneNumber The national or international formatted phone number to add * @param {string} phoneNumber The national or international formatted phone number to add
* @return {Promise} Resolves when the text message has been sent. Then call haveMsisdnToken(). * @return {Promise} Resolves when the text message has been sent. Then call haveMsisdnToken().
*/ */
addMsisdn(phoneCountry, phoneNumber) { public addMsisdn(phoneCountry: string, phoneNumber: string): Promise<IRequestMsisdnTokenResponse> {
return MatrixClientPeg.get().requestAdd3pidMsisdnToken( return MatrixClientPeg.get().requestAdd3pidMsisdnToken(
phoneCountry, phoneNumber, this.clientSecret, 1, phoneCountry, phoneNumber, this.clientSecret, 1,
).then((res) => { ).then((res) => {
@ -129,7 +133,7 @@ export default class AddThreepid {
* @param {string} phoneNumber The national or international formatted phone number to add * @param {string} phoneNumber The national or international formatted phone number to add
* @return {Promise} Resolves when the text message has been sent. Then call haveMsisdnToken(). * @return {Promise} Resolves when the text message has been sent. Then call haveMsisdnToken().
*/ */
async bindMsisdn(phoneCountry, phoneNumber) { public async bindMsisdn(phoneCountry: string, phoneNumber: string): Promise<IRequestMsisdnTokenResponse> {
this.bind = true; this.bind = true;
if (await MatrixClientPeg.get().doesServerSupportSeparateAddAndBind()) { if (await MatrixClientPeg.get().doesServerSupportSeparateAddAndBind()) {
// For separate bind, request a token directly from the IS. // For separate bind, request a token directly from the IS.
@ -161,7 +165,7 @@ export default class AddThreepid {
* with a "message" property which contains a human-readable message detailing why * with a "message" property which contains a human-readable message detailing why
* the request failed. * the request failed.
*/ */
async checkEmailLinkClicked() { public async checkEmailLinkClicked(): Promise<any[]> {
try { try {
if (await MatrixClientPeg.get().doesServerSupportSeparateAddAndBind()) { if (await MatrixClientPeg.get().doesServerSupportSeparateAddAndBind()) {
if (this.bind) { if (this.bind) {
@ -175,7 +179,7 @@ export default class AddThreepid {
}); });
} else { } else {
try { try {
await this._makeAddThreepidOnlyRequest(); await this.makeAddThreepidOnlyRequest();
// The spec has always required this to use UI auth but synapse briefly // The spec has always required this to use UI auth but synapse briefly
// implemented it without, so this may just succeed and that's OK. // implemented it without, so this may just succeed and that's OK.
@ -186,9 +190,6 @@ export default class AddThreepid {
throw e; throw e;
} }
// pop up an interactive auth dialog
const InteractiveAuthDialog = sdk.getComponent("dialogs.InteractiveAuthDialog");
const dialogAesthetics = { const dialogAesthetics = {
[SSOAuthEntry.PHASE_PREAUTH]: { [SSOAuthEntry.PHASE_PREAUTH]: {
title: _t("Use Single Sign On to continue"), title: _t("Use Single Sign On to continue"),
@ -208,7 +209,7 @@ export default class AddThreepid {
title: _t("Add Email Address"), title: _t("Add Email Address"),
matrixClient: MatrixClientPeg.get(), matrixClient: MatrixClientPeg.get(),
authData: e.data, authData: e.data,
makeRequest: this._makeAddThreepidOnlyRequest, makeRequest: this.makeAddThreepidOnlyRequest,
aestheticsForStagePhases: { aestheticsForStagePhases: {
[SSOAuthEntry.LOGIN_TYPE]: dialogAesthetics, [SSOAuthEntry.LOGIN_TYPE]: dialogAesthetics,
[SSOAuthEntry.UNSTABLE_LOGIN_TYPE]: dialogAesthetics, [SSOAuthEntry.UNSTABLE_LOGIN_TYPE]: dialogAesthetics,
@ -235,16 +236,16 @@ export default class AddThreepid {
} }
/** /**
* @param {Object} auth UI auth object * @param {{type: string, session?: string}} auth UI auth object
* @return {Promise<Object>} Response from /3pid/add call (in current spec, an empty object) * @return {Promise<Object>} Response from /3pid/add call (in current spec, an empty object)
*/ */
_makeAddThreepidOnlyRequest = (auth) => { private makeAddThreepidOnlyRequest = (auth?: {type: string, session?: string}): Promise<{}> => {
return MatrixClientPeg.get().addThreePidOnly({ return MatrixClientPeg.get().addThreePidOnly({
sid: this.sessionId, sid: this.sessionId,
client_secret: this.clientSecret, client_secret: this.clientSecret,
auth, auth,
}); });
} };
/** /**
* Takes a phone number verification code as entered by the user and validates * Takes a phone number verification code as entered by the user and validates
@ -254,7 +255,7 @@ export default class AddThreepid {
* with a "message" property which contains a human-readable message detailing why * with a "message" property which contains a human-readable message detailing why
* the request failed. * the request failed.
*/ */
async haveMsisdnToken(msisdnToken) { public async haveMsisdnToken(msisdnToken: string): Promise<any[]> {
const authClient = new IdentityAuthClient(); const authClient = new IdentityAuthClient();
const supportsSeparateAddAndBind = const supportsSeparateAddAndBind =
await MatrixClientPeg.get().doesServerSupportSeparateAddAndBind(); await MatrixClientPeg.get().doesServerSupportSeparateAddAndBind();
@ -291,7 +292,7 @@ export default class AddThreepid {
}); });
} else { } else {
try { try {
await this._makeAddThreepidOnlyRequest(); await this.makeAddThreepidOnlyRequest();
// The spec has always required this to use UI auth but synapse briefly // The spec has always required this to use UI auth but synapse briefly
// implemented it without, so this may just succeed and that's OK. // implemented it without, so this may just succeed and that's OK.
@ -302,9 +303,6 @@ export default class AddThreepid {
throw e; throw e;
} }
// pop up an interactive auth dialog
const InteractiveAuthDialog = sdk.getComponent("dialogs.InteractiveAuthDialog");
const dialogAesthetics = { const dialogAesthetics = {
[SSOAuthEntry.PHASE_PREAUTH]: { [SSOAuthEntry.PHASE_PREAUTH]: {
title: _t("Use Single Sign On to continue"), title: _t("Use Single Sign On to continue"),
@ -324,7 +322,7 @@ export default class AddThreepid {
title: _t("Add Phone Number"), title: _t("Add Phone Number"),
matrixClient: MatrixClientPeg.get(), matrixClient: MatrixClientPeg.get(),
authData: e.data, authData: e.data,
makeRequest: this._makeAddThreepidOnlyRequest, makeRequest: this.makeAddThreepidOnlyRequest,
aestheticsForStagePhases: { aestheticsForStagePhases: {
[SSOAuthEntry.LOGIN_TYPE]: dialogAesthetics, [SSOAuthEntry.LOGIN_TYPE]: dialogAesthetics,
[SSOAuthEntry.UNSTABLE_LOGIN_TYPE]: dialogAesthetics, [SSOAuthEntry.UNSTABLE_LOGIN_TYPE]: dialogAesthetics,

View file

@ -142,15 +142,11 @@ export function avatarUrlForRoom(room: Room, width: number, height: number, resi
// space rooms cannot be DMs so skip the rest // space rooms cannot be DMs so skip the rest
if (SpaceStore.spacesEnabled && room.isSpaceRoom()) return null; if (SpaceStore.spacesEnabled && room.isSpaceRoom()) return null;
let otherMember = null; // If the room is not a DM don't fallback to a member avatar
const otherUserId = DMRoomMap.shared().getUserIdForRoomId(room.roomId); if (!DMRoomMap.shared().getUserIdForRoomId(room.roomId)) return null;
if (otherUserId) {
otherMember = room.getMember(otherUserId); // If there are only two members in the DM use the avatar of the other member
} else { const otherMember = room.getAvatarFallbackMember();
// if the room is not marked as a 1:1, but only has max 2 members
// then still try to show any avatar (pref. other member)
otherMember = room.getAvatarFallbackMember();
}
if (otherMember?.getMxcAvatarUrl()) { if (otherMember?.getMxcAvatarUrl()) {
return mediaFromMxc(otherMember.getMxcAvatarUrl()).getThumbnailOfSourceHttp(width, height, resizeMethod); return mediaFromMxc(otherMember.getMxcAvatarUrl()).getThumbnailOfSourceHttp(width, height, resizeMethod);
} }

View file

@ -161,3 +161,20 @@ export function wantsDateSeparator(prevEventDate: Date, nextEventDate: Date): bo
// Compare weekdays // Compare weekdays
return prevEventDate.getDay() !== nextEventDate.getDay(); return prevEventDate.getDay() !== nextEventDate.getDay();
} }
export function formatFullDateNoDay(date: Date) {
return _t("%(date)s at %(time)s", {
date: date.toLocaleDateString().replace(/\//g, '-'),
time: date.toLocaleTimeString().replace(/:/g, '-'),
});
}
export function formatFullDateNoDayNoTime(date: Date) {
return (
date.getFullYear() +
"/" +
pad(date.getMonth() + 1) +
"/" +
pad(date.getDate())
);
}

View file

@ -14,12 +14,12 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React from "react";
import { SERVICE_TYPES } from 'matrix-js-sdk/src/service-types'; import { SERVICE_TYPES } from 'matrix-js-sdk/src/service-types';
import { createClient } from 'matrix-js-sdk/src/matrix'; import { createClient, MatrixClient } from 'matrix-js-sdk/src/matrix';
import { MatrixClientPeg } from './MatrixClientPeg'; import { MatrixClientPeg } from './MatrixClientPeg';
import Modal from './Modal'; import Modal from './Modal';
import * as sdk from './index';
import { _t } from './languageHandler'; import { _t } from './languageHandler';
import { Service, startTermsFlow, TermsNotSignedError } from './Terms'; import { Service, startTermsFlow, TermsNotSignedError } from './Terms';
import { import {
@ -27,23 +27,25 @@ import {
doesIdentityServerHaveTerms, doesIdentityServerHaveTerms,
useDefaultIdentityServer, useDefaultIdentityServer,
} from './utils/IdentityServerUtils'; } from './utils/IdentityServerUtils';
import { abbreviateUrl } from './utils/UrlUtils';
import { logger } from "matrix-js-sdk/src/logger"; import { logger } from "matrix-js-sdk/src/logger";
import QuestionDialog from "./components/views/dialogs/QuestionDialog";
import { abbreviateUrl } from "./utils/UrlUtils";
export class AbortedIdentityActionError extends Error {} export class AbortedIdentityActionError extends Error {}
export default class IdentityAuthClient { export default class IdentityAuthClient {
private accessToken: string;
private tempClient: MatrixClient;
private authEnabled = true;
/** /**
* Creates a new identity auth client * Creates a new identity auth client
* @param {string} identityUrl The URL to contact the identity server with. * @param {string} identityUrl The URL to contact the identity server with.
* When provided, this class will operate solely within memory, refusing to * When provided, this class will operate solely within memory, refusing to
* persist any information such as tokens. Default null (not provided). * persist any information such as tokens. Default null (not provided).
*/ */
constructor(identityUrl = null) { constructor(identityUrl?: string) {
this.accessToken = null;
this.authEnabled = true;
if (identityUrl) { if (identityUrl) {
// XXX: We shouldn't have to create a whole new MatrixClient just to // XXX: We shouldn't have to create a whole new MatrixClient just to
// do identity server auth. The functions don't take an identity URL // do identity server auth. The functions don't take an identity URL
@ -54,32 +56,29 @@ export default class IdentityAuthClient {
baseUrl: "", // invalid by design baseUrl: "", // invalid by design
idBaseUrl: identityUrl, idBaseUrl: identityUrl,
}); });
} else {
// Indicates that we're using the real client, not some workaround.
this.tempClient = null;
} }
} }
get _matrixClient() { private get matrixClient(): MatrixClient {
return this.tempClient ? this.tempClient : MatrixClientPeg.get(); return this.tempClient ? this.tempClient : MatrixClientPeg.get();
} }
_writeToken() { private writeToken(): void {
if (this.tempClient) return; // temporary client: ignore if (this.tempClient) return; // temporary client: ignore
window.localStorage.setItem("mx_is_access_token", this.accessToken); window.localStorage.setItem("mx_is_access_token", this.accessToken);
} }
_readToken() { private readToken(): string {
if (this.tempClient) return null; // temporary client: ignore if (this.tempClient) return null; // temporary client: ignore
return window.localStorage.getItem("mx_is_access_token"); return window.localStorage.getItem("mx_is_access_token");
} }
hasCredentials() { public hasCredentials(): boolean {
return this.accessToken != null; // undef or null return Boolean(this.accessToken);
} }
// Returns a promise that resolves to the access_token string from the IS // Returns a promise that resolves to the access_token string from the IS
async getAccessToken({ check = true } = {}) { public async getAccessToken({ check = true } = {}): Promise<string> {
if (!this.authEnabled) { if (!this.authEnabled) {
// The current IS doesn't support authentication // The current IS doesn't support authentication
return null; return null;
@ -87,21 +86,21 @@ export default class IdentityAuthClient {
let token = this.accessToken; let token = this.accessToken;
if (!token) { if (!token) {
token = this._readToken(); token = this.readToken();
} }
if (!token) { if (!token) {
token = await this.registerForToken(check); token = await this.registerForToken(check);
if (token) { if (token) {
this.accessToken = token; this.accessToken = token;
this._writeToken(); this.writeToken();
} }
return token; return token;
} }
if (check) { if (check) {
try { try {
await this._checkToken(token); await this.checkToken(token);
} catch (e) { } catch (e) {
if ( if (
e instanceof TermsNotSignedError || e instanceof TermsNotSignedError ||
@ -114,7 +113,7 @@ export default class IdentityAuthClient {
token = await this.registerForToken(); token = await this.registerForToken();
if (token) { if (token) {
this.accessToken = token; this.accessToken = token;
this._writeToken(); this.writeToken();
} }
} }
} }
@ -122,11 +121,11 @@ export default class IdentityAuthClient {
return token; return token;
} }
async _checkToken(token) { private async checkToken(token: string): Promise<void> {
const identityServerUrl = this._matrixClient.getIdentityServerUrl(); const identityServerUrl = this.matrixClient.getIdentityServerUrl();
try { try {
await this._matrixClient.getIdentityAccount(token); await this.matrixClient.getIdentityAccount(token);
} catch (e) { } catch (e) {
if (e.errcode === "M_TERMS_NOT_SIGNED") { if (e.errcode === "M_TERMS_NOT_SIGNED") {
logger.log("Identity server requires new terms to be agreed to"); logger.log("Identity server requires new terms to be agreed to");
@ -145,8 +144,8 @@ export default class IdentityAuthClient {
!doesAccountDataHaveIdentityServer() && !doesAccountDataHaveIdentityServer() &&
!(await doesIdentityServerHaveTerms(identityServerUrl)) !(await doesIdentityServerHaveTerms(identityServerUrl))
) { ) {
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); const { finished } = Modal.createTrackedDialog(
const { finished } = Modal.createTrackedDialog('Default identity server terms warning', '', 'Default identity server terms warning', '',
QuestionDialog, { QuestionDialog, {
title: _t("Identity server has no terms of service"), title: _t("Identity server has no terms of service"),
description: ( description: (
@ -184,13 +183,13 @@ export default class IdentityAuthClient {
// See also https://github.com/vector-im/element-web/issues/10455. // See also https://github.com/vector-im/element-web/issues/10455.
} }
async registerForToken(check=true) { public async registerForToken(check = true): Promise<string> {
const hsOpenIdToken = await MatrixClientPeg.get().getOpenIdToken(); const hsOpenIdToken = await MatrixClientPeg.get().getOpenIdToken();
// XXX: The spec is `token`, but we used `access_token` for a Sydent release. // XXX: The spec is `token`, but we used `access_token` for a Sydent release.
const { access_token: accessToken, token } = const { access_token: accessToken, token } =
await this._matrixClient.registerWithIdentityServer(hsOpenIdToken); await this.matrixClient.registerWithIdentityServer(hsOpenIdToken);
const identityAccessToken = token ? token : accessToken; const identityAccessToken = token ? token : accessToken;
if (check) await this._checkToken(identityAccessToken); if (check) await this.checkToken(identityAccessToken);
return identityAccessToken; return identityAccessToken;
} }
} }

View file

@ -1,6 +1,21 @@
import React from "react"; import React from "react";
import ReactDom from "react-dom"; import ReactDom from "react-dom";
import PropTypes from 'prop-types';
interface IChildProps {
style: React.CSSProperties;
ref: (node: React.ReactInstance) => void;
}
interface IProps {
// either a list of child nodes, or a single child.
children: React.ReactNode;
// optional transition information for changing existing children
transition?: object;
// a list of state objects to apply to each child node in turn
startStyles: React.CSSProperties[];
}
/** /**
* The NodeAnimator contains components and animates transitions. * The NodeAnimator contains components and animates transitions.
@ -9,55 +24,45 @@ import PropTypes from 'prop-types';
* from DOM order. This makes it a lot simpler and lighter: if you need fully * from DOM order. This makes it a lot simpler and lighter: if you need fully
* automatic positional animation, look at react-shuffle or similar libraries. * automatic positional animation, look at react-shuffle or similar libraries.
*/ */
export default class NodeAnimator extends React.Component { export default class NodeAnimator extends React.Component<IProps> {
static propTypes = { private nodes = {};
// either a list of child nodes, or a single child. private children: { [key: string]: React.DetailedReactHTMLElement<any, HTMLElement> };
children: PropTypes.any, public static defaultProps: Partial<IProps> = {
// optional transition information for changing existing children
transition: PropTypes.object,
// a list of state objects to apply to each child node in turn
startStyles: PropTypes.array,
};
static defaultProps = {
startStyles: [], startStyles: [],
}; };
constructor(props) { constructor(props: IProps) {
super(props); super(props);
this.nodes = {}; this.updateChildren(this.props.children);
this._updateChildren(this.props.children);
} }
componentDidUpdate() { public componentDidUpdate(): void {
this._updateChildren(this.props.children); this.updateChildren(this.props.children);
} }
/** /**
* *
* @param {HTMLElement} node element to apply styles to * @param {HTMLElement} node element to apply styles to
* @param {object} styles a key/value pair of CSS properties * @param {React.CSSProperties} styles a key/value pair of CSS properties
* @returns {void} * @returns {void}
*/ */
_applyStyles(node, styles) { private applyStyles(node: HTMLElement, styles: React.CSSProperties): void {
Object.entries(styles).forEach(([property, value]) => { Object.entries(styles).forEach(([property, value]) => {
node.style[property] = value; node.style[property] = value;
}); });
} }
_updateChildren(newChildren) { private updateChildren(newChildren: React.ReactNode): void {
const oldChildren = this.children || {}; const oldChildren = this.children || {};
this.children = {}; this.children = {};
React.Children.toArray(newChildren).forEach((c) => { React.Children.toArray(newChildren).forEach((c: any) => {
if (oldChildren[c.key]) { if (oldChildren[c.key]) {
const old = oldChildren[c.key]; const old = oldChildren[c.key];
const oldNode = ReactDom.findDOMNode(this.nodes[old.key]); const oldNode = ReactDom.findDOMNode(this.nodes[old.key]);
if (oldNode && oldNode.style.left !== c.props.style.left) { if (oldNode && (oldNode as HTMLElement).style.left !== c.props.style.left) {
this._applyStyles(oldNode, { left: c.props.style.left }); this.applyStyles(oldNode as HTMLElement, { left: c.props.style.left });
// console.log("translation: "+oldNode.style.left+" -> "+c.props.style.left); // console.log("translation: "+oldNode.style.left+" -> "+c.props.style.left);
} }
// clone the old element with the props (and children) of the new element // clone the old element with the props (and children) of the new element
@ -66,7 +71,7 @@ export default class NodeAnimator extends React.Component {
} else { } else {
// new element. If we have a startStyle, use that as the style and go through // new element. If we have a startStyle, use that as the style and go through
// the enter animations // the enter animations
const newProps = {}; const newProps: Partial<IChildProps> = {};
const restingStyle = c.props.style; const restingStyle = c.props.style;
const startStyles = this.props.startStyles; const startStyles = this.props.startStyles;
@ -76,7 +81,7 @@ export default class NodeAnimator extends React.Component {
// console.log("mounted@startstyle0: "+JSON.stringify(startStyle)); // console.log("mounted@startstyle0: "+JSON.stringify(startStyle));
} }
newProps.ref = ((n) => this._collectNode( newProps.ref = ((n) => this.collectNode(
c.key, n, restingStyle, c.key, n, restingStyle,
)); ));
@ -85,7 +90,7 @@ export default class NodeAnimator extends React.Component {
}); });
} }
_collectNode(k, node, restingStyle) { private collectNode(k: string, node: React.ReactInstance, restingStyle: React.CSSProperties): void {
if ( if (
node && node &&
this.nodes[k] === undefined && this.nodes[k] === undefined &&
@ -96,7 +101,7 @@ export default class NodeAnimator extends React.Component {
// start from startStyle 1: 0 is the one we gave it // start from startStyle 1: 0 is the one we gave it
// to start with, so now we animate 1 etc. // to start with, so now we animate 1 etc.
for (let i = 1; i < startStyles.length; ++i) { for (let i = 1; i < startStyles.length; ++i) {
this._applyStyles(domNode, startStyles[i]); this.applyStyles(domNode as HTMLElement, startStyles[i]);
// console.log("start:" // console.log("start:"
// JSON.stringify(startStyles[i]), // JSON.stringify(startStyles[i]),
// ); // );
@ -104,7 +109,7 @@ export default class NodeAnimator extends React.Component {
// and then we animate to the resting state // and then we animate to the resting state
setTimeout(() => { setTimeout(() => {
this._applyStyles(domNode, restingStyle); this.applyStyles(domNode as HTMLElement, restingStyle);
}, 0); }, 0);
// console.log("enter:", // console.log("enter:",
@ -113,7 +118,7 @@ export default class NodeAnimator extends React.Component {
this.nodes[k] = node; this.nodes[k] = node;
} }
render() { public render(): JSX.Element {
return ( return (
<>{ Object.values(this.children) }</> <>{ Object.values(this.children) }</>
); );

View file

@ -16,11 +16,13 @@ limitations under the License.
*/ */
/** The types of page which can be shown by the LoggedInView */ /** The types of page which can be shown by the LoggedInView */
export default { enum PageType {
HomePage: "home_page", HomePage = "home_page",
RoomView: "room_view", RoomView = "room_view",
RoomDirectory: "room_directory", RoomDirectory = "room_directory",
UserView: "user_view", UserView = "user_view",
GroupView: "group_view", GroupView = "group_view",
MyGroups: "my_groups", MyGroups = "my_groups",
}; }
export default PageType;

View file

@ -20,10 +20,11 @@ limitations under the License.
* registration code. * registration code.
*/ */
import React from "react";
import dis from './dispatcher/dispatcher'; import dis from './dispatcher/dispatcher';
import * as sdk from './index';
import Modal from './Modal'; import Modal from './Modal';
import { _t } from './languageHandler'; import { _t } from './languageHandler';
import QuestionDialog from "./components/views/dialogs/QuestionDialog";
// Regex for what a "safe" or "Matrix-looking" localpart would be. // Regex for what a "safe" or "Matrix-looking" localpart would be.
// TODO: Update as needed for https://github.com/matrix-org/matrix-doc/issues/1514 // TODO: Update as needed for https://github.com/matrix-org/matrix-doc/issues/1514
@ -41,9 +42,11 @@ export const SAFE_LOCALPART_REGEX = /^[a-z0-9=_\-./]+$/;
* @param {bool} options.screen_after * @param {bool} options.screen_after
* If present the screen to redirect to after a successful login or register. * If present the screen to redirect to after a successful login or register.
*/ */
export async function startAnyRegistrationFlow(options) { export async function startAnyRegistrationFlow(
// eslint-disable-next-line camelcase
options: { go_home_on_cancel?: boolean, go_welcome_on_cancel?: boolean, screen_after?: boolean},
): Promise<void> {
if (options === undefined) options = {}; if (options === undefined) options = {};
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
const modal = Modal.createTrackedDialog('Registration required', '', QuestionDialog, { const modal = Modal.createTrackedDialog('Registration required', '', QuestionDialog, {
hasCancelButton: true, hasCancelButton: true,
quitOnly: true, quitOnly: true,

View file

@ -42,10 +42,15 @@ export interface IInviteResult {
* *
* @param {string} roomId The ID of the room to invite to * @param {string} roomId The ID of the room to invite to
* @param {string[]} addresses Array of strings of addresses to invite. May be matrix IDs or 3pids. * @param {string[]} addresses Array of strings of addresses to invite. May be matrix IDs or 3pids.
* @param {function} progressCallback optional callback, fired after each invite.
* @returns {Promise} Promise * @returns {Promise} Promise
*/ */
export function inviteMultipleToRoom(roomId: string, addresses: string[]): Promise<IInviteResult> { export function inviteMultipleToRoom(
const inviter = new MultiInviter(roomId); roomId: string,
addresses: string[],
progressCallback?: () => void,
): Promise<IInviteResult> {
const inviter = new MultiInviter(roomId, progressCallback);
return inviter.invite(addresses).then(states => Promise.resolve({ states, inviter })); return inviter.invite(addresses).then(states => Promise.resolve({ states, inviter }));
} }
@ -104,8 +109,8 @@ export function isValid3pidInvite(event: MatrixEvent): boolean {
return true; return true;
} }
export function inviteUsersToRoom(roomId: string, userIds: string[]): Promise<void> { export function inviteUsersToRoom(roomId: string, userIds: string[], progressCallback?: () => void): Promise<void> {
return inviteMultipleToRoom(roomId, userIds).then((result) => { return inviteMultipleToRoom(roomId, userIds, progressCallback).then((result) => {
const room = MatrixClientPeg.get().getRoom(roomId); const room = MatrixClientPeg.get().getRoom(roomId);
showAnyInviteErrors(result.states, room, result.inviter); showAnyInviteErrors(result.states, room, result.inviter);
}).catch((err) => { }).catch((err) => {

View file

@ -17,27 +17,31 @@ limitations under the License.
import { MatrixClientPeg } from './MatrixClientPeg'; import { MatrixClientPeg } from './MatrixClientPeg';
import { PushProcessor } from 'matrix-js-sdk/src/pushprocessor'; import { PushProcessor } from 'matrix-js-sdk/src/pushprocessor';
import { NotificationCountType, Room } from "matrix-js-sdk/src/models/room";
import { IAnnotatedPushRule, PushRuleKind } from "matrix-js-sdk/src/@types/PushRules";
export const ALL_MESSAGES_LOUD = 'all_messages_loud'; export enum RoomNotifState {
export const ALL_MESSAGES = 'all_messages'; AllMessagesLoud = 'all_messages_loud',
export const MENTIONS_ONLY = 'mentions_only'; AllMessages = 'all_messages',
export const MUTE = 'mute'; MentionsOnly = 'mentions_only',
Mute = 'mute',
}
export const BADGE_STATES = [ALL_MESSAGES, ALL_MESSAGES_LOUD]; export const BADGE_STATES = [RoomNotifState.AllMessages, RoomNotifState.AllMessagesLoud];
export const MENTION_BADGE_STATES = [...BADGE_STATES, MENTIONS_ONLY]; export const MENTION_BADGE_STATES = [...BADGE_STATES, RoomNotifState.MentionsOnly];
export function shouldShowNotifBadge(roomNotifState) { export function shouldShowNotifBadge(roomNotifState: RoomNotifState): boolean {
return BADGE_STATES.includes(roomNotifState); return BADGE_STATES.includes(roomNotifState);
} }
export function shouldShowMentionBadge(roomNotifState) { export function shouldShowMentionBadge(roomNotifState: RoomNotifState): boolean {
return MENTION_BADGE_STATES.includes(roomNotifState); return MENTION_BADGE_STATES.includes(roomNotifState);
} }
export function aggregateNotificationCount(rooms) { export function aggregateNotificationCount(rooms: Room[]): {count: number, highlight: boolean} {
return rooms.reduce((result, room) => { return rooms.reduce<{count: number, highlight: boolean}>((result, room) => {
const roomNotifState = getRoomNotifsState(room.roomId); const roomNotifState = getRoomNotifsState(room.roomId);
const highlight = room.getUnreadNotificationCount('highlight') > 0; const highlight = room.getUnreadNotificationCount(NotificationCountType.Highlight) > 0;
// use helper method to include highlights in the previous version of the room // use helper method to include highlights in the previous version of the room
const notificationCount = getUnreadNotificationCount(room); const notificationCount = getUnreadNotificationCount(room);
@ -55,9 +59,9 @@ export function aggregateNotificationCount(rooms) {
}, { count: 0, highlight: false }); }, { count: 0, highlight: false });
} }
export function getRoomHasBadge(room) { export function getRoomHasBadge(room: Room): boolean {
const roomNotifState = getRoomNotifsState(room.roomId); const roomNotifState = getRoomNotifsState(room.roomId);
const highlight = room.getUnreadNotificationCount('highlight') > 0; const highlight = room.getUnreadNotificationCount(NotificationCountType.Highlight) > 0;
const notificationCount = room.getUnreadNotificationCount(); const notificationCount = room.getUnreadNotificationCount();
const notifBadges = notificationCount > 0 && shouldShowNotifBadge(roomNotifState); const notifBadges = notificationCount > 0 && shouldShowNotifBadge(roomNotifState);
@ -66,14 +70,14 @@ export function getRoomHasBadge(room) {
return notifBadges || mentionBadges; return notifBadges || mentionBadges;
} }
export function getRoomNotifsState(roomId) { export function getRoomNotifsState(roomId: string): RoomNotifState {
if (MatrixClientPeg.get().isGuest()) return ALL_MESSAGES; if (MatrixClientPeg.get().isGuest()) return RoomNotifState.AllMessages;
// look through the override rules for a rule affecting this room: // look through the override rules for a rule affecting this room:
// if one exists, it will take precedence. // if one exists, it will take precedence.
const muteRule = findOverrideMuteRule(roomId); const muteRule = findOverrideMuteRule(roomId);
if (muteRule) { if (muteRule) {
return MUTE; return RoomNotifState.Mute;
} }
// for everything else, look at the room rule. // for everything else, look at the room rule.
@ -89,27 +93,27 @@ export function getRoomNotifsState(roomId) {
// XXX: We have to assume the default is to notify for all messages // XXX: We have to assume the default is to notify for all messages
// (in particular this will be 'wrong' for one to one rooms because // (in particular this will be 'wrong' for one to one rooms because
// they will notify loudly for all messages) // they will notify loudly for all messages)
if (!roomRule || !roomRule.enabled) return ALL_MESSAGES; if (!roomRule || !roomRule.enabled) return RoomNotifState.AllMessages;
// a mute at the room level will still allow mentions // a mute at the room level will still allow mentions
// to notify // to notify
if (isMuteRule(roomRule)) return MENTIONS_ONLY; if (isMuteRule(roomRule)) return RoomNotifState.MentionsOnly;
const actionsObject = PushProcessor.actionListToActionsObject(roomRule.actions); const actionsObject = PushProcessor.actionListToActionsObject(roomRule.actions);
if (actionsObject.tweaks.sound) return ALL_MESSAGES_LOUD; if (actionsObject.tweaks.sound) return RoomNotifState.AllMessagesLoud;
return null; return null;
} }
export function setRoomNotifsState(roomId, newState) { export function setRoomNotifsState(roomId: string, newState: RoomNotifState): Promise<void> {
if (newState === MUTE) { if (newState === RoomNotifState.Mute) {
return setRoomNotifsStateMuted(roomId); return setRoomNotifsStateMuted(roomId);
} else { } else {
return setRoomNotifsStateUnmuted(roomId, newState); return setRoomNotifsStateUnmuted(roomId, newState);
} }
} }
export function getUnreadNotificationCount(room, type=null) { export function getUnreadNotificationCount(room: Room, type: NotificationCountType = null): number {
let notificationCount = room.getUnreadNotificationCount(type); let notificationCount = room.getUnreadNotificationCount(type);
// Check notification counts in the old room just in case there's some lost // Check notification counts in the old room just in case there's some lost
@ -124,21 +128,21 @@ export function getUnreadNotificationCount(room, type=null) {
// notifying the user for unread messages because they would have extreme // notifying the user for unread messages because they would have extreme
// difficulty changing their notification preferences away from "All Messages" // difficulty changing their notification preferences away from "All Messages"
// and "Noisy". // and "Noisy".
notificationCount += oldRoom.getUnreadNotificationCount("highlight"); notificationCount += oldRoom.getUnreadNotificationCount(NotificationCountType.Highlight);
} }
} }
return notificationCount; return notificationCount;
} }
function setRoomNotifsStateMuted(roomId) { function setRoomNotifsStateMuted(roomId: string): Promise<any> {
const cli = MatrixClientPeg.get(); const cli = MatrixClientPeg.get();
const promises = []; const promises = [];
// delete the room rule // delete the room rule
const roomRule = cli.getRoomPushRule('global', roomId); const roomRule = cli.getRoomPushRule('global', roomId);
if (roomRule) { if (roomRule) {
promises.push(cli.deletePushRule('global', 'room', roomRule.rule_id)); promises.push(cli.deletePushRule('global', PushRuleKind.RoomSpecific, roomRule.rule_id));
} }
// add/replace an override rule to squelch everything in this room // add/replace an override rule to squelch everything in this room
@ -146,7 +150,7 @@ function setRoomNotifsStateMuted(roomId) {
// is an override rule, not a room rule: it still pertains to this room // is an override rule, not a room rule: it still pertains to this room
// though, so using the room ID as the rule ID is logical and prevents // though, so using the room ID as the rule ID is logical and prevents
// duplicate copies of the rule. // duplicate copies of the rule.
promises.push(cli.addPushRule('global', 'override', roomId, { promises.push(cli.addPushRule('global', PushRuleKind.Override, roomId, {
conditions: [ conditions: [
{ {
kind: 'event_match', kind: 'event_match',
@ -162,30 +166,30 @@ function setRoomNotifsStateMuted(roomId) {
return Promise.all(promises); return Promise.all(promises);
} }
function setRoomNotifsStateUnmuted(roomId, newState) { function setRoomNotifsStateUnmuted(roomId: string, newState: RoomNotifState): Promise<any> {
const cli = MatrixClientPeg.get(); const cli = MatrixClientPeg.get();
const promises = []; const promises = [];
const overrideMuteRule = findOverrideMuteRule(roomId); const overrideMuteRule = findOverrideMuteRule(roomId);
if (overrideMuteRule) { if (overrideMuteRule) {
promises.push(cli.deletePushRule('global', 'override', overrideMuteRule.rule_id)); promises.push(cli.deletePushRule('global', PushRuleKind.Override, overrideMuteRule.rule_id));
} }
if (newState === 'all_messages') { if (newState === RoomNotifState.AllMessages) {
const roomRule = cli.getRoomPushRule('global', roomId); const roomRule = cli.getRoomPushRule('global', roomId);
if (roomRule) { if (roomRule) {
promises.push(cli.deletePushRule('global', 'room', roomRule.rule_id)); promises.push(cli.deletePushRule('global', PushRuleKind.RoomSpecific, roomRule.rule_id));
} }
} else if (newState === 'mentions_only') { } else if (newState === RoomNotifState.MentionsOnly) {
promises.push(cli.addPushRule('global', 'room', roomId, { promises.push(cli.addPushRule('global', PushRuleKind.RoomSpecific, roomId, {
actions: [ actions: [
'dont_notify', 'dont_notify',
], ],
})); }));
// https://matrix.org/jira/browse/SPEC-400 // https://matrix.org/jira/browse/SPEC-400
promises.push(cli.setPushRuleEnabled('global', 'room', roomId, true)); promises.push(cli.setPushRuleEnabled('global', PushRuleKind.RoomSpecific, roomId, true));
} else if ('all_messages_loud') { } else if (newState === RoomNotifState.AllMessagesLoud) {
promises.push(cli.addPushRule('global', 'room', roomId, { promises.push(cli.addPushRule('global', PushRuleKind.RoomSpecific, roomId, {
actions: [ actions: [
'notify', 'notify',
{ {
@ -195,13 +199,13 @@ function setRoomNotifsStateUnmuted(roomId, newState) {
], ],
})); }));
// https://matrix.org/jira/browse/SPEC-400 // https://matrix.org/jira/browse/SPEC-400
promises.push(cli.setPushRuleEnabled('global', 'room', roomId, true)); promises.push(cli.setPushRuleEnabled('global', PushRuleKind.RoomSpecific, roomId, true));
} }
return Promise.all(promises); return Promise.all(promises);
} }
function findOverrideMuteRule(roomId) { function findOverrideMuteRule(roomId: string): IAnnotatedPushRule {
const cli = MatrixClientPeg.get(); const cli = MatrixClientPeg.get();
if (!cli.pushRules || if (!cli.pushRules ||
!cli.pushRules['global'] || !cli.pushRules['global'] ||
@ -218,7 +222,7 @@ function findOverrideMuteRule(roomId) {
return null; return null;
} }
function isRuleForRoom(roomId, rule) { function isRuleForRoom(roomId: string, rule: IAnnotatedPushRule): boolean {
if (rule.conditions.length !== 1) { if (rule.conditions.length !== 1) {
return false; return false;
} }
@ -226,6 +230,6 @@ function isRuleForRoom(roomId, rule) {
return (cond.kind === 'event_match' && cond.key === 'room_id' && cond.pattern === roomId); return (cond.kind === 'event_match' && cond.key === 'room_id' && cond.pattern === roomId);
} }
function isMuteRule(rule) { function isMuteRule(rule: IAnnotatedPushRule): boolean {
return (rule.actions.length === 1 && rule.actions[0] === 'dont_notify'); return (rule.actions.length === 1 && rule.actions[0] === 'dont_notify');
} }

View file

@ -247,13 +247,31 @@ import { objectClone } from "./utils/objects";
import { logger } from "matrix-js-sdk/src/logger"; import { logger } from "matrix-js-sdk/src/logger";
function sendResponse(event, res) { enum Action {
CloseScalar = "close_scalar",
GetWidgets = "get_widgets",
SetWidgets = "set_widgets",
SetWidget = "set_widget",
JoinRulesState = "join_rules_state",
SetPlumbingState = "set_plumbing_state",
GetMembershipCount = "get_membership_count",
GetRoomEncryptionState = "get_room_enc_state",
CanSendEvent = "can_send_event",
MembershipState = "membership_state",
invite = "invite",
BotOptions = "bot_options",
SetBotOptions = "set_bot_options",
SetBotPower = "set_bot_power",
}
function sendResponse(event: MessageEvent<any>, res: any): void {
const data = objectClone(event.data); const data = objectClone(event.data);
data.response = res; data.response = res;
// @ts-ignore
event.source.postMessage(data, event.origin); event.source.postMessage(data, event.origin);
} }
function sendError(event, msg, nestedError) { function sendError(event: MessageEvent<any>, msg: string, nestedError?: Error): void {
console.error("Action:" + event.data.action + " failed with message: " + msg); console.error("Action:" + event.data.action + " failed with message: " + msg);
const data = objectClone(event.data); const data = objectClone(event.data);
data.response = { data.response = {
@ -264,10 +282,11 @@ function sendError(event, msg, nestedError) {
if (nestedError) { if (nestedError) {
data.response.error._error = nestedError; data.response.error._error = nestedError;
} }
// @ts-ignore
event.source.postMessage(data, event.origin); event.source.postMessage(data, event.origin);
} }
function inviteUser(event, roomId, userId) { function inviteUser(event: MessageEvent<any>, roomId: string, userId: string): void {
logger.log(`Received request to invite ${userId} into room ${roomId}`); logger.log(`Received request to invite ${userId} into room ${roomId}`);
const client = MatrixClientPeg.get(); const client = MatrixClientPeg.get();
if (!client) { if (!client) {
@ -295,7 +314,7 @@ function inviteUser(event, roomId, userId) {
}); });
} }
function setWidget(event, roomId) { function setWidget(event: MessageEvent<any>, roomId: string): void {
const widgetId = event.data.widget_id; const widgetId = event.data.widget_id;
let widgetType = event.data.type; let widgetType = event.data.type;
const widgetUrl = event.data.url; const widgetUrl = event.data.url;
@ -356,7 +375,7 @@ function setWidget(event, roomId) {
} }
} }
function getWidgets(event, roomId) { function getWidgets(event: MessageEvent<any>, roomId: string): void {
const client = MatrixClientPeg.get(); const client = MatrixClientPeg.get();
if (!client) { if (!client) {
sendError(event, _t('You need to be logged in.')); sendError(event, _t('You need to be logged in.'));
@ -382,7 +401,7 @@ function getWidgets(event, roomId) {
sendResponse(event, widgetStateEvents); sendResponse(event, widgetStateEvents);
} }
function getRoomEncState(event, roomId) { function getRoomEncState(event: MessageEvent<any>, roomId: string): void {
const client = MatrixClientPeg.get(); const client = MatrixClientPeg.get();
if (!client) { if (!client) {
sendError(event, _t('You need to be logged in.')); sendError(event, _t('You need to be logged in.'));
@ -398,7 +417,7 @@ function getRoomEncState(event, roomId) {
sendResponse(event, roomIsEncrypted); sendResponse(event, roomIsEncrypted);
} }
function setPlumbingState(event, roomId, status) { function setPlumbingState(event: MessageEvent<any>, roomId: string, status: string): void {
if (typeof status !== 'string') { if (typeof status !== 'string') {
throw new Error('Plumbing state status should be a string'); throw new Error('Plumbing state status should be a string');
} }
@ -417,7 +436,7 @@ function setPlumbingState(event, roomId, status) {
}); });
} }
function setBotOptions(event, roomId, userId) { function setBotOptions(event: MessageEvent<any>, roomId: string, userId: string): void {
logger.log(`Received request to set options for bot ${userId} in room ${roomId}`); logger.log(`Received request to set options for bot ${userId} in room ${roomId}`);
const client = MatrixClientPeg.get(); const client = MatrixClientPeg.get();
if (!client) { if (!client) {
@ -433,7 +452,9 @@ function setBotOptions(event, roomId, userId) {
}); });
} }
function setBotPower(event, roomId, userId, level) { async function setBotPower(
event: MessageEvent<any>, roomId: string, userId: string, level: number, ignoreIfGreater?: boolean,
): Promise<void> {
if (!(Number.isInteger(level) && level >= 0)) { if (!(Number.isInteger(level) && level >= 0)) {
sendError(event, _t('Power level must be positive integer.')); sendError(event, _t('Power level must be positive integer.'));
return; return;
@ -446,40 +467,52 @@ function setBotPower(event, roomId, userId, level) {
return; return;
} }
client.getStateEvent(roomId, "m.room.power_levels", "").then((powerLevels) => { try {
const powerEvent = new MatrixEvent( const powerLevels = await client.getStateEvent(roomId, "m.room.power_levels", "");
// If the PL is equal to or greater than the requested PL, ignore.
if (ignoreIfGreater === true) {
// As per https://matrix.org/docs/spec/client_server/r0.6.0#m-room-power-levels
const currentPl = (
powerLevels.content.users && powerLevels.content.users[userId]
) || powerLevels.content.users_default || 0;
if (currentPl >= level) {
return sendResponse(event, {
success: true,
});
}
}
await client.setPowerLevel(roomId, userId, level, new MatrixEvent(
{ {
type: "m.room.power_levels", type: "m.room.power_levels",
content: powerLevels, content: powerLevels,
}, },
); ));
return sendResponse(event, {
client.setPowerLevel(roomId, userId, level, powerEvent).then(() => {
sendResponse(event, {
success: true, success: true,
}); });
}, (err) => { } catch (err) {
sendError(event, err.message ? err.message : _t('Failed to send request.'), err); sendError(event, err.message ? err.message : _t('Failed to send request.'), err);
}); }
});
} }
function getMembershipState(event, roomId, userId) { function getMembershipState(event: MessageEvent<any>, roomId: string, userId: string): void {
logger.log(`membership_state of ${userId} in room ${roomId} requested.`); logger.log(`membership_state of ${userId} in room ${roomId} requested.`);
returnStateEvent(event, roomId, "m.room.member", userId); returnStateEvent(event, roomId, "m.room.member", userId);
} }
function getJoinRules(event, roomId) { function getJoinRules(event: MessageEvent<any>, roomId: string): void {
logger.log(`join_rules of ${roomId} requested.`); logger.log(`join_rules of ${roomId} requested.`);
returnStateEvent(event, roomId, "m.room.join_rules", ""); returnStateEvent(event, roomId, "m.room.join_rules", "");
} }
function botOptions(event, roomId, userId) { function botOptions(event: MessageEvent<any>, roomId: string, userId: string): void {
logger.log(`bot_options of ${userId} in room ${roomId} requested.`); logger.log(`bot_options of ${userId} in room ${roomId} requested.`);
returnStateEvent(event, roomId, "m.room.bot.options", "_" + userId); returnStateEvent(event, roomId, "m.room.bot.options", "_" + userId);
} }
function getMembershipCount(event, roomId) { function getMembershipCount(event: MessageEvent<any>, roomId: string): void {
const client = MatrixClientPeg.get(); const client = MatrixClientPeg.get();
if (!client) { if (!client) {
sendError(event, _t('You need to be logged in.')); sendError(event, _t('You need to be logged in.'));
@ -494,7 +527,7 @@ function getMembershipCount(event, roomId) {
sendResponse(event, count); sendResponse(event, count);
} }
function canSendEvent(event, roomId) { function canSendEvent(event: MessageEvent<any>, roomId: string): void {
const evType = "" + event.data.event_type; // force stringify const evType = "" + event.data.event_type; // force stringify
const isState = Boolean(event.data.is_state); const isState = Boolean(event.data.is_state);
const client = MatrixClientPeg.get(); const client = MatrixClientPeg.get();
@ -528,7 +561,7 @@ function canSendEvent(event, roomId) {
sendResponse(event, true); sendResponse(event, true);
} }
function returnStateEvent(event, roomId, eventType, stateKey) { function returnStateEvent(event: MessageEvent<any>, roomId: string, eventType: string, stateKey: string): void {
const client = MatrixClientPeg.get(); const client = MatrixClientPeg.get();
if (!client) { if (!client) {
sendError(event, _t('You need to be logged in.')); sendError(event, _t('You need to be logged in.'));
@ -547,8 +580,9 @@ function returnStateEvent(event, roomId, eventType, stateKey) {
sendResponse(event, stateEvent.getContent()); sendResponse(event, stateEvent.getContent());
} }
const onMessage = function(event) { const onMessage = function(event: MessageEvent<any>): void {
if (!event.origin) { // stupid chrome if (!event.origin) { // stupid chrome
// @ts-ignore
event.origin = event.originalEvent.origin; event.origin = event.originalEvent.origin;
} }
@ -582,8 +616,8 @@ const onMessage = function(event) {
return; return;
} }
if (event.data.action === "close_scalar") { if (event.data.action === Action.CloseScalar) {
dis.dispatch({ action: "close_scalar" }); dis.dispatch({ action: Action.CloseScalar });
sendResponse(event, null); sendResponse(event, null);
return; return;
} }
@ -596,10 +630,10 @@ const onMessage = function(event) {
// Get and set user widgets (not associated with a specific room) // Get and set user widgets (not associated with a specific room)
// If roomId is specified, it must be validated, so room-based widgets agreed // If roomId is specified, it must be validated, so room-based widgets agreed
// handled further down. // handled further down.
if (event.data.action === "get_widgets") { if (event.data.action === Action.GetWidgets) {
getWidgets(event, null); getWidgets(event, null);
return; return;
} else if (event.data.action === "set_widget") { } else if (event.data.action === Action.SetWidgets) {
setWidget(event, null); setWidget(event, null);
return; return;
} else { } else {
@ -614,28 +648,28 @@ const onMessage = function(event) {
} }
// Get and set room-based widgets // Get and set room-based widgets
if (event.data.action === "get_widgets") { if (event.data.action === Action.GetWidgets) {
getWidgets(event, roomId); getWidgets(event, roomId);
return; return;
} else if (event.data.action === "set_widget") { } else if (event.data.action === Action.SetWidget) {
setWidget(event, roomId); setWidget(event, roomId);
return; return;
} }
// These APIs don't require userId // These APIs don't require userId
if (event.data.action === "join_rules_state") { if (event.data.action === Action.JoinRulesState) {
getJoinRules(event, roomId); getJoinRules(event, roomId);
return; return;
} else if (event.data.action === "set_plumbing_state") { } else if (event.data.action === Action.SetPlumbingState) {
setPlumbingState(event, roomId, event.data.status); setPlumbingState(event, roomId, event.data.status);
return; return;
} else if (event.data.action === "get_membership_count") { } else if (event.data.action === Action.GetMembershipCount) {
getMembershipCount(event, roomId); getMembershipCount(event, roomId);
return; return;
} else if (event.data.action === "get_room_enc_state") { } else if (event.data.action === Action.GetRoomEncryptionState) {
getRoomEncState(event, roomId); getRoomEncState(event, roomId);
return; return;
} else if (event.data.action === "can_send_event") { } else if (event.data.action === Action.CanSendEvent) {
canSendEvent(event, roomId); canSendEvent(event, roomId);
return; return;
} }
@ -645,20 +679,20 @@ const onMessage = function(event) {
return; return;
} }
switch (event.data.action) { switch (event.data.action) {
case "membership_state": case Action.MembershipState:
getMembershipState(event, roomId, userId); getMembershipState(event, roomId, userId);
break; break;
case "invite": case Action.invite:
inviteUser(event, roomId, userId); inviteUser(event, roomId, userId);
break; break;
case "bot_options": case Action.BotOptions:
botOptions(event, roomId, userId); botOptions(event, roomId, userId);
break; break;
case "set_bot_options": case Action.SetBotOptions:
setBotOptions(event, roomId, userId); setBotOptions(event, roomId, userId);
break; break;
case "set_bot_power": case Action.SetBotPower:
setBotPower(event, roomId, userId, event.data.level); setBotPower(event, roomId, userId, event.data.level, event.data.ignoreIfGreater);
break; break;
default: default:
console.warn("Unhandled postMessage event with action '" + event.data.action +"'"); console.warn("Unhandled postMessage event with action '" + event.data.action +"'");
@ -667,16 +701,16 @@ const onMessage = function(event) {
}; };
let listenerCount = 0; let listenerCount = 0;
let openManagerUrl = null; let openManagerUrl: string = null;
export function startListening() { export function startListening(): void {
if (listenerCount === 0) { if (listenerCount === 0) {
window.addEventListener("message", onMessage, false); window.addEventListener("message", onMessage, false);
} }
listenerCount += 1; listenerCount += 1;
} }
export function stopListening() { export function stopListening(): void {
listenerCount -= 1; listenerCount -= 1;
if (listenerCount === 0) { if (listenerCount === 0) {
window.removeEventListener("message", onMessage); window.removeEventListener("message", onMessage);
@ -691,6 +725,6 @@ export function stopListening() {
} }
} }
export function setOpenManagerUrl(url) { export function setOpenManagerUrl(url: string): void {
openManagerUrl = url; openManagerUrl = url;
} }

View file

@ -14,12 +14,20 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
class Skinner { import React from "react";
constructor() {
this.components = null;
}
getComponent(name) { export interface IComponents {
[key: string]: React.Component;
}
export interface ISkinObject {
components: IComponents;
}
export class Skinner {
public components: IComponents = null;
public getComponent(name: string): React.Component {
if (!name) throw new Error(`Invalid component name: ${name}`); if (!name) throw new Error(`Invalid component name: ${name}`);
if (this.components === null) { if (this.components === null) {
throw new Error( throw new Error(
@ -30,7 +38,7 @@ class Skinner {
); );
} }
const doLookup = (components) => { const doLookup = (components: IComponents): React.Component => {
if (!components) return null; if (!components) return null;
let comp = components[name]; let comp = components[name];
// XXX: Temporarily also try 'views.' as we're currently // XXX: Temporarily also try 'views.' as we're currently
@ -58,7 +66,7 @@ class Skinner {
return comp; return comp;
} }
load(skinObject) { public load(skinObject: ISkinObject): void {
if (this.components !== null) { if (this.components !== null) {
throw new Error( throw new Error(
"Attempted to load a skin while a skin is already loaded"+ "Attempted to load a skin while a skin is already loaded"+
@ -72,6 +80,7 @@ class Skinner {
} }
// Now that we have a skin, load our components too // Now that we have a skin, load our components too
// eslint-disable-next-line @typescript-eslint/no-var-requires
const idx = require("./component-index"); const idx = require("./component-index");
if (!idx || !idx.components) throw new Error("Invalid react-sdk component index"); if (!idx || !idx.components) throw new Error("Invalid react-sdk component index");
for (const c in idx.components) { for (const c in idx.components) {
@ -79,7 +88,7 @@ class Skinner {
} }
} }
addComponent(name, comp) { public addComponent(name: string, comp: any) {
let slot = name; let slot = name;
if (comp.replaces !== undefined) { if (comp.replaces !== undefined) {
if (comp.replaces.indexOf('.') > -1) { if (comp.replaces.indexOf('.') > -1) {
@ -91,7 +100,7 @@ class Skinner {
this.components[slot] = comp; this.components[slot] = comp;
} }
reset() { public reset(): void {
this.components = null; this.components = null;
} }
} }
@ -105,8 +114,8 @@ class Skinner {
// See https://derickbailey.com/2016/03/09/creating-a-true-singleton-in-node-js-with-es6-symbols/ // See https://derickbailey.com/2016/03/09/creating-a-true-singleton-in-node-js-with-es6-symbols/
// or https://nodejs.org/api/modules.html#modules_module_caching_caveats // or https://nodejs.org/api/modules.html#modules_module_caching_caveats
// ("Modules are cached based on their resolved filename") // ("Modules are cached based on their resolved filename")
if (global.mxSkinner === undefined) { if (window.mxSkinner === undefined) {
global.mxSkinner = new Skinner(); window.mxSkinner = new Skinner();
} }
export default global.mxSkinner; export default window.mxSkinner;

View file

@ -44,7 +44,7 @@ import { Action } from "./dispatcher/actions";
import { EffectiveMembership, getEffectiveMembership, leaveRoomBehaviour } from "./utils/membership"; import { EffectiveMembership, getEffectiveMembership, leaveRoomBehaviour } from "./utils/membership";
import SdkConfig from "./SdkConfig"; import SdkConfig from "./SdkConfig";
import SettingsStore from "./settings/SettingsStore"; import SettingsStore from "./settings/SettingsStore";
import { UIFeature } from "./settings/UIFeature"; import { UIComponent, UIFeature } from "./settings/UIFeature";
import { CHAT_EFFECTS } from "./effects"; import { CHAT_EFFECTS } from "./effects";
import CallHandler from "./CallHandler"; import CallHandler from "./CallHandler";
import { guessAndSetDMRoom } from "./Rooms"; import { guessAndSetDMRoom } from "./Rooms";
@ -56,6 +56,7 @@ import InfoDialog from "./components/views/dialogs/InfoDialog";
import SlashCommandHelpDialog from "./components/views/dialogs/SlashCommandHelpDialog"; import SlashCommandHelpDialog from "./components/views/dialogs/SlashCommandHelpDialog";
import { logger } from "matrix-js-sdk/src/logger"; import { logger } from "matrix-js-sdk/src/logger";
import { shouldShowComponent } from "./customisations/helpers/UIComponents";
// XXX: workaround for https://github.com/microsoft/TypeScript/issues/31816 // XXX: workaround for https://github.com/microsoft/TypeScript/issues/31816
interface HTMLInputEvent extends Event { interface HTMLInputEvent extends Event {
@ -403,6 +404,7 @@ export const Commands = [
command: 'invite', command: 'invite',
args: '<user-id> [<reason>]', args: '<user-id> [<reason>]',
description: _td('Invites user with given id to current room'), description: _td('Invites user with given id to current room'),
isEnabled: () => shouldShowComponent(UIComponent.InviteUsers),
runFn: function(roomId, args) { runFn: function(roomId, args) {
if (args) { if (args) {
const [address, reason] = args.split(/\s+(.+)/); const [address, reason] = args.split(/\s+(.+)/);

View file

@ -166,6 +166,11 @@ function textForTopicEvent(ev: MatrixEvent): () => string | null {
}); });
} }
function textForRoomAvatarEvent(ev: MatrixEvent): () => string | null {
const senderDisplayName = ev?.sender?.name || ev.getSender();
return () => _t('%(senderDisplayName)s changed the room avatar.', { senderDisplayName });
}
function textForRoomNameEvent(ev: MatrixEvent): () => string | null { function textForRoomNameEvent(ev: MatrixEvent): () => string | null {
const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
@ -289,11 +294,27 @@ function textForServerACLEvent(ev: MatrixEvent): () => string | null {
function textForMessageEvent(ev: MatrixEvent): () => string | null { function textForMessageEvent(ev: MatrixEvent): () => string | null {
return () => { return () => {
const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
let message = senderDisplayName + ': ' + ev.getContent().body; let message = ev.getContent().body;
if (ev.isRedacted()) {
message = _t("Message deleted");
const unsigned = ev.getUnsigned();
const redactedBecauseUserId = unsigned?.redacted_because?.sender;
if (redactedBecauseUserId && redactedBecauseUserId !== ev.getSender()) {
const room = MatrixClientPeg.get().getRoom(ev.getRoomId());
const sender = room?.getMember(redactedBecauseUserId);
message = _t("Message deleted by %(name)s", { name: sender?.name
|| redactedBecauseUserId });
}
}
if (ev.getContent().msgtype === "m.emote") { if (ev.getContent().msgtype === "m.emote") {
message = "* " + senderDisplayName + " " + message; message = "* " + senderDisplayName + " " + message;
} else if (ev.getContent().msgtype === "m.image") { } else if (ev.getContent().msgtype === "m.image") {
message = _t('%(senderDisplayName)s sent an image.', { senderDisplayName }); message = _t('%(senderDisplayName)s sent an image.', { senderDisplayName });
} else if (ev.getType() == "m.sticker") {
message = _t('%(senderDisplayName)s sent a sticker.', { senderDisplayName });
} else {
// in this case, parse it as a plain text message
message = senderDisplayName + ': ' + message;
} }
return message; return message;
}; };
@ -669,6 +690,7 @@ interface IHandlers {
const handlers: IHandlers = { const handlers: IHandlers = {
'm.room.message': textForMessageEvent, 'm.room.message': textForMessageEvent,
'm.sticker': textForMessageEvent,
'm.call.invite': textForCallInviteEvent, 'm.call.invite': textForCallInviteEvent,
}; };
@ -677,6 +699,7 @@ const stateHandlers: IHandlers = {
'm.room.name': textForRoomNameEvent, 'm.room.name': textForRoomNameEvent,
'm.room.topic': textForTopicEvent, 'm.room.topic': textForTopicEvent,
'm.room.member': textForMemberEvent, 'm.room.member': textForMemberEvent,
"m.room.avatar": textForRoomAvatarEvent,
'm.room.third_party_invite': textForThreePidInviteEvent, 'm.room.third_party_invite': textForThreePidInviteEvent,
'm.room.history_visibility': textForHistoryVisibilityEvent, 'm.room.history_visibility': textForHistoryVisibilityEvent,
'm.room.power_levels': textForPowerEvent, 'm.room.power_levels': textForPowerEvent,

View file

@ -14,9 +14,13 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { IThreepid, ThreepidMedium } from "matrix-js-sdk/src/@types/threepids";
import { MatrixClient } from "matrix-js-sdk/src/client";
import IdentityAuthClient from './IdentityAuthClient'; import IdentityAuthClient from './IdentityAuthClient';
export async function getThreepidsWithBindStatus(client, filterMedium) { export async function getThreepidsWithBindStatus(
client: MatrixClient, filterMedium?: ThreepidMedium,
): Promise<IThreepid[]> {
const userId = client.getUserId(); const userId = client.getUserId();
let { threepids } = await client.getThreePids(); let { threepids } = await client.getThreePids();
@ -31,7 +35,7 @@ export async function getThreepidsWithBindStatus(client, filterMedium) {
const identityAccessToken = await authClient.getAccessToken({ check: false }); const identityAccessToken = await authClient.getAccessToken({ check: false });
// Restructure for lookup query // Restructure for lookup query
const query = threepids.map(({ medium, address }) => [medium, address]); const query = threepids.map(({ medium, address }): [string, string] => [medium, address]);
const lookupResults = await client.bulkLookupThreePids(query, identityAccessToken); const lookupResults = await client.bulkLookupThreePids(query, identityAccessToken);
// Record which are already bound // Record which are already bound

View file

@ -19,6 +19,7 @@ limitations under the License.
import React, { CSSProperties, RefObject, SyntheticEvent, useRef, useState } from "react"; import React, { CSSProperties, RefObject, SyntheticEvent, useRef, useState } from "react";
import ReactDOM from "react-dom"; import ReactDOM from "react-dom";
import classNames from "classnames"; import classNames from "classnames";
import FocusLock from "react-focus-lock";
import { Key } from "../../Keyboard"; import { Key } from "../../Keyboard";
import { Writeable } from "../../@types/common"; import { Writeable } from "../../@types/common";
@ -43,8 +44,6 @@ function getOrCreateContainer(): HTMLDivElement {
return container; return container;
} }
const ARIA_MENU_ITEM_ROLES = new Set(["menuitem", "menuitemcheckbox", "menuitemradio"]);
export interface IPosition { export interface IPosition {
top?: number; top?: number;
bottom?: number; bottom?: number;
@ -84,6 +83,10 @@ export interface IProps extends IPosition {
// it will be mounted to a container at the root of the DOM. // it will be mounted to a container at the root of the DOM.
mountAsChild?: boolean; mountAsChild?: boolean;
// If specified, contents will be wrapped in a FocusLock, this is only needed if the context menu is being rendered
// within an existing FocusLock e.g inside a modal.
focusLock?: boolean;
// Function to be called on menu close // Function to be called on menu close
onFinished(); onFinished();
// on resize callback // on resize callback
@ -99,7 +102,7 @@ interface IState {
// this will allow the ContextMenu to manage its own focus using arrow keys as per the ARIA guidelines. // this will allow the ContextMenu to manage its own focus using arrow keys as per the ARIA guidelines.
@replaceableComponent("structures.ContextMenu") @replaceableComponent("structures.ContextMenu")
export class ContextMenu extends React.PureComponent<IProps, IState> { export class ContextMenu extends React.PureComponent<IProps, IState> {
private initialFocus: HTMLElement; private readonly initialFocus: HTMLElement;
static defaultProps = { static defaultProps = {
hasBackground: true, hasBackground: true,
@ -108,6 +111,7 @@ export class ContextMenu extends React.PureComponent<IProps, IState> {
constructor(props, context) { constructor(props, context) {
super(props, context); super(props, context);
this.state = { this.state = {
contextMenuElem: null, contextMenuElem: null,
}; };
@ -121,14 +125,13 @@ export class ContextMenu extends React.PureComponent<IProps, IState> {
this.initialFocus.focus(); this.initialFocus.focus();
} }
private collectContextMenuRect = (element) => { private collectContextMenuRect = (element: HTMLDivElement) => {
// We don't need to clean up when unmounting, so ignore // We don't need to clean up when unmounting, so ignore
if (!element) return; if (!element) return;
let first = element.querySelector('[role^="menuitem"]'); const first = element.querySelector<HTMLElement>('[role^="menuitem"]')
if (!first) { || element.querySelector<HTMLElement>('[tab-index]');
first = element.querySelector('[tab-index]');
}
if (first) { if (first) {
first.focus(); first.focus();
} }
@ -205,7 +208,7 @@ export class ContextMenu extends React.PureComponent<IProps, IState> {
descending = true; descending = true;
} }
} }
} while (element && !ARIA_MENU_ITEM_ROLES.has(element.getAttribute("role"))); } while (element && !element.getAttribute("role")?.startsWith("menuitem"));
if (element) { if (element) {
(element as HTMLElement).focus(); (element as HTMLElement).focus();
@ -226,6 +229,11 @@ export class ContextMenu extends React.PureComponent<IProps, IState> {
} }
}; };
private onClick = (ev: React.MouseEvent) => {
// Don't allow clicks to escape the context menu wrapper
ev.stopPropagation();
};
private onKeyDown = (ev: React.KeyboardEvent) => { private onKeyDown = (ev: React.KeyboardEvent) => {
// don't let keyboard handling escape the context menu // don't let keyboard handling escape the context menu
ev.stopPropagation(); ev.stopPropagation();
@ -378,11 +386,23 @@ export class ContextMenu extends React.PureComponent<IProps, IState> {
); );
} }
let body = <>
{ chevron }
{ props.children }
</>;
if (props.focusLock) {
body = <FocusLock>
{ body }
</FocusLock>;
}
return ( return (
<div <div
className={classNames("mx_ContextualMenu_wrapper", this.props.wrapperClassName)} className={classNames("mx_ContextualMenu_wrapper", this.props.wrapperClassName)}
style={{ ...position, ...wrapperStyle }} style={{ ...position, ...wrapperStyle }}
onKeyDown={this.onKeyDown} onKeyDown={this.onKeyDown}
onClick={this.onClick}
onContextMenu={this.onContextMenuPreventBubbling} onContextMenu={this.onContextMenuPreventBubbling}
> >
<div <div
@ -391,8 +411,7 @@ export class ContextMenu extends React.PureComponent<IProps, IState> {
ref={this.collectContextMenuRect} ref={this.collectContextMenuRect}
role={this.props.managed ? "menu" : undefined} role={this.props.managed ? "menu" : undefined}
> >
{ chevron } { body }
{ props.children }
</div> </div>
{ background } { background }
</div> </div>

View file

@ -37,6 +37,7 @@ import TimelinePanel from "./TimelinePanel";
import Spinner from "../views/elements/Spinner"; import Spinner from "../views/elements/Spinner";
import { TileShape } from '../views/rooms/EventTile'; import { TileShape } from '../views/rooms/EventTile';
import { Layout } from "../../settings/Layout"; import { Layout } from "../../settings/Layout";
import RoomContext, { TimelineRenderingType } from '../../contexts/RoomContext';
interface IProps { interface IProps {
roomId: string; roomId: string;
@ -57,6 +58,7 @@ class FilePanel extends React.Component<IProps, IState> {
// added to the timeline. // added to the timeline.
private decryptingEvents = new Set<string>(); private decryptingEvents = new Set<string>();
public noRoom: boolean; public noRoom: boolean;
static contextType = RoomContext;
state = { state = {
timelineSet: null, timelineSet: null,
@ -249,9 +251,11 @@ class FilePanel extends React.Component<IProps, IState> {
const isRoomEncrypted = this.noRoom ? false : MatrixClientPeg.get().isRoomEncrypted(this.props.roomId); const isRoomEncrypted = this.noRoom ? false : MatrixClientPeg.get().isRoomEncrypted(this.props.roomId);
if (this.state.timelineSet) { if (this.state.timelineSet) {
// console.log("rendering TimelinePanel for timelineSet " + this.state.timelineSet.room.roomId + " " +
// "(" + this.state.timelineSet._timelines.join(", ") + ")" + " with key " + this.props.roomId);
return ( return (
<RoomContext.Provider value={{
...this.context,
timelineRenderingType: TimelineRenderingType.File,
}}>
<BaseCard <BaseCard
className="mx_FilePanel" className="mx_FilePanel"
onClose={this.props.onClose} onClose={this.props.onClose}
@ -271,9 +275,14 @@ class FilePanel extends React.Component<IProps, IState> {
layout={Layout.Group} layout={Layout.Group}
/> />
</BaseCard> </BaseCard>
</RoomContext.Provider>
); );
} else { } else {
return ( return (
<RoomContext.Provider value={{
...this.context,
timelineRenderingType: TimelineRenderingType.File,
}}>
<BaseCard <BaseCard
className="mx_FilePanel" className="mx_FilePanel"
onClose={this.props.onClose} onClose={this.props.onClose}
@ -281,6 +290,7 @@ class FilePanel extends React.Component<IProps, IState> {
> >
<Spinner /> <Spinner />
</BaseCard> </BaseCard>
</RoomContext.Provider>
); );
} }
} }

View file

@ -84,7 +84,7 @@ interface IState {
stageState?: IStageStatus; stageState?: IStageStatus;
busy: boolean; busy: boolean;
errorText?: string; errorText?: string;
stageErrorText?: string; errorCode?: string;
submitButtonEnabled: boolean; submitButtonEnabled: boolean;
} }
@ -103,7 +103,7 @@ export default class InteractiveAuthComponent extends React.Component<IProps, IS
authStage: null, authStage: null,
busy: false, busy: false,
errorText: null, errorText: null,
stageErrorText: null, errorCode: null,
submitButtonEnabled: false, submitButtonEnabled: false,
}; };
@ -145,6 +145,7 @@ export default class InteractiveAuthComponent extends React.Component<IProps, IS
const msg = error.message || error.toString(); const msg = error.message || error.toString();
this.setState({ this.setState({
errorText: msg, errorText: msg,
errorCode: error.errcode,
}); });
}); });
} }
@ -186,6 +187,7 @@ export default class InteractiveAuthComponent extends React.Component<IProps, IS
authStage: stageType, authStage: stageType,
stageState: stageState, stageState: stageState,
errorText: stageState.error, errorText: stageState.error,
errorCode: stageState.errcode,
}, () => { }, () => {
if (oldStage !== stageType) { if (oldStage !== stageType) {
this.setFocus(); this.setFocus();
@ -208,7 +210,7 @@ export default class InteractiveAuthComponent extends React.Component<IProps, IS
this.setState({ this.setState({
busy: true, busy: true,
errorText: null, errorText: null,
stageErrorText: null, errorCode: null,
}); });
} }
// The JS SDK eagerly reports itself as "not busy" right after any // The JS SDK eagerly reports itself as "not busy" right after any
@ -235,7 +237,15 @@ export default class InteractiveAuthComponent extends React.Component<IProps, IS
this.props.onAuthFinished(false, ERROR_USER_CANCELLED); this.props.onAuthFinished(false, ERROR_USER_CANCELLED);
}; };
private renderCurrentStage(): JSX.Element { private onAuthStageFailed = (e: Error): void => {
this.props.onAuthFinished(false, e);
};
private setEmailSid = (sid: string): void => {
this.authLogic.setEmailSid(sid);
};
render() {
const stage = this.state.authStage; const stage = this.state.authStage;
if (!stage) { if (!stage) {
if (this.state.busy) { if (this.state.busy) {
@ -255,7 +265,8 @@ export default class InteractiveAuthComponent extends React.Component<IProps, IS
clientSecret={this.authLogic.getClientSecret()} clientSecret={this.authLogic.getClientSecret()}
stageParams={this.authLogic.getStageParams(stage)} stageParams={this.authLogic.getStageParams(stage)}
submitAuthDict={this.submitAuthDict} submitAuthDict={this.submitAuthDict}
errorText={this.state.stageErrorText} errorText={this.state.errorText}
errorCode={this.state.errorCode}
busy={this.state.busy} busy={this.state.busy}
inputs={this.props.inputs} inputs={this.props.inputs}
stageState={this.state.stageState} stageState={this.state.stageState}
@ -269,32 +280,4 @@ export default class InteractiveAuthComponent extends React.Component<IProps, IS
/> />
); );
} }
private onAuthStageFailed = (e: Error): void => {
this.props.onAuthFinished(false, e);
};
private setEmailSid = (sid: string): void => {
this.authLogic.setEmailSid(sid);
};
render() {
let error = null;
if (this.state.errorText) {
error = (
<div className="error">
{ this.state.errorText }
</div>
);
}
return (
<div>
<div>
{ this.renderCurrentStage() }
{ error }
</div>
</div>
);
}
} }

View file

@ -42,7 +42,7 @@ import linkifyMatrix from "../../linkify-matrix";
import * as Lifecycle from '../../Lifecycle'; import * as Lifecycle from '../../Lifecycle';
// LifecycleStore is not used but does listen to and dispatch actions // LifecycleStore is not used but does listen to and dispatch actions
import '../../stores/LifecycleStore'; import '../../stores/LifecycleStore';
import PageTypes from '../../PageTypes'; import PageType from '../../PageTypes';
import createRoom, { IOpts } from "../../createRoom"; import createRoom, { IOpts } from "../../createRoom";
import { _t, _td, getCurrentLanguage } from '../../languageHandler'; import { _t, _td, getCurrentLanguage } from '../../languageHandler';
@ -208,7 +208,7 @@ interface IState {
view: Views; view: Views;
// What the LoggedInView would be showing if visible // What the LoggedInView would be showing if visible
// eslint-disable-next-line camelcase // eslint-disable-next-line camelcase
page_type?: PageTypes; page_type?: PageType;
// The ID of the room we're viewing. This is either populated directly // The ID of the room we're viewing. This is either populated directly
// in the case where we view a room by ID or by RoomView when it resolves // in the case where we view a room by ID or by RoomView when it resolves
// what ID an alias points at. // what ID an alias points at.
@ -724,7 +724,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
break; break;
} }
case 'view_my_groups': case 'view_my_groups':
this.setPage(PageTypes.MyGroups); this.setPage(PageType.MyGroups);
this.notifyNewScreen('groups'); this.notifyNewScreen('groups');
break; break;
case 'view_group': case 'view_group':
@ -763,7 +763,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
localStorage.setItem("mx_seenSpacesBeta", "1"); localStorage.setItem("mx_seenSpacesBeta", "1");
// We just dispatch the page change rather than have to worry about // We just dispatch the page change rather than have to worry about
// what the logic is for each of these branches. // what the logic is for each of these branches.
if (this.state.page_type === PageTypes.MyGroups) { if (this.state.page_type === PageType.MyGroups) {
dis.dispatch({ action: 'view_last_screen' }); dis.dispatch({ action: 'view_last_screen' });
} else { } else {
dis.dispatch({ action: 'view_my_groups' }); dis.dispatch({ action: 'view_my_groups' });
@ -849,7 +849,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
} }
}; };
private setPage(pageType: string) { private setPage(pageType: PageType) {
this.setState({ this.setState({
page_type: pageType, page_type: pageType,
}); });
@ -956,7 +956,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
this.setState({ this.setState({
view: Views.LOGGED_IN, view: Views.LOGGED_IN,
currentRoomId: roomInfo.room_id || null, currentRoomId: roomInfo.room_id || null,
page_type: PageTypes.RoomView, page_type: PageType.RoomView,
threepidInvite: roomInfo.threepid_invite, threepidInvite: roomInfo.threepid_invite,
roomOobData: roomInfo.oob_data, roomOobData: roomInfo.oob_data,
ready: true, ready: true,
@ -984,7 +984,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
currentGroupId: groupId, currentGroupId: groupId,
currentGroupIsNew: payload.group_is_new, currentGroupIsNew: payload.group_is_new,
}); });
this.setPage(PageTypes.GroupView); this.setPage(PageType.GroupView);
this.notifyNewScreen('group/' + groupId); this.notifyNewScreen('group/' + groupId);
} }
@ -1027,7 +1027,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
justRegistered, justRegistered,
currentRoomId: null, currentRoomId: null,
}); });
this.setPage(PageTypes.HomePage); this.setPage(PageType.HomePage);
this.notifyNewScreen('home'); this.notifyNewScreen('home');
ThemeController.isLogin = false; ThemeController.isLogin = false;
this.themeWatcher.recheck(); this.themeWatcher.recheck();
@ -1045,7 +1045,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
} }
this.notifyNewScreen('user/' + userId); this.notifyNewScreen('user/' + userId);
this.setState({ currentUserId: userId }); this.setState({ currentUserId: userId });
this.setPage(PageTypes.UserView); this.setPage(PageType.UserView);
}); });
} }

View file

@ -48,6 +48,8 @@ import Spinner from "../views/elements/Spinner";
import TileErrorBoundary from '../views/messages/TileErrorBoundary'; import TileErrorBoundary from '../views/messages/TileErrorBoundary';
import { RoomPermalinkCreator } from "../../utils/permalinks/Permalinks"; import { RoomPermalinkCreator } from "../../utils/permalinks/Permalinks";
import EditorStateTransfer from "../../utils/EditorStateTransfer"; import EditorStateTransfer from "../../utils/EditorStateTransfer";
import { logger } from 'matrix-js-sdk/src/logger';
import { Action } from '../../dispatcher/actions';
const CONTINUATION_MAX_INTERVAL = 5 * 60 * 1000; // 5 minutes const CONTINUATION_MAX_INTERVAL = 5 * 60 * 1000; // 5 minutes
const continuedTypes = [EventType.Sticker, EventType.RoomMessage]; const continuedTypes = [EventType.Sticker, EventType.RoomMessage];
@ -60,7 +62,7 @@ const groupedEvents = [
// check if there is a previous event and it has the same sender as this event // check if there is a previous event and it has the same sender as this event
// and the types are the same/is in continuedTypes and the time between them is <= CONTINUATION_MAX_INTERVAL // and the types are the same/is in continuedTypes and the time between them is <= CONTINUATION_MAX_INTERVAL
function shouldFormContinuation( export function shouldFormContinuation(
prevEvent: MatrixEvent, prevEvent: MatrixEvent,
mxEvent: MatrixEvent, mxEvent: MatrixEvent,
showHiddenEvents: boolean, showHiddenEvents: boolean,
@ -287,6 +289,15 @@ export default class MessagePanel extends React.Component<IProps, IState> {
ghostReadMarkers, ghostReadMarkers,
}); });
} }
const pendingEditItem = this.pendingEditItem;
if (!this.props.editState && this.props.room && pendingEditItem) {
defaultDispatcher.dispatch({
action: Action.EditEvent,
event: this.props.room.findEventById(pendingEditItem),
timelineRenderingType: this.context.timelineRenderingType,
});
}
} }
private calculateRoomMembersCount = (): void => { private calculateRoomMembersCount = (): void => {
@ -550,10 +561,14 @@ export default class MessagePanel extends React.Component<IProps, IState> {
return { nextEvent, nextTile }; return { nextEvent, nextTile };
} }
private get roomHasPendingEdit(): string { private get pendingEditItem(): string | undefined {
return this.props.room && localStorage.getItem(`mx_edit_room_${this.props.room.roomId}`); try {
return localStorage.getItem(`mx_edit_room_${this.props.room.roomId}_${this.context.timelineRenderingType}`);
} catch (err) {
logger.error(err);
return undefined;
}
} }
private getEventTiles(): ReactNode[] { private getEventTiles(): ReactNode[] {
this.eventNodes = {}; this.eventNodes = {};
@ -663,13 +678,6 @@ export default class MessagePanel extends React.Component<IProps, IState> {
} }
} }
if (!this.props.editState && this.roomHasPendingEdit) {
defaultDispatcher.dispatch({
action: "edit_event",
event: this.props.room.findEventById(this.roomHasPendingEdit),
});
}
if (grouper) { if (grouper) {
ret.push(...grouper.getTiles()); ret.push(...grouper.getTiles());
} }

View file

@ -24,6 +24,7 @@ import TimelinePanel from "./TimelinePanel";
import Spinner from "../views/elements/Spinner"; import Spinner from "../views/elements/Spinner";
import { TileShape } from "../views/rooms/EventTile"; import { TileShape } from "../views/rooms/EventTile";
import { Layout } from "../../settings/Layout"; import { Layout } from "../../settings/Layout";
import RoomContext, { TimelineRenderingType } from "../../contexts/RoomContext";
interface IProps { interface IProps {
onClose(): void; onClose(): void;
@ -34,6 +35,7 @@ interface IProps {
*/ */
@replaceableComponent("structures.NotificationPanel") @replaceableComponent("structures.NotificationPanel")
export default class NotificationPanel extends React.PureComponent<IProps> { export default class NotificationPanel extends React.PureComponent<IProps> {
static contextType = RoomContext;
render() { render() {
const emptyState = (<div className="mx_RightPanel_empty mx_NotificationPanel_empty"> const emptyState = (<div className="mx_RightPanel_empty mx_NotificationPanel_empty">
<h2>{ _t('Youre all caught up') }</h2> <h2>{ _t('Youre all caught up') }</h2>
@ -61,8 +63,13 @@ export default class NotificationPanel extends React.PureComponent<IProps> {
content = <Spinner />; content = <Spinner />;
} }
return <BaseCard className="mx_NotificationPanel" onClose={this.props.onClose} withoutScrollContainer> return <RoomContext.Provider value={{
...this.context,
timelineRenderingType: TimelineRenderingType.Notification,
}}>
<BaseCard className="mx_NotificationPanel" onClose={this.props.onClose} withoutScrollContainer>
{ content } { content }
</BaseCard>; </BaseCard>
</RoomContext.Provider>;
} }
} }

View file

@ -48,8 +48,8 @@ import { Layout } from "../../settings/Layout";
import AccessibleButton from "../views/elements/AccessibleButton"; import AccessibleButton from "../views/elements/AccessibleButton";
import RightPanelStore from "../../stores/RightPanelStore"; import RightPanelStore from "../../stores/RightPanelStore";
import { haveTileForEvent } from "../views/rooms/EventTile"; import { haveTileForEvent } from "../views/rooms/EventTile";
import RoomContext from "../../contexts/RoomContext"; import RoomContext, { TimelineRenderingType } from "../../contexts/RoomContext";
import MatrixClientContext from "../../contexts/MatrixClientContext"; import MatrixClientContext, { withMatrixClientHOC, MatrixClientProps } from "../../contexts/MatrixClientContext";
import { E2EStatus, shieldStatusForRoom } from '../../utils/ShieldUtils'; import { E2EStatus, shieldStatusForRoom } from '../../utils/ShieldUtils';
import { Action } from "../../dispatcher/actions"; import { Action } from "../../dispatcher/actions";
import { IMatrixClientCreds } from "../../MatrixClientPeg"; import { IMatrixClientCreds } from "../../MatrixClientPeg";
@ -91,6 +91,7 @@ import TopUnreadMessagesBar from "../views/rooms/TopUnreadMessagesBar";
import SpaceStore from "../../stores/SpaceStore"; import SpaceStore from "../../stores/SpaceStore";
import { logger } from "matrix-js-sdk/src/logger"; import { logger } from "matrix-js-sdk/src/logger";
import { EventTimeline } from 'matrix-js-sdk/src/models/event-timeline';
const DEBUG = false; const DEBUG = false;
let debuglog = function(msg: string) {}; let debuglog = function(msg: string) {};
@ -102,7 +103,7 @@ if (DEBUG) {
debuglog = logger.log.bind(console); debuglog = logger.log.bind(console);
} }
interface IProps { interface IRoomProps extends MatrixClientProps {
threepidInvite: IThreepidInvite; threepidInvite: IThreepidInvite;
oobData?: IOOBData; oobData?: IOOBData;
@ -113,7 +114,7 @@ interface IProps {
onRegistered?(credentials: IMatrixClientCreds): void; onRegistered?(credentials: IMatrixClientCreds): void;
} }
export interface IState { export interface IRoomState {
room?: Room; room?: Room;
roomId?: string; roomId?: string;
roomAlias?: string; roomAlias?: string;
@ -187,10 +188,12 @@ export interface IState {
// if it did we don't want the room to be marked as read as soon as it is loaded. // if it did we don't want the room to be marked as read as soon as it is loaded.
wasContextSwitch?: boolean; wasContextSwitch?: boolean;
editState?: EditorStateTransfer; editState?: EditorStateTransfer;
timelineRenderingType: TimelineRenderingType;
liveTimeline?: EventTimeline;
} }
@replaceableComponent("structures.RoomView") @replaceableComponent("structures.RoomView")
export default class RoomView extends React.Component<IProps, IState> { export class RoomView extends React.Component<IRoomProps, IRoomState> {
private readonly dispatcherRef: string; private readonly dispatcherRef: string;
private readonly roomStoreToken: EventSubscription; private readonly roomStoreToken: EventSubscription;
private readonly rightPanelStoreToken: EventSubscription; private readonly rightPanelStoreToken: EventSubscription;
@ -247,6 +250,8 @@ export default class RoomView extends React.Component<IProps, IState> {
showDisplaynameChanges: true, showDisplaynameChanges: true,
matrixClientIsReady: this.context && this.context.isInitialSyncComplete(), matrixClientIsReady: this.context && this.context.isInitialSyncComplete(),
dragCounter: 0, dragCounter: 0,
timelineRenderingType: TimelineRenderingType.Room,
liveTimeline: undefined,
}; };
this.dispatcherRef = dis.register(this.onAction); this.dispatcherRef = dis.register(this.onAction);
@ -336,7 +341,7 @@ export default class RoomView extends React.Component<IProps, IState> {
const roomId = RoomViewStore.getRoomId(); const roomId = RoomViewStore.getRoomId();
const newState: Pick<IState, any> = { const newState: Pick<IRoomState, any> = {
roomId, roomId,
roomAlias: RoomViewStore.getRoomAlias(), roomAlias: RoomViewStore.getRoomAlias(),
roomLoading: RoomViewStore.isRoomLoading(), roomLoading: RoomViewStore.isRoomLoading(),
@ -808,7 +813,9 @@ export default class RoomView extends React.Component<IProps, IState> {
this.onSearchClick(); this.onSearchClick();
break; break;
case "edit_event": { case Action.EditEvent: {
// Quit early if we're trying to edit events in wrong rendering context
if (payload.timelineRenderingType !== this.state.timelineRenderingType) return;
const editState = payload.event ? new EditorStateTransfer(payload.event) : null; const editState = payload.event ? new EditorStateTransfer(payload.event) : null;
this.setState({ editState }, () => { this.setState({ editState }, () => {
if (payload.event) { if (payload.event) {
@ -932,6 +939,10 @@ export default class RoomView extends React.Component<IProps, IState> {
this.updateE2EStatus(room); this.updateE2EStatus(room);
this.updatePermissions(room); this.updatePermissions(room);
this.checkWidgets(room); this.checkWidgets(room);
this.setState({
liveTimeline: room.getLiveTimeline(),
});
}; };
private async calculateRecommendedVersion(room: Room) { private async calculateRecommendedVersion(room: Room) {
@ -2086,3 +2097,6 @@ export default class RoomView extends React.Component<IProps, IState> {
); );
} }
} }
const RoomViewWithMatrixClient = withMatrixClientHOC(RoomView);
export default RoomViewWithMatrixClient;

View file

@ -15,17 +15,17 @@ limitations under the License.
*/ */
import React, { import React, {
Dispatch,
KeyboardEvent,
KeyboardEventHandler,
ReactNode, ReactNode,
SetStateAction,
useCallback, useCallback,
useContext,
useEffect, useEffect,
useMemo, useMemo,
useRef, useRef,
useState, useState,
KeyboardEvent,
KeyboardEventHandler,
useContext,
SetStateAction,
Dispatch,
} from "react"; } from "react";
import { Room } from "matrix-js-sdk/src/models/room"; import { Room } from "matrix-js-sdk/src/models/room";
import { RoomHierarchy } from "matrix-js-sdk/src/room-hierarchy"; import { RoomHierarchy } from "matrix-js-sdk/src/room-hierarchy";
@ -33,7 +33,8 @@ import { EventType, RoomType } from "matrix-js-sdk/src/@types/event";
import { IHierarchyRelation, IHierarchyRoom } from "matrix-js-sdk/src/@types/spaces"; import { IHierarchyRelation, IHierarchyRoom } from "matrix-js-sdk/src/@types/spaces";
import { MatrixClient } from "matrix-js-sdk/src/matrix"; import { MatrixClient } from "matrix-js-sdk/src/matrix";
import classNames from "classnames"; import classNames from "classnames";
import { sortBy } from "lodash"; import { sortBy, uniqBy } from "lodash";
import { GuestAccess, HistoryVisibility } from "matrix-js-sdk/src/@types/partials";
import dis from "../../dispatcher/dispatcher"; import dis from "../../dispatcher/dispatcher";
import defaultDispatcher from "../../dispatcher/dispatcher"; import defaultDispatcher from "../../dispatcher/dispatcher";
@ -333,6 +334,30 @@ interface IHierarchyLevelProps {
onToggleClick?(parentId: string, childId: string): void; onToggleClick?(parentId: string, childId: string): void;
} }
const toLocalRoom = (cli: MatrixClient, room: IHierarchyRoom): IHierarchyRoom => {
const history = cli.getRoomUpgradeHistory(room.room_id, true);
const cliRoom = history[history.length - 1];
if (cliRoom) {
return {
...room,
room_id: cliRoom.roomId,
room_type: cliRoom.getType(),
name: cliRoom.name,
topic: cliRoom.currentState.getStateEvents(EventType.RoomTopic, "")?.getContent().topic,
avatar_url: cliRoom.getMxcAvatarUrl(),
canonical_alias: cliRoom.getCanonicalAlias(),
aliases: cliRoom.getAltAliases(),
world_readable: cliRoom.currentState.getStateEvents(EventType.RoomHistoryVisibility, "")?.getContent()
.history_visibility === HistoryVisibility.WorldReadable,
guest_can_join: cliRoom.currentState.getStateEvents(EventType.RoomGuestAccess, "")?.getContent()
.guest_access === GuestAccess.CanJoin,
num_joined_members: cliRoom.getJoinedMemberCount(),
};
}
return room;
};
export const HierarchyLevel = ({ export const HierarchyLevel = ({
root, root,
roomSet, roomSet,
@ -353,7 +378,7 @@ export const HierarchyLevel = ({
const [subspaces, childRooms] = sortedChildren.reduce((result, ev: IHierarchyRelation) => { const [subspaces, childRooms] = sortedChildren.reduce((result, ev: IHierarchyRelation) => {
const room = hierarchy.roomMap.get(ev.state_key); const room = hierarchy.roomMap.get(ev.state_key);
if (room && roomSet.has(room)) { if (room && roomSet.has(room)) {
result[room.room_type === RoomType.Space ? 0 : 1].push(room); result[room.room_type === RoomType.Space ? 0 : 1].push(toLocalRoom(cli, room));
} }
return result; return result;
}, [[] as IHierarchyRoom[], [] as IHierarchyRoom[]]); }, [[] as IHierarchyRoom[], [] as IHierarchyRoom[]]);
@ -361,7 +386,7 @@ export const HierarchyLevel = ({
const newParents = new Set(parents).add(root.room_id); const newParents = new Set(parents).add(root.room_id);
return <React.Fragment> return <React.Fragment>
{ {
childRooms.map(room => ( uniqBy(childRooms, "room_id").map(room => (
<Tile <Tile
key={room.room_id} key={room.room_id}
room={room} room={room}
@ -410,50 +435,39 @@ export const HierarchyLevel = ({
const INITIAL_PAGE_SIZE = 20; const INITIAL_PAGE_SIZE = 20;
export const useSpaceSummary = (space: Room): { export const useRoomHierarchy = (space: Room): {
loading: boolean; loading: boolean;
rooms: IHierarchyRoom[]; rooms: IHierarchyRoom[];
hierarchy: RoomHierarchy; hierarchy: RoomHierarchy;
loadMore(pageSize?: number): Promise <void>; loadMore(pageSize?: number): Promise <void>;
} => { } => {
const [rooms, setRooms] = useState<IHierarchyRoom[]>([]); const [rooms, setRooms] = useState<IHierarchyRoom[]>([]);
const [loading, setLoading] = useState(true);
const [hierarchy, setHierarchy] = useState<RoomHierarchy>(); const [hierarchy, setHierarchy] = useState<RoomHierarchy>();
const resetHierarchy = useCallback(() => { const resetHierarchy = useCallback(() => {
const hierarchy = new RoomHierarchy(space, INITIAL_PAGE_SIZE); const hierarchy = new RoomHierarchy(space, INITIAL_PAGE_SIZE);
setHierarchy(hierarchy);
let discard = false;
hierarchy.load().then(() => { hierarchy.load().then(() => {
if (discard) return; if (space !== hierarchy.root) return; // discard stale results
setRooms(hierarchy.rooms); setRooms(hierarchy.rooms);
setLoading(false);
}); });
setHierarchy(hierarchy);
return () => {
discard = true;
};
}, [space]); }, [space]);
useEffect(resetHierarchy, [resetHierarchy]); useEffect(resetHierarchy, [resetHierarchy]);
useDispatcher(defaultDispatcher, (payload => { useDispatcher(defaultDispatcher, (payload => {
if (payload.action === Action.UpdateSpaceHierarchy) { if (payload.action === Action.UpdateSpaceHierarchy) {
setLoading(true);
setRooms([]); // TODO setRooms([]); // TODO
resetHierarchy(); resetHierarchy();
} }
})); }));
const loadMore = useCallback(async (pageSize?: number) => { const loadMore = useCallback(async (pageSize?: number) => {
if (!hierarchy.canLoadMore || hierarchy.noSupport) return; if (hierarchy.loading || !hierarchy.canLoadMore || hierarchy.noSupport) return;
setLoading(true);
await hierarchy.load(pageSize); await hierarchy.load(pageSize);
setRooms(hierarchy.rooms); setRooms(hierarchy.rooms);
setLoading(false);
}, [hierarchy]); }, [hierarchy]);
const loading = hierarchy?.loading ?? true;
return { loading, rooms, hierarchy, loadMore }; return { loading, rooms, hierarchy, loadMore };
}; };
@ -587,7 +601,7 @@ const SpaceHierarchy = ({
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 { loading, rooms, hierarchy, loadMore } = useSpaceSummary(space); const { loading, rooms, hierarchy, loadMore } = useRoomHierarchy(space);
const filteredRoomSet = useMemo<Set<IHierarchyRoom>>(() => { const filteredRoomSet = useMemo<Set<IHierarchyRoom>>(() => {
if (!rooms?.length) return new Set(); if (!rooms?.length) return new Set();
@ -648,8 +662,6 @@ const SpaceHierarchy = ({
return <RovingTabIndexProvider onKeyDown={onKeyDown} handleHomeEnd handleUpDown> return <RovingTabIndexProvider onKeyDown={onKeyDown} handleHomeEnd handleUpDown>
{ ({ onKeyDownHandler }) => { { ({ onKeyDownHandler }) => {
let content: JSX.Element; let content: JSX.Element;
let loader: JSX.Element;
if (loading && !rooms.length) { if (loading && !rooms.length) {
content = <Spinner />; content = <Spinner />;
} else { } else {
@ -671,19 +683,20 @@ const SpaceHierarchy = ({
}} }}
/> />
</>; </>;
} else if (!hierarchy.canLoadMore) {
if (hierarchy.canLoadMore) {
loader = <div ref={loaderRef}>
<Spinner />
</div>;
}
} else {
results = <div className="mx_SpaceHierarchy_noResults"> results = <div className="mx_SpaceHierarchy_noResults">
<h3>{ _t("No results found") }</h3> <h3>{ _t("No results found") }</h3>
<div>{ _t("You may want to try a different search or check for typos.") }</div> <div>{ _t("You may want to try a different search or check for typos.") }</div>
</div>; </div>;
} }
let loader: JSX.Element;
if (hierarchy.canLoadMore) {
loader = <div ref={loaderRef}>
<Spinner />
</div>;
}
content = <> content = <>
<div className="mx_SpaceHierarchy_listHeader"> <div className="mx_SpaceHierarchy_listHeader">
<h4>{ query.trim() ? _t("Results") : _t("Rooms and spaces") }</h4> <h4>{ query.trim() ? _t("Results") : _t("Rooms and spaces") }</h4>

View file

@ -82,6 +82,8 @@ import GroupAvatar from "../views/avatars/GroupAvatar";
import { useDispatcher } from "../../hooks/useDispatcher"; import { useDispatcher } from "../../hooks/useDispatcher";
import { logger } from "matrix-js-sdk/src/logger"; import { logger } from "matrix-js-sdk/src/logger";
import { shouldShowComponent } from "../../customisations/helpers/UIComponents";
import { UIComponent } from "../../settings/UIFeature";
interface IProps { interface IProps {
space: Room; space: Room;
@ -412,7 +414,9 @@ const SpaceLanding = ({ space }: { space: Room }) => {
const userId = cli.getUserId(); const userId = cli.getUserId();
let inviteButton; let inviteButton;
if ((myMembership === "join" && space.canInvite(userId)) || space.getJoinRule() === JoinRule.Public) { if (((myMembership === "join" && space.canInvite(userId)) || space.getJoinRule() === JoinRule.Public) &&
shouldShowComponent(UIComponent.InviteUsers)
) {
inviteButton = ( inviteButton = (
<AccessibleButton <AccessibleButton
kind="primary" kind="primary"
@ -730,7 +734,6 @@ const SpaceSetupPrivateInvite = ({ space, onFinished }) => {
</div> </div>
<div className="mx_SpaceRoomView_inviteTeammates_betaDisclaimer"> <div className="mx_SpaceRoomView_inviteTeammates_betaDisclaimer">
<BetaPill />
{ _t("<b>This is an experimental feature.</b> For now, " + { _t("<b>This is an experimental feature.</b> For now, " +
"new users receiving an invite will have to open the invite on <link/> to actually join.", {}, { "new users receiving an invite will have to open the invite on <link/> to actually join.", {}, {
b: sub => <b>{ sub }</b>, b: sub => <b>{ sub }</b>,

View file

@ -34,6 +34,8 @@ import { SetRightPanelPhasePayload } from '../../dispatcher/payloads/SetRightPan
import { Action } from '../../dispatcher/actions'; import { Action } from '../../dispatcher/actions';
import { MatrixClientPeg } from '../../MatrixClientPeg'; import { MatrixClientPeg } from '../../MatrixClientPeg';
import { E2EStatus } from '../../utils/ShieldUtils'; import { E2EStatus } from '../../utils/ShieldUtils';
import EditorStateTransfer from '../../utils/EditorStateTransfer';
import RoomContext, { TimelineRenderingType } from '../../contexts/RoomContext';
interface IProps { interface IProps {
room: Room; room: Room;
@ -47,10 +49,14 @@ interface IProps {
interface IState { interface IState {
replyToEvent?: MatrixEvent; replyToEvent?: MatrixEvent;
thread?: Thread; thread?: Thread;
editState?: EditorStateTransfer;
} }
@replaceableComponent("structures.ThreadView") @replaceableComponent("structures.ThreadView")
export default class ThreadView extends React.Component<IProps, IState> { export default class ThreadView extends React.Component<IProps, IState> {
static contextType = RoomContext;
private dispatcherRef: string; private dispatcherRef: string;
private timelinePanelRef: React.RefObject<TimelinePanel> = React.createRef(); private timelinePanelRef: React.RefObject<TimelinePanel> = React.createRef();
@ -90,6 +96,23 @@ export default class ThreadView extends React.Component<IProps, IState> {
this.setupThread(payload.event); this.setupThread(payload.event);
} }
} }
switch (payload.action) {
case Action.EditEvent: {
// Quit early if it's not a thread context
if (payload.timelineRenderingType !== TimelineRenderingType.Thread) return;
// Quit early if that's not a thread event
if (payload.event && !payload.event.getThread()) return;
const editState = payload.event ? new EditorStateTransfer(payload.event) : null;
this.setState({ editState }, () => {
if (payload.event) {
this.timelinePanelRef.current?.scrollToEventIfNeeded(payload.event.getId());
}
});
break;
}
default:
break;
}
}; };
private setupThread = (mxEv: MatrixEvent) => { private setupThread = (mxEv: MatrixEvent) => {
@ -124,6 +147,12 @@ export default class ThreadView extends React.Component<IProps, IState> {
public render(): JSX.Element { public render(): JSX.Element {
return ( return (
<RoomContext.Provider value={{
...this.context,
timelineRenderingType: TimelineRenderingType.Thread,
liveTimeline: this.state?.thread?.timelineSet?.getLiveTimeline(),
}}>
<BaseCard <BaseCard
className="mx_ThreadView" className="mx_ThreadView"
onClose={this.props.onClose} onClose={this.props.onClose}
@ -149,9 +178,11 @@ export default class ThreadView extends React.Component<IProps, IState> {
className="mx_RoomView_messagePanel mx_GroupLayout" className="mx_RoomView_messagePanel mx_GroupLayout"
permalinkCreator={this.props.permalinkCreator} permalinkCreator={this.props.permalinkCreator}
membersLoaded={true} membersLoaded={true}
editState={this.state.editState}
/> />
) } ) }
<MessageComposer
{ this.state?.thread?.timelineSet && (<MessageComposer
room={this.props.room} room={this.props.room}
resizeNotifier={this.props.resizeNotifier} resizeNotifier={this.props.resizeNotifier}
replyInThread={true} replyInThread={true}
@ -160,8 +191,9 @@ export default class ThreadView extends React.Component<IProps, IState> {
permalinkCreator={this.props.permalinkCreator} permalinkCreator={this.props.permalinkCreator}
e2eStatus={this.props.e2eStatus} e2eStatus={this.props.e2eStatus}
compact={true} compact={true}
/> />) }
</BaseCard> </BaseCard>
</RoomContext.Provider>
); );
} }
} }

View file

@ -20,6 +20,7 @@ import * as sdk from '../../../index';
import { SetupEncryptionStore, Phase } from '../../../stores/SetupEncryptionStore'; import { SetupEncryptionStore, Phase } from '../../../stores/SetupEncryptionStore';
import SetupEncryptionBody from "./SetupEncryptionBody"; import SetupEncryptionBody from "./SetupEncryptionBody";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import AccessibleButton from '../../views/elements/AccessibleButton';
interface IProps { interface IProps {
onFinished: () => void; onFinished: () => void;
@ -27,6 +28,7 @@ interface IProps {
interface IState { interface IState {
phase: Phase; phase: Phase;
lostKeys: boolean;
} }
@replaceableComponent("structures.auth.CompleteSecurity") @replaceableComponent("structures.auth.CompleteSecurity")
@ -36,12 +38,17 @@ export default class CompleteSecurity extends React.Component<IProps, IState> {
const store = SetupEncryptionStore.sharedInstance(); const store = SetupEncryptionStore.sharedInstance();
store.on("update", this.onStoreUpdate); store.on("update", this.onStoreUpdate);
store.start(); store.start();
this.state = { phase: store.phase }; this.state = { phase: store.phase, lostKeys: store.lostKeys() };
} }
private onStoreUpdate = (): void => { private onStoreUpdate = (): void => {
const store = SetupEncryptionStore.sharedInstance(); const store = SetupEncryptionStore.sharedInstance();
this.setState({ phase: store.phase }); this.setState({ phase: store.phase, lostKeys: store.lostKeys() });
};
private onSkipClick = (): void => {
const store = SetupEncryptionStore.sharedInstance();
store.skip();
}; };
public componentWillUnmount(): void { public componentWillUnmount(): void {
@ -53,15 +60,20 @@ export default class CompleteSecurity extends React.Component<IProps, IState> {
public render() { public render() {
const AuthPage = sdk.getComponent("auth.AuthPage"); const AuthPage = sdk.getComponent("auth.AuthPage");
const CompleteSecurityBody = sdk.getComponent("auth.CompleteSecurityBody"); const CompleteSecurityBody = sdk.getComponent("auth.CompleteSecurityBody");
const { phase } = this.state; const { phase, lostKeys } = this.state;
let icon; let icon;
let title; let title;
if (phase === Phase.Loading) { if (phase === Phase.Loading) {
return null; return null;
} else if (phase === Phase.Intro) { } else if (phase === Phase.Intro) {
if (lostKeys) {
icon = <span className="mx_CompleteSecurity_headerIcon mx_E2EIcon_warning" />;
title = _t("Unable to verify this login");
} else {
icon = <span className="mx_CompleteSecurity_headerIcon mx_E2EIcon_warning" />; icon = <span className="mx_CompleteSecurity_headerIcon mx_E2EIcon_warning" />;
title = _t("Verify this login"); title = _t("Verify this login");
}
} else if (phase === Phase.Done) { } else if (phase === Phase.Done) {
icon = <span className="mx_CompleteSecurity_headerIcon mx_E2EIcon_verified" />; icon = <span className="mx_CompleteSecurity_headerIcon mx_E2EIcon_verified" />;
title = _t("Session verified"); title = _t("Session verified");
@ -71,16 +83,29 @@ export default class CompleteSecurity extends React.Component<IProps, IState> {
} else if (phase === Phase.Busy) { } else if (phase === Phase.Busy) {
icon = <span className="mx_CompleteSecurity_headerIcon mx_E2EIcon_warning" />; icon = <span className="mx_CompleteSecurity_headerIcon mx_E2EIcon_warning" />;
title = _t("Verify this login"); title = _t("Verify this login");
} else if (phase === Phase.ConfirmReset) {
icon = <span className="mx_CompleteSecurity_headerIcon mx_E2EIcon_warning" />;
title = _t("Really reset verification keys?");
} else if (phase === Phase.Finished) {
// SetupEncryptionBody will take care of calling onFinished, we don't need to do anything
} else { } else {
throw new Error(`Unknown phase ${phase}`); throw new Error(`Unknown phase ${phase}`);
} }
let skipButton;
if (phase === Phase.Intro || phase === Phase.ConfirmReset) {
skipButton = (
<AccessibleButton onClick={this.onSkipClick} className="mx_CompleteSecurity_skip" aria-label={_t("Skip verification for now")} />
);
}
return ( return (
<AuthPage> <AuthPage>
<CompleteSecurityBody> <CompleteSecurityBody>
<h2 className="mx_CompleteSecurity_header"> <h2 className="mx_CompleteSecurity_header">
{ icon } { icon }
{ title } { title }
{ skipButton }
</h2> </h2>
<div className="mx_CompleteSecurity_body"> <div className="mx_CompleteSecurity_body">
<SetupEncryptionBody onFinished={this.props.onFinished} /> <SetupEncryptionBody onFinished={this.props.onFinished} />

View file

@ -46,6 +46,7 @@ interface IState {
phase: Phase; phase: Phase;
verificationRequest: VerificationRequest; verificationRequest: VerificationRequest;
backupInfo: IKeyBackupInfo; backupInfo: IKeyBackupInfo;
lostKeys: boolean;
} }
@replaceableComponent("structures.auth.SetupEncryptionBody") @replaceableComponent("structures.auth.SetupEncryptionBody")
@ -62,6 +63,7 @@ export default class SetupEncryptionBody extends React.Component<IProps, IState>
// Because of the latter, it lives in the state. // Because of the latter, it lives in the state.
verificationRequest: store.verificationRequest, verificationRequest: store.verificationRequest,
backupInfo: store.backupInfo, backupInfo: store.backupInfo,
lostKeys: store.lostKeys(),
}; };
} }
@ -75,6 +77,7 @@ export default class SetupEncryptionBody extends React.Component<IProps, IState>
phase: store.phase, phase: store.phase,
verificationRequest: store.verificationRequest, verificationRequest: store.verificationRequest,
backupInfo: store.backupInfo, backupInfo: store.backupInfo,
lostKeys: store.lostKeys(),
}); });
}; };
@ -105,11 +108,6 @@ export default class SetupEncryptionBody extends React.Component<IProps, IState>
}); });
}; };
private onSkipClick = () => {
const store = SetupEncryptionStore.sharedInstance();
store.skip();
};
private onSkipConfirmClick = () => { private onSkipConfirmClick = () => {
const store = SetupEncryptionStore.sharedInstance(); const store = SetupEncryptionStore.sharedInstance();
store.skipConfirm(); store.skipConfirm();
@ -120,6 +118,22 @@ export default class SetupEncryptionBody extends React.Component<IProps, IState>
store.returnAfterSkip(); store.returnAfterSkip();
}; };
private onResetClick = (ev: React.MouseEvent<HTMLAnchorElement>) => {
ev.preventDefault();
const store = SetupEncryptionStore.sharedInstance();
store.reset();
};
private onResetConfirmClick = () => {
const store = SetupEncryptionStore.sharedInstance();
store.resetConfirm();
};
private onResetBackClick = () => {
const store = SetupEncryptionStore.sharedInstance();
store.returnAfterReset();
};
private onDoneClick = () => { private onDoneClick = () => {
const store = SetupEncryptionStore.sharedInstance(); const store = SetupEncryptionStore.sharedInstance();
store.done(); store.done();
@ -132,6 +146,7 @@ export default class SetupEncryptionBody extends React.Component<IProps, IState>
public render() { public render() {
const { const {
phase, phase,
lostKeys,
} = this.state; } = this.state;
if (this.state.verificationRequest) { if (this.state.verificationRequest) {
@ -143,17 +158,35 @@ export default class SetupEncryptionBody extends React.Component<IProps, IState>
isRoomEncrypted={false} isRoomEncrypted={false}
/>; />;
} else if (phase === Phase.Intro) { } else if (phase === Phase.Intro) {
if (lostKeys) {
return (
<div>
<p>{ _t(
"It looks like you don't have a Security Key or any other devices you can " +
"verify against. This device will not be able to access old encrypted messages. " +
"In order to verify your identity on this device, you'll need to reset " +
"your verification keys.",
) }</p>
<div className="mx_CompleteSecurity_actionRow">
<AccessibleButton kind="primary" onClick={this.onResetConfirmClick}>
{ _t("Proceed with reset") }
</AccessibleButton>
</div>
</div>
);
} else {
const store = SetupEncryptionStore.sharedInstance(); const store = SetupEncryptionStore.sharedInstance();
let recoveryKeyPrompt; let recoveryKeyPrompt;
if (store.keyInfo && keyHasPassphrase(store.keyInfo)) { if (store.keyInfo && keyHasPassphrase(store.keyInfo)) {
recoveryKeyPrompt = _t("Use Security Key or Phrase"); recoveryKeyPrompt = _t("Verify with Security Key or Phrase");
} else if (store.keyInfo) { } else if (store.keyInfo) {
recoveryKeyPrompt = _t("Use Security Key"); recoveryKeyPrompt = _t("Verify with Security Key");
} }
let useRecoveryKeyButton; let useRecoveryKeyButton;
if (recoveryKeyPrompt) { if (recoveryKeyPrompt) {
useRecoveryKeyButton = <AccessibleButton kind="link" onClick={this.onUsePassphraseClick}> useRecoveryKeyButton = <AccessibleButton kind="primary" onClick={this.onUsePassphraseClick}>
{ recoveryKeyPrompt } { recoveryKeyPrompt }
</AccessibleButton>; </AccessibleButton>;
} }
@ -161,7 +194,7 @@ export default class SetupEncryptionBody extends React.Component<IProps, IState>
let verifyButton; let verifyButton;
if (store.hasDevicesToVerifyAgainst) { if (store.hasDevicesToVerifyAgainst) {
verifyButton = <AccessibleButton kind="primary" onClick={this.onVerifyClick}> verifyButton = <AccessibleButton kind="primary" onClick={this.onVerifyClick}>
{ _t("Use another login") } { _t("Verify with another login") }
</AccessibleButton>; </AccessibleButton>;
} }
@ -174,12 +207,18 @@ export default class SetupEncryptionBody extends React.Component<IProps, IState>
<div className="mx_CompleteSecurity_actionRow"> <div className="mx_CompleteSecurity_actionRow">
{ verifyButton } { verifyButton }
{ useRecoveryKeyButton } { useRecoveryKeyButton }
<AccessibleButton kind="danger" onClick={this.onSkipClick}> </div>
{ _t("Skip") } <div className="mx_SetupEncryptionBody_reset">
</AccessibleButton> { _t("Forgotten or lost all recovery methods? <a>Reset all</a>", null, {
a: (sub) => <a
href=""
onClick={this.onResetClick}
className="mx_SetupEncryptionBody_reset_link">{ sub }</a>,
}) }
</div> </div>
</div> </div>
); );
}
} else if (phase === Phase.Done) { } else if (phase === Phase.Done) {
let message; let message;
if (this.state.backupInfo) { if (this.state.backupInfo) {
@ -215,14 +254,13 @@ export default class SetupEncryptionBody extends React.Component<IProps, IState>
) }</p> ) }</p>
<div className="mx_CompleteSecurity_actionRow"> <div className="mx_CompleteSecurity_actionRow">
<AccessibleButton <AccessibleButton
className="warning" kind="danger_outline"
kind="secondary"
onClick={this.onSkipConfirmClick} onClick={this.onSkipConfirmClick}
> >
{ _t("Skip") } { _t("I'll verify later") }
</AccessibleButton> </AccessibleButton>
<AccessibleButton <AccessibleButton
kind="danger" kind="primary"
onClick={this.onSkipBackClick} onClick={this.onSkipBackClick}
> >
{ _t("Go Back") } { _t("Go Back") }
@ -230,6 +268,30 @@ export default class SetupEncryptionBody extends React.Component<IProps, IState>
</div> </div>
</div> </div>
); );
} else if (phase === Phase.ConfirmReset) {
return (
<div>
<p>{ _t(
"Resetting your verification keys cannot be undone. After resetting, " +
"you won't have access to old encrypted messages, and any friends who " +
"have previously verified you will see security warnings until you " +
"re-verify with them.",
) }</p>
<p>{ _t(
"Please only proceed if you're sure you've lost all of your other " +
"devices and your security key.",
) }</p>
<div className="mx_CompleteSecurity_actionRow">
<AccessibleButton kind="danger_outline" onClick={this.onResetConfirmClick}>
{ _t("Proceed with reset") }
</AccessibleButton>
<AccessibleButton kind="primary" onClick={this.onResetBackClick}>
{ _t("Go Back") }
</AccessibleButton>
</div>
</div>
);
} else if (phase === Phase.Busy || phase === Phase.Loading) { } else if (phase === Phase.Busy || phase === Phase.Loading) {
return <Spinner />; return <Spinner />;
} else { } else {

View file

@ -41,7 +41,7 @@ import { logger } from "matrix-js-sdk/src/logger";
* *
* matrixClient: A matrix client. May be a different one to the one * matrixClient: A matrix client. May be a different one to the one
* currently being used generally (eg. to register with * currently being used generally (eg. to register with
* one HS whilst beign a guest on another). * one HS whilst being a guest on another).
* loginType: the login type of the auth stage being attempted * loginType: the login type of the auth stage being attempted
* authSessionId: session id from the server * authSessionId: session id from the server
* clientSecret: The client secret in use for identity server auth sessions * clientSecret: The client secret in use for identity server auth sessions
@ -84,6 +84,7 @@ interface IAuthEntryProps {
loginType: string; loginType: string;
authSessionId: string; authSessionId: string;
errorText?: string; errorText?: string;
errorCode?: string;
// Is the auth logic currently waiting for something to happen? // Is the auth logic currently waiting for something to happen?
busy?: boolean; busy?: boolean;
onPhaseChange: (phase: number) => void; onPhaseChange: (phase: number) => void;
@ -427,18 +428,29 @@ export class EmailIdentityAuthEntry extends React.Component<IEmailIdentityAuthEn
} }
render() { render() {
let errorSection;
// ignore the error when errcode is M_UNAUTHORIZED as we expect that error until the link is clicked.
if (this.props.errorText && this.props.errorCode !== "M_UNAUTHORIZED") {
errorSection = (
<div className="error" role="alert">
{ this.props.errorText }
</div>
);
}
// This component is now only displayed once the token has been requested, // This component is now only displayed once the token has been requested,
// so we know the email has been sent. It can also get loaded after the user // so we know the email has been sent. It can also get loaded after the user
// has clicked the validation link if the server takes a while to propagate // has clicked the validation link if the server takes a while to propagate
// the validation internally. If we're in the session spawned from clicking // the validation internally. If we're in the session spawned from clicking
// the validation link, we won't know the email address, so if we don't have it, // the validation link, we won't know the email address, so if we don't have it,
// assume that the link has been clicked and the server will realise when we poll. // assume that the link has been clicked and the server will realise when we poll.
if (this.props.inputs.emailAddress === undefined) { // We only have a session ID if the user has clicked the link in their email,
return <Spinner />;
} else if (this.props.stageState?.emailSid) {
// we only have a session ID if the user has clicked the link in their email,
// so show a loading state instead of "an email has been sent to..." because // so show a loading state instead of "an email has been sent to..." because
// that's confusing when you've already read that email. // that's confusing when you've already read that email.
if (this.props.inputs.emailAddress === undefined || this.props.stageState?.emailSid) {
if (errorSection) {
return errorSection;
}
return <Spinner />; return <Spinner />;
} else { } else {
return ( return (
@ -448,6 +460,7 @@ export class EmailIdentityAuthEntry extends React.Component<IEmailIdentityAuthEn
) } ) }
</p> </p>
<p>{ _t("Open the link in the email to continue registration.") }</p> <p>{ _t("Open the link in the email to continue registration.") }</p>
{ errorSection }
</div> </div>
); );
} }

View file

@ -33,7 +33,7 @@ interface IProps extends Omit<React.ComponentProps<typeof BaseAvatar>, "name" |
resizeMethod?: ResizeMethod; resizeMethod?: ResizeMethod;
// The onClick to give the avatar // The onClick to give the avatar
onClick?: React.MouseEventHandler; onClick?: React.MouseEventHandler;
// Whether the onClick of the avatar should be overriden to dispatch `Action.ViewUser` // Whether the onClick of the avatar should be overridden to dispatch `Action.ViewUser`
viewUserOnClick?: boolean; viewUserOnClick?: boolean;
title?: string; title?: string;
style?: any; style?: any;

View file

@ -0,0 +1,85 @@
/*
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.
*/
import React, { ComponentProps, useMemo, useState } from 'react';
import ConfirmUserActionDialog from "./ConfirmUserActionDialog";
import SpaceStore from "../../../stores/SpaceStore";
import { Room } from "matrix-js-sdk/src/models/room";
import SpaceChildrenPicker from "../spaces/SpaceChildrenPicker";
type BaseProps = ComponentProps<typeof ConfirmUserActionDialog>;
interface IProps extends Omit<BaseProps, "groupMember" | "matrixClient" | "children" | "onFinished"> {
space: Room;
allLabel: string;
specificLabel: string;
noneLabel?: string;
warningMessage?: string;
onFinished(success: boolean, reason?: string, rooms?: Room[]): void;
spaceChildFilter?(child: Room): boolean;
}
const ConfirmSpaceUserActionDialog: React.FC<IProps> = ({
space,
spaceChildFilter,
allLabel,
specificLabel,
noneLabel,
warningMessage,
onFinished,
...props
}) => {
const spaceChildren = useMemo(() => {
const children = SpaceStore.instance.getChildren(space.roomId);
if (spaceChildFilter) {
return children.filter(spaceChildFilter);
}
return children;
}, [space.roomId, spaceChildFilter]);
const [roomsToLeave, setRoomsToLeave] = useState<Room[]>([]);
const selectedRooms = useMemo(() => new Set(roomsToLeave), [roomsToLeave]);
let warning: JSX.Element;
if (warningMessage) {
warning = <div className="mx_ConfirmSpaceUserActionDialog_warning">
{ warningMessage }
</div>;
}
return (
<ConfirmUserActionDialog
{...props}
onFinished={(success: boolean, reason?: string) => {
onFinished(success, reason, roomsToLeave);
}}
className="mx_ConfirmSpaceUserActionDialog"
>
{ warning }
<SpaceChildrenPicker
space={space}
spaceChildren={spaceChildren}
selected={selectedRooms}
allLabel={allLabel}
specificLabel={specificLabel}
noneLabel={noneLabel}
onChange={setRoomsToLeave}
/>
</ConfirmUserActionDialog>
);
};
export default ConfirmSpaceUserActionDialog;

View file

@ -14,9 +14,11 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React from 'react'; import React, { ChangeEvent, ReactNode } from 'react';
import { MatrixClient } from 'matrix-js-sdk/src/client'; import { MatrixClient } from 'matrix-js-sdk/src/client';
import { RoomMember } from "matrix-js-sdk/src/models/room-member"; import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import classNames from "classnames";
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import { GroupMemberType } from '../../../groups'; import { GroupMemberType } from '../../../groups';
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
@ -25,12 +27,13 @@ import MemberAvatar from '../avatars/MemberAvatar';
import BaseAvatar from '../avatars/BaseAvatar'; import BaseAvatar from '../avatars/BaseAvatar';
import BaseDialog from "./BaseDialog"; import BaseDialog from "./BaseDialog";
import DialogButtons from "../elements/DialogButtons"; import DialogButtons from "../elements/DialogButtons";
import Field from '../elements/Field';
interface IProps { interface IProps {
// matrix-js-sdk (room) member object. Supply either this or 'groupMember' // matrix-js-sdk (room) member object. Supply either this or 'groupMember'
member: RoomMember; member?: RoomMember;
// group member object. Supply either this or 'member' // group member object. Supply either this or 'member'
groupMember: GroupMemberType; groupMember?: GroupMemberType;
// needed if a group member is specified // needed if a group member is specified
matrixClient?: MatrixClient; matrixClient?: MatrixClient;
action: string; // eg. 'Ban' action: string; // eg. 'Ban'
@ -41,9 +44,15 @@ interface IProps {
// be the string entered. // be the string entered.
askReason?: boolean; askReason?: boolean;
danger?: boolean; danger?: boolean;
children?: ReactNode;
className?: string;
onFinished: (success: boolean, reason?: string) => void; onFinished: (success: boolean, reason?: string) => void;
} }
interface IState {
reason: string;
}
/* /*
* A dialog for confirming an operation on another user. * A dialog for confirming an operation on another user.
* Takes a user ID and a verb, displays the target user prominently * Takes a user ID and a verb, displays the target user prominently
@ -53,37 +62,50 @@ interface IProps {
* Also tweaks the style for 'dangerous' actions (albeit only with colour) * Also tweaks the style for 'dangerous' actions (albeit only with colour)
*/ */
@replaceableComponent("views.dialogs.ConfirmUserActionDialog") @replaceableComponent("views.dialogs.ConfirmUserActionDialog")
export default class ConfirmUserActionDialog extends React.Component<IProps> { export default class ConfirmUserActionDialog extends React.Component<IProps, IState> {
private reasonField: React.RefObject<HTMLInputElement> = React.createRef();
static defaultProps = { static defaultProps = {
danger: false, danger: false,
askReason: false, askReason: false,
}; };
public onOk = (): void => { constructor(props: IProps) {
this.props.onFinished(true, this.reasonField.current?.value); super(props);
this.state = {
reason: "",
};
}
private onOk = (): void => {
this.props.onFinished(true, this.state.reason);
}; };
public onCancel = (): void => { private onCancel = (): void => {
this.props.onFinished(false); this.props.onFinished(false);
}; };
private onReasonChange = (ev: ChangeEvent<HTMLInputElement>) => {
this.setState({
reason: ev.target.value,
});
};
public render() { public render() {
const confirmButtonClass = this.props.danger ? 'danger' : ''; const confirmButtonClass = this.props.danger ? 'danger' : '';
let reasonBox; let reasonBox;
if (this.props.askReason) { if (this.props.askReason) {
reasonBox = ( reasonBox = (
<div>
<form onSubmit={this.onOk}> <form onSubmit={this.onOk}>
<input className="mx_ConfirmUserActionDialog_reasonField" <Field
ref={this.reasonField} type="text"
placeholder={_t("Reason")} onChange={this.onReasonChange}
value={this.state.reason}
className="mx_ConfirmUserActionDialog_reasonField"
label={_t("Reason")}
autoFocus={true} autoFocus={true}
/> />
</form> </form>
</div>
); );
} }
@ -105,19 +127,23 @@ export default class ConfirmUserActionDialog extends React.Component<IProps> {
return ( return (
<BaseDialog <BaseDialog
className="mx_ConfirmUserActionDialog" className={classNames("mx_ConfirmUserActionDialog", this.props.className)}
onFinished={this.props.onFinished} onFinished={this.props.onFinished}
title={this.props.title} title={this.props.title}
contentId='mx_Dialog_content' contentId='mx_Dialog_content'
> >
<div id="mx_Dialog_content" className="mx_Dialog_content"> <div id="mx_Dialog_content" className="mx_Dialog_content">
<div className="mx_ConfirmUserActionDialog_user">
<div className="mx_ConfirmUserActionDialog_avatar"> <div className="mx_ConfirmUserActionDialog_avatar">
{ avatar } { avatar }
</div> </div>
<div className="mx_ConfirmUserActionDialog_name">{ name }</div> <div className="mx_ConfirmUserActionDialog_name">{ name }</div>
<div className="mx_ConfirmUserActionDialog_userId">{ userId }</div> <div className="mx_ConfirmUserActionDialog_userId">{ userId }</div>
</div> </div>
{ reasonBox } { reasonBox }
{ this.props.children }
</div>
<DialogButtons primaryButton={this.props.action} <DialogButtons primaryButton={this.props.action}
onPrimaryButtonClick={this.onOk} onPrimaryButtonClick={this.onOk}
primaryButtonClass={confirmButtonClass} primaryButtonClass={confirmButtonClass}

View file

@ -39,6 +39,8 @@ import dis from "../../../dispatcher/dispatcher";
import { Action } from "../../../dispatcher/actions"; import { Action } from "../../../dispatcher/actions";
import { UserTab } from "./UserSettingsDialog"; import { UserTab } from "./UserSettingsDialog";
import TagOrderActions from "../../../actions/TagOrderActions"; import TagOrderActions from "../../../actions/TagOrderActions";
import { inviteUsersToRoom } from "../../../RoomInvite";
import ProgressBar from "../elements/ProgressBar";
interface IProps { interface IProps {
matrixClient: MatrixClient; matrixClient: MatrixClient;
@ -90,10 +92,22 @@ export interface IGroupSummary {
} }
/* eslint-enable camelcase */ /* eslint-enable camelcase */
enum Progress {
NotStarted,
ValidatingInputs,
FetchingData,
CreatingSpace,
InvitingUsers,
// anything beyond here is inviting user n - 4
}
const CreateSpaceFromCommunityDialog: React.FC<IProps> = ({ matrixClient: cli, groupId, onFinished }) => { const CreateSpaceFromCommunityDialog: React.FC<IProps> = ({ matrixClient: cli, groupId, onFinished }) => {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string>(null); const [error, setError] = useState<string>(null);
const [busy, setBusy] = useState(false);
const [progress, setProgress] = useState(Progress.NotStarted);
const [numInvites, setNumInvites] = useState(0);
const busy = progress > 0;
const [avatar, setAvatar] = useState<File>(null); // undefined means to remove avatar const [avatar, setAvatar] = useState<File>(null); // undefined means to remove avatar
const [name, setName] = useState(""); const [name, setName] = useState("");
@ -122,30 +136,34 @@ const CreateSpaceFromCommunityDialog: React.FC<IProps> = ({ matrixClient: cli, g
if (busy) return; if (busy) return;
setError(null); setError(null);
setBusy(true); setProgress(Progress.ValidatingInputs);
// require & validate the space name field // require & validate the space name field
if (!(await spaceNameField.current.validate({ allowEmpty: false }))) { if (!(await spaceNameField.current.validate({ allowEmpty: false }))) {
setBusy(false); setProgress(0);
spaceNameField.current.focus(); spaceNameField.current.focus();
spaceNameField.current.validate({ allowEmpty: false, focused: true }); spaceNameField.current.validate({ allowEmpty: false, focused: true });
return; return;
} }
// validate the space name alias field but do not require it // validate the space name alias field but do not require it
if (joinRule === JoinRule.Public && !(await spaceAliasField.current.validate({ allowEmpty: true }))) { if (joinRule === JoinRule.Public && !(await spaceAliasField.current.validate({ allowEmpty: true }))) {
setBusy(false); setProgress(0);
spaceAliasField.current.focus(); spaceAliasField.current.focus();
spaceAliasField.current.validate({ allowEmpty: true, focused: true }); spaceAliasField.current.validate({ allowEmpty: true, focused: true });
return; return;
} }
try { try {
setProgress(Progress.FetchingData);
const [rooms, members, invitedMembers] = await Promise.all([ const [rooms, members, invitedMembers] = await Promise.all([
cli.getGroupRooms(groupId).then(parseRoomsResponse) as Promise<IGroupRoom[]>, cli.getGroupRooms(groupId).then(parseRoomsResponse) as Promise<IGroupRoom[]>,
cli.getGroupUsers(groupId).then(parseMembersResponse) as Promise<GroupMember[]>, cli.getGroupUsers(groupId).then(parseMembersResponse) as Promise<GroupMember[]>,
cli.getGroupInvitedUsers(groupId).then(parseMembersResponse) as Promise<GroupMember[]>, cli.getGroupInvitedUsers(groupId).then(parseMembersResponse) as Promise<GroupMember[]>,
]); ]);
setNumInvites(members.length + invitedMembers.length);
const viaMap = new Map<string, string[]>(); const viaMap = new Map<string, string[]>();
for (const { roomId, canonicalAlias } of rooms) { for (const { roomId, canonicalAlias } of rooms) {
const room = cli.getRoom(roomId); const room = cli.getRoom(roomId);
@ -167,6 +185,8 @@ const CreateSpaceFromCommunityDialog: React.FC<IProps> = ({ matrixClient: cli, g
} }
} }
setProgress(Progress.CreatingSpace);
const spaceAvatar = avatar !== undefined ? avatar : groupSummary.profile.avatar_url; const spaceAvatar = avatar !== undefined ? avatar : groupSummary.profile.avatar_url;
const roomId = await createSpace(name, joinRule === JoinRule.Public, alias, topic, spaceAvatar, { const roomId = await createSpace(name, joinRule === JoinRule.Public, alias, topic, spaceAvatar, {
creation_content: { creation_content: {
@ -179,11 +199,16 @@ const CreateSpaceFromCommunityDialog: React.FC<IProps> = ({ matrixClient: cli, g
via: viaMap.get(roomId) || [], via: viaMap.get(roomId) || [],
}, },
})), })),
invite: [...members, ...invitedMembers].map(m => m.userId).filter(m => m !== cli.getUserId()), // we do not specify the inviters here because Synapse applies a limit and this may cause it to trip
}, { }, {
andView: false, andView: false,
}); });
setProgress(Progress.InvitingUsers);
const userIds = [...members, ...invitedMembers].map(m => m.userId).filter(m => m !== cli.getUserId());
await inviteUsersToRoom(roomId, userIds, () => setProgress(p => p + 1));
// eagerly remove it from the community panel // eagerly remove it from the community panel
dis.dispatch(TagOrderActions.removeTag(cli, groupId)); dis.dispatch(TagOrderActions.removeTag(cli, groupId));
@ -250,7 +275,7 @@ const CreateSpaceFromCommunityDialog: React.FC<IProps> = ({ matrixClient: cli, g
setError(e); setError(e);
} }
setBusy(false); setProgress(Progress.NotStarted);
}; };
let footer; let footer;
@ -267,13 +292,41 @@ const CreateSpaceFromCommunityDialog: React.FC<IProps> = ({ matrixClient: cli, g
{ _t("Retry") } { _t("Retry") }
</AccessibleButton> </AccessibleButton>
</>; </>;
} else if (busy) {
let description: string;
switch (progress) {
case Progress.ValidatingInputs:
case Progress.FetchingData:
description = _t("Fetching data...");
break;
case Progress.CreatingSpace:
description = _t("Creating Space...");
break;
case Progress.InvitingUsers:
default:
description = _t("Adding rooms... (%(progress)s out of %(count)s)", {
count: numInvites,
progress,
});
break;
}
footer = <span>
<ProgressBar
value={progress > Progress.FetchingData ? progress : 0}
max={numInvites + Progress.InvitingUsers}
/>
<div className="mx_CreateSpaceFromCommunityDialog_progressText">
{ description }
</div>
</span>;
} else { } else {
footer = <> footer = <>
<AccessibleButton kind="primary_outline" disabled={busy} onClick={() => onFinished()}> <AccessibleButton kind="primary_outline" onClick={() => onFinished()}>
{ _t("Cancel") } { _t("Cancel") }
</AccessibleButton> </AccessibleButton>
<AccessibleButton kind="primary" disabled={busy} onClick={onCreateSpaceClick}> <AccessibleButton kind="primary" onClick={onCreateSpaceClick}>
{ busy ? _t("Creating...") : _t("Create Space") } { _t("Create Space") }
</AccessibleButton> </AccessibleButton>
</>; </>;
} }

View file

@ -0,0 +1,397 @@
/*
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.
*/
import React, { useRef, useState } from "react";
import { Room } from "matrix-js-sdk/src";
import { _t } from "../../../languageHandler";
import { IDialogProps } from "./IDialogProps";
import BaseDialog from "./BaseDialog";
import DialogButtons from "../elements/DialogButtons";
import Field from "../elements/Field";
import StyledRadioGroup from "../elements/StyledRadioGroup";
import StyledCheckbox from "../elements/StyledCheckbox";
import {
ExportFormat,
ExportType,
textForFormat,
textForType,
} from "../../../utils/exportUtils/exportUtils";
import withValidation, { IFieldState, IValidationResult } from "../elements/Validation";
import HTMLExporter from "../../../utils/exportUtils/HtmlExport";
import JSONExporter from "../../../utils/exportUtils/JSONExport";
import PlainTextExporter from "../../../utils/exportUtils/PlainTextExport";
import { useStateCallback } from "../../../hooks/useStateCallback";
import Exporter from "../../../utils/exportUtils/Exporter";
import Spinner from "../elements/Spinner";
import InfoDialog from "./InfoDialog";
interface IProps extends IDialogProps {
room: Room;
}
const ExportDialog: React.FC<IProps> = ({ room, onFinished }) => {
const [exportFormat, setExportFormat] = useState(ExportFormat.Html);
const [exportType, setExportType] = useState(ExportType.Timeline);
const [includeAttachments, setAttachments] = useState(false);
const [isExporting, setExporting] = useState(false);
const [numberOfMessages, setNumberOfMessages] = useState<number>(100);
const [sizeLimit, setSizeLimit] = useState<number | null>(8);
const sizeLimitRef = useRef<Field>();
const messageCountRef = useRef<Field>();
const [exportProgressText, setExportProgressText] = useState("Processing...");
const [displayCancel, setCancelWarning] = useState(false);
const [exportCancelled, setExportCancelled] = useState(false);
const [exportSuccessful, setExportSuccessful] = useState(false);
const [exporter, setExporter] = useStateCallback<Exporter>(
null,
async (exporter: Exporter) => {
await exporter?.export().then(() => {
if (!exportCancelled) setExportSuccessful(true);
});
},
);
const startExport = async () => {
const exportOptions = {
numberOfMessages,
attachmentsIncluded: includeAttachments,
maxSize: sizeLimit * 1024 * 1024,
};
switch (exportFormat) {
case ExportFormat.Html:
setExporter(
new HTMLExporter(
room,
ExportType[exportType],
exportOptions,
setExportProgressText,
),
);
break;
case ExportFormat.Json:
setExporter(
new JSONExporter(
room,
ExportType[exportType],
exportOptions,
setExportProgressText,
),
);
break;
case ExportFormat.PlainText:
setExporter(
new PlainTextExporter(
room,
ExportType[exportType],
exportOptions,
setExportProgressText,
),
);
break;
default:
console.error("Unknown export format");
return;
}
};
const onExportClick = async () => {
const isValidSize = await sizeLimitRef.current.validate({
focused: false,
});
if (!isValidSize) {
sizeLimitRef.current.validate({ focused: true });
return;
}
if (exportType === ExportType.LastNMessages) {
const isValidNumberOfMessages =
await messageCountRef.current.validate({ focused: false });
if (!isValidNumberOfMessages) {
messageCountRef.current.validate({ focused: true });
return;
}
}
setExporting(true);
await startExport();
};
const validateSize = withValidation({
rules: [
{
key: "required",
test({ value, allowEmpty }) {
return allowEmpty || !!value;
},
invalid: () => {
const min = 1;
const max = 10 ** 8;
return _t("Enter a number between %(min)s and %(max)s", {
min,
max,
});
},
}, {
key: "number",
test: ({ value }) => {
const parsedSize = parseFloat(value);
const min = 1;
const max = 2000;
return !(isNaN(parsedSize) || min > parsedSize || parsedSize > max);
},
invalid: () => {
const min = 1;
const max = 2000;
return _t(
"Size can only be a number between %(min)s MB and %(max)s MB",
{ min, max },
);
},
},
],
});
const onValidateSize = async (fieldState: IFieldState): Promise<IValidationResult> => {
const result = await validateSize(fieldState);
return result;
};
const validateNumberOfMessages = withValidation({
rules: [
{
key: "required",
test({ value, allowEmpty }) {
return allowEmpty || !!value;
},
invalid: () => {
const min = 1;
const max = 10 ** 8;
return _t("Enter a number between %(min)s and %(max)s", {
min,
max,
});
},
}, {
key: "number",
test: ({ value }) => {
const parsedSize = parseFloat(value);
const min = 1;
const max = 10 ** 8;
if (isNaN(parsedSize)) return false;
return !(min > parsedSize || parsedSize > max);
},
invalid: () => {
const min = 1;
const max = 10 ** 8;
return _t(
"Number of messages can only be a number between %(min)s and %(max)s",
{ min, max },
);
},
},
],
});
const onValidateNumberOfMessages = async (fieldState: IFieldState): Promise<IValidationResult> => {
const result = await validateNumberOfMessages(fieldState);
return result;
};
const onCancel = async () => {
if (isExporting) setCancelWarning(true);
else onFinished(false);
};
const confirmCanel = async () => {
await exporter?.cancelExport();
setExportCancelled(true);
setExporting(false);
setExporter(null);
};
const exportFormatOptions = Object.keys(ExportFormat).map((format) => ({
value: ExportFormat[format],
label: textForFormat(ExportFormat[format]),
}));
const exportTypeOptions = Object.keys(ExportType).map((type) => {
return (
<option key={type} value={ExportType[type]}>
{ textForType(ExportType[type]) }
</option>
);
});
let messageCount = null;
if (exportType === ExportType.LastNMessages) {
messageCount = (
<Field
element="input"
type="number"
value={numberOfMessages.toString()}
ref={messageCountRef}
onValidate={onValidateNumberOfMessages}
label={_t("Number of messages")}
onChange={(e) => {
setNumberOfMessages(parseInt(e.target.value));
}}
/>
);
}
const sizePostFix = <span>{ _t("MB") }</span>;
if (exportCancelled) {
// Display successful cancellation message
return (
<InfoDialog
title={_t("Export Successful")}
description={_t("The export was cancelled successfully")}
hasCloseButton={true}
onFinished={onFinished}
/>
);
} else if (exportSuccessful) {
// Display successful export message
return (
<InfoDialog
title={_t("Export Successful")}
description={_t(
"Your export was successful. Find it in your Downloads folder.",
)}
hasCloseButton={true}
onFinished={onFinished}
/>
);
} else if (displayCancel) {
// Display cancel warning
return (
<BaseDialog
title={_t("Warning")}
className="mx_ExportDialog"
contentId="mx_Dialog_content"
onFinished={onFinished}
fixedWidth={true}
>
<p>
{ _t(
"Are you sure you want to stop exporting your data? If you do, you'll need to start over.",
) }
</p>
<DialogButtons
primaryButton={_t("Stop")}
primaryButtonClass="danger"
hasCancel={true}
cancelButton={_t("Continue")}
onCancel={() => setCancelWarning(false)}
onPrimaryButtonClick={confirmCanel}
/>
</BaseDialog>
);
} else {
// Display export settings
return (
<BaseDialog
title={isExporting ? _t("Exporting your data") : _t("Export Chat")}
className={`mx_ExportDialog ${isExporting && "mx_ExportDialog_Exporting"}`}
contentId="mx_Dialog_content"
hasCancel={true}
onFinished={onFinished}
fixedWidth={true}
>
{ !isExporting ? <p>
{ _t(
"Select from the options below to export chats from your timeline",
) }
</p> : null }
<span className="mx_ExportDialog_subheading">
{ _t("Format") }
</span>
<div className="mx_ExportDialog_options">
<StyledRadioGroup
name="exportFormat"
value={exportFormat}
onChange={(key) => setExportFormat(ExportFormat[key])}
definitions={exportFormatOptions}
/>
<span className="mx_ExportDialog_subheading">
{ _t("Messages") }
</span>
<Field
element="select"
value={exportType}
onChange={(e) => {
setExportType(ExportType[e.target.value]);
}}
>
{ exportTypeOptions }
</Field>
{ messageCount }
<span className="mx_ExportDialog_subheading">
{ _t("Size Limit") }
</span>
<Field
type="number"
autoComplete="off"
onValidate={onValidateSize}
element="input"
ref={sizeLimitRef}
value={sizeLimit.toString()}
postfixComponent={sizePostFix}
onChange={(e) => setSizeLimit(parseInt(e.target.value))}
/>
<StyledCheckbox
checked={includeAttachments}
onChange={(e) =>
setAttachments(
(e.target as HTMLInputElement).checked,
)
}
>
{ _t("Include Attachments") }
</StyledCheckbox>
</div>
{ isExporting ? (
<div className="mx_ExportDialog_progress">
<Spinner w={24} h={24} />
<p>
{ exportProgressText }
</p>
<DialogButtons
primaryButton={_t("Cancel")}
primaryButtonClass="danger"
hasCancel={false}
onPrimaryButtonClick={onCancel}
/>
</div>
) : (
<DialogButtons
primaryButton={_t("Export")}
onPrimaryButtonClick={onExportClick}
onCancel={() => onFinished(false)}
/>
) }
</BaseDialog>
);
}
};
export default ExportDialog;

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, { useEffect, useMemo, useState } from "react"; import React, { useMemo, useState } from "react";
import { Room } from "matrix-js-sdk/src/models/room"; import { Room } from "matrix-js-sdk/src/models/room";
import { JoinRule } from "matrix-js-sdk/src/@types/partials"; import { JoinRule } from "matrix-js-sdk/src/@types/partials";
@ -22,108 +22,7 @@ import { _t } from '../../../languageHandler';
import DialogButtons from "../elements/DialogButtons"; import DialogButtons from "../elements/DialogButtons";
import BaseDialog from "../dialogs/BaseDialog"; import BaseDialog from "../dialogs/BaseDialog";
import SpaceStore from "../../../stores/SpaceStore"; import SpaceStore from "../../../stores/SpaceStore";
import AutoHideScrollbar from "../../structures/AutoHideScrollbar"; import SpaceChildrenPicker from "../spaces/SpaceChildrenPicker";
import { Entry } from "./AddExistingToSpaceDialog";
import SearchBox from "../../structures/SearchBox";
import QueryMatcher from "../../../autocomplete/QueryMatcher";
import StyledRadioGroup from "../elements/StyledRadioGroup";
enum RoomsToLeave {
All = "All",
Specific = "Specific",
None = "None",
}
const SpaceChildPicker = ({ filterPlaceholder, rooms, selected, onChange }) => {
const [query, setQuery] = useState("");
const lcQuery = query.toLowerCase().trim();
const filteredRooms = useMemo(() => {
if (!lcQuery) {
return rooms;
}
const matcher = new QueryMatcher<Room>(rooms, {
keys: ["name"],
funcs: [r => [r.getCanonicalAlias(), ...r.getAltAliases()].filter(Boolean)],
shouldMatchWordsOnly: false,
});
return matcher.match(lcQuery);
}, [rooms, lcQuery]);
return <div className="mx_LeaveSpaceDialog_section">
<SearchBox
className="mx_textinput_icon mx_textinput_search"
placeholder={filterPlaceholder}
onSearch={setQuery}
autoFocus={true}
/>
<AutoHideScrollbar className="mx_LeaveSpaceDialog_content">
{ filteredRooms.map(room => {
return <Entry
key={room.roomId}
room={room}
checked={selected.has(room)}
onChange={(checked) => {
onChange(checked, room);
}}
/>;
}) }
{ filteredRooms.length < 1 ? <span className="mx_LeaveSpaceDialog_noResults">
{ _t("No results") }
</span> : undefined }
</AutoHideScrollbar>
</div>;
};
const LeaveRoomsPicker = ({ space, spaceChildren, roomsToLeave, setRoomsToLeave }) => {
const selected = useMemo(() => new Set(roomsToLeave), [roomsToLeave]);
const [state, setState] = useState<string>(RoomsToLeave.None);
useEffect(() => {
if (state === RoomsToLeave.All) {
setRoomsToLeave(spaceChildren);
} else {
setRoomsToLeave([]);
}
}, [setRoomsToLeave, state, spaceChildren]);
return <div className="mx_LeaveSpaceDialog_section">
<StyledRadioGroup
name="roomsToLeave"
value={state}
onChange={setState}
definitions={[
{
value: RoomsToLeave.None,
label: _t("Don't leave any rooms"),
}, {
value: RoomsToLeave.All,
label: _t("Leave all rooms"),
}, {
value: RoomsToLeave.Specific,
label: _t("Leave some rooms"),
},
]}
/>
{ state === RoomsToLeave.Specific && (
<SpaceChildPicker
filterPlaceholder={_t("Search %(spaceName)s", { spaceName: space.name })}
rooms={spaceChildren}
selected={selected}
onChange={(selected: boolean, room: Room) => {
if (selected) {
setRoomsToLeave([room, ...roomsToLeave]);
} else {
setRoomsToLeave(roomsToLeave.filter(r => r !== room));
}
}}
/>
) }
</div>;
};
interface IProps { interface IProps {
space: Room; space: Room;
@ -144,6 +43,7 @@ const isOnlyAdmin = (room: Room): boolean => {
const LeaveSpaceDialog: React.FC<IProps> = ({ space, onFinished }) => { const LeaveSpaceDialog: React.FC<IProps> = ({ space, onFinished }) => {
const spaceChildren = useMemo(() => SpaceStore.instance.getChildren(space.roomId), [space.roomId]); const spaceChildren = useMemo(() => SpaceStore.instance.getChildren(space.roomId), [space.roomId]);
const [roomsToLeave, setRoomsToLeave] = useState<Room[]>([]); const [roomsToLeave, setRoomsToLeave] = useState<Room[]>([]);
const selectedRooms = useMemo(() => new Set(roomsToLeave), [roomsToLeave]);
let rejoinWarning; let rejoinWarning;
if (space.getJoinRule() !== JoinRule.Public) { if (space.getJoinRule() !== JoinRule.Public) {
@ -180,12 +80,17 @@ const LeaveSpaceDialog: React.FC<IProps> = ({ space, onFinished }) => {
{ spaceChildren.length > 0 && _t("Would you like to leave the rooms in this space?") } { spaceChildren.length > 0 && _t("Would you like to leave the rooms in this space?") }
</p> </p>
{ spaceChildren.length > 0 && <LeaveRoomsPicker { spaceChildren.length > 0 && (
<SpaceChildrenPicker
space={space} space={space}
spaceChildren={spaceChildren} spaceChildren={spaceChildren}
roomsToLeave={roomsToLeave} selected={selectedRooms}
setRoomsToLeave={setRoomsToLeave} onChange={setRoomsToLeave}
/> } noneLabel={_t("Don't leave any rooms")}
allLabel={_t("Leave all rooms")}
specificLabel={_t("Leave some rooms")}
/>
) }
{ onlyAdminWarning && <div className="mx_LeaveSpaceDialog_section_warning"> { onlyAdminWarning && <div className="mx_LeaveSpaceDialog_section_warning">
{ onlyAdminWarning } { onlyAdminWarning }

View file

@ -44,18 +44,31 @@ interface IProps {
initialTabId?: string; initialTabId?: string;
} }
interface IState {
roomName: string;
}
@replaceableComponent("views.dialogs.RoomSettingsDialog") @replaceableComponent("views.dialogs.RoomSettingsDialog")
export default class RoomSettingsDialog extends React.Component<IProps> { export default class RoomSettingsDialog extends React.Component<IProps, IState> {
private dispatcherRef: string; private dispatcherRef: string;
constructor(props: IProps) {
super(props);
this.state = { roomName: '' };
}
public componentDidMount() { public componentDidMount() {
this.dispatcherRef = dis.register(this.onAction); this.dispatcherRef = dis.register(this.onAction);
MatrixClientPeg.get().on("Room.name", this.onRoomName);
this.onRoomName();
} }
public componentWillUnmount() { public componentWillUnmount() {
if (this.dispatcherRef) { if (this.dispatcherRef) {
dis.unregister(this.dispatcherRef); dis.unregister(this.dispatcherRef);
} }
MatrixClientPeg.get().removeListener("Room.name", this.onRoomName);
} }
private onAction = (payload): void => { private onAction = (payload): void => {
@ -66,6 +79,12 @@ export default class RoomSettingsDialog extends React.Component<IProps> {
} }
}; };
private onRoomName = (): void => {
this.setState({
roomName: MatrixClientPeg.get().getRoom(this.props.roomId).name,
});
};
private getTabs(): Tab[] { private getTabs(): Tab[] {
const tabs: Tab[] = []; const tabs: Tab[] = [];
@ -122,7 +141,7 @@ export default class RoomSettingsDialog extends React.Component<IProps> {
} }
render() { render() {
const roomName = MatrixClientPeg.get().getRoom(this.props.roomId).name; const roomName = this.state.roomName;
return ( return (
<BaseDialog <BaseDialog
className='mx_RoomSettingsDialog' className='mx_RoomSettingsDialog'

View file

@ -28,15 +28,25 @@ import { IDialogProps } from "./IDialogProps";
import BugReportDialog from './BugReportDialog'; import BugReportDialog from './BugReportDialog';
import BaseDialog from "./BaseDialog"; import BaseDialog from "./BaseDialog";
import DialogButtons from "../elements/DialogButtons"; import DialogButtons from "../elements/DialogButtons";
import ProgressBar from "../elements/ProgressBar";
export interface IFinishedOpts {
continue: boolean;
invite: boolean;
}
interface IProps extends IDialogProps { interface IProps extends IDialogProps {
roomId: string; roomId: string;
targetVersion: string; targetVersion: string;
description?: ReactNode; description?: ReactNode;
doUpgrade?(opts: IFinishedOpts, fn: (progressText: string, progress: number, total: number) => void): Promise<void>;
} }
interface IState { interface IState {
inviteUsersToNewRoom: boolean; inviteUsersToNewRoom: boolean;
progressText?: string;
progress?: number;
total?: number;
} }
@replaceableComponent("views.dialogs.RoomUpgradeWarningDialog") @replaceableComponent("views.dialogs.RoomUpgradeWarningDialog")
@ -50,15 +60,30 @@ export default class RoomUpgradeWarningDialog extends React.Component<IProps, IS
const room = MatrixClientPeg.get().getRoom(this.props.roomId); const room = MatrixClientPeg.get().getRoom(this.props.roomId);
const joinRules = room?.currentState.getStateEvents(EventType.RoomJoinRules, ""); const joinRules = room?.currentState.getStateEvents(EventType.RoomJoinRules, "");
this.isPrivate = joinRules?.getContent()['join_rule'] !== JoinRule.Public ?? true; this.isPrivate = joinRules?.getContent()['join_rule'] !== JoinRule.Public ?? true;
this.currentVersion = room?.getVersion() || "1"; this.currentVersion = room?.getVersion();
this.state = { this.state = {
inviteUsersToNewRoom: true, inviteUsersToNewRoom: true,
}; };
} }
private onProgressCallback = (progressText: string, progress: number, total: number): void => {
this.setState({ progressText, progress, total });
};
private onContinue = () => { private onContinue = () => {
this.props.onFinished({ continue: true, invite: this.isPrivate && this.state.inviteUsersToNewRoom }); const opts = {
continue: true,
invite: this.isPrivate && this.state.inviteUsersToNewRoom,
};
if (this.props.doUpgrade) {
this.props.doUpgrade(opts, this.onProgressCallback).then(() => {
this.props.onFinished(opts);
});
} else {
this.props.onFinished(opts);
}
}; };
private onCancel = () => { private onCancel = () => {
@ -118,6 +143,23 @@ export default class RoomUpgradeWarningDialog extends React.Component<IProps, IS
); );
} }
let footer: JSX.Element;
if (this.state.progressText) {
footer = <span className="mx_RoomUpgradeWarningDialog_progress">
<ProgressBar value={this.state.progress} max={this.state.total} />
<div className="mx_RoomUpgradeWarningDialog_progressText">
{ this.state.progressText }
</div>
</span>;
} else {
footer = <DialogButtons
primaryButton={_t("Upgrade")}
onPrimaryButtonClick={this.onContinue}
cancelButton={_t("Cancel")}
onCancel={this.onCancel}
/>;
}
return ( return (
<BaseDialog <BaseDialog
className='mx_RoomUpgradeWarningDialog' className='mx_RoomUpgradeWarningDialog'
@ -154,12 +196,7 @@ export default class RoomUpgradeWarningDialog extends React.Component<IProps, IS
</p> </p>
{ inviteToggle } { inviteToggle }
</div> </div>
<DialogButtons { footer }
primaryButton={_t("Upgrade")}
onPrimaryButtonClick={this.onContinue}
cancelButton={_t("Cancel")}
onCancel={this.onCancel}
/>
</BaseDialog> </BaseDialog>
); );
} }

View file

@ -268,7 +268,7 @@ const NetworkDropdown = ({ onOptionChange, protocols = {}, selectedServerName, s
}; };
const buttonRect = handle.current.getBoundingClientRect(); const buttonRect = handle.current.getBoundingClientRect();
content = <ContextMenu {...inPlaceOf(buttonRect)} onFinished={closeMenu}> content = <ContextMenu {...inPlaceOf(buttonRect)} onFinished={closeMenu} focusLock>
<div className="mx_NetworkDropdown_menu"> <div className="mx_NetworkDropdown_menu">
{ options } { options }
<MenuItem className="mx_NetworkDropdown_server_add" label={undefined} onClick={onClick}> <MenuItem className="mx_NetworkDropdown_server_add" label={undefined} onClick={onClick}>

View file

@ -178,6 +178,14 @@ export default class Dropdown extends React.Component<IProps, IState> {
this.ignoreEvent = ev; this.ignoreEvent = ev;
}; };
private onChevronClick = (ev: React.MouseEvent) => {
if (this.state.expanded) {
this.setState({ expanded: false });
ev.stopPropagation();
ev.preventDefault();
}
};
private onAccessibleButtonClick = (ev: ButtonEvent) => { private onAccessibleButtonClick = (ev: ButtonEvent) => {
if (this.props.disabled) return; if (this.props.disabled) return;
@ -375,7 +383,7 @@ export default class Dropdown extends React.Component<IProps, IState> {
onKeyDown={this.onKeyDown} onKeyDown={this.onKeyDown}
> >
{ currentValue } { currentValue }
<span className="mx_Dropdown_arrow" /> <span onClick={this.onChevronClick} className="mx_Dropdown_arrow" />
{ menu } { menu }
</AccessibleButton> </AccessibleButton>
</div>; </div>;

View file

@ -143,6 +143,10 @@ export default class Field extends React.PureComponent<PropShapes, IState> {
public focus() { public focus() {
this.input.focus(); this.input.focus();
// programmatic does not fire onFocus handler
this.setState({
focused: true,
});
} }
private onFocus = (ev) => { private onFocus = (ev) => {

View file

@ -53,6 +53,7 @@ interface IProps {
layout?: Layout; layout?: Layout;
// Whether to always show a timestamp // Whether to always show a timestamp
alwaysShowTimestamps?: boolean; alwaysShowTimestamps?: boolean;
forExport?: boolean;
isQuoteExpanded?: boolean; isQuoteExpanded?: boolean;
setQuoteExpanded: (isExpanded: boolean) => void; setQuoteExpanded: (isExpanded: boolean) => void;
} }
@ -381,6 +382,17 @@ export default class ReplyThread extends React.Component<IProps, IState> {
}) })
} }
</blockquote>; </blockquote>;
} else if (this.props.forExport) {
const eventId = ReplyThread.getParentEventId(this.props.parentEv);
header = <p className="mx_ReplyThread_Export">
{ _t("In reply to <a>this message</a>",
{},
{ a: (sub) => (
<a className="mx_reply_anchor" href={`#${eventId}`} scroll-to={eventId}> { sub } </a>
),
})
}
</p>;
} else if (this.state.loading) { } else if (this.state.loading) {
header = <Spinner w={16} h={16} />; header = <Spinner w={16} h={16} />;
} }

View file

@ -35,12 +35,17 @@ function getDaysArray(): string[] {
interface IProps { interface IProps {
ts: number; ts: number;
forExport?: boolean;
} }
@replaceableComponent("views.messages.DateSeparator") @replaceableComponent("views.messages.DateSeparator")
export default class DateSeparator extends React.Component<IProps> { export default class DateSeparator extends React.Component<IProps> {
private getLabel() { private getLabel() {
const date = new Date(this.props.ts); const date = new Date(this.props.ts);
// During the time the archive is being viewed, a specific day might not make sense, so we return the full date
if (this.props.forExport) return formatFullDateNoTime(date);
const today = new Date(); const today = new Date();
const yesterday = new Date(); const yesterday = new Date();
const days = getDaysArray(); const days = getDaysArray();

View file

@ -33,6 +33,7 @@ export interface IBodyProps {
onHeightChanged: () => void; onHeightChanged: () => void;
showUrlPreview?: boolean; showUrlPreview?: boolean;
forExport?: boolean;
tileShape: TileShape; tileShape: TileShape;
maxImageHeight?: number; maxImageHeight?: number;
replacingEventId?: string; replacingEventId?: string;

View file

@ -90,6 +90,17 @@ export default class MAudioBody extends React.PureComponent<IBodyProps, IState>
); );
} }
if (this.props.forExport) {
const content = this.props.mxEvent.getContent();
// During export, the content url will point to the MSC, which will later point to a local url
const contentUrl = content.file?.url || content.url;
return (
<span className="mx_MAudioBody">
<audio src={contentUrl} controls />
</span>
);
}
if (!this.state.playback) { if (!this.state.playback) {
return ( return (
<span className="mx_MAudioBody"> <span className="mx_MAudioBody">

View file

@ -123,6 +123,11 @@ export default class MFileBody extends React.Component<IProps, IState> {
this.state = {}; this.state = {};
} }
private getContentUrl(): string | null {
if (this.props.forExport) return null;
const media = mediaFromContent(this.props.mxEvent.getContent());
return media.srcHttp;
}
private get content(): IMediaEventContent { private get content(): IMediaEventContent {
return this.props.mxEvent.getContent<IMediaEventContent>(); return this.props.mxEvent.getContent<IMediaEventContent>();
} }
@ -149,11 +154,6 @@ export default class MFileBody extends React.Component<IProps, IState> {
}); });
} }
private getContentUrl(): string {
const media = mediaFromContent(this.props.mxEvent.getContent());
return media.srcHttp;
}
public componentDidUpdate(prevProps, prevState) { public componentDidUpdate(prevProps, prevState) {
if (this.props.onHeightChanged && !prevState.decryptedBlob && this.state.decryptedBlob) { if (this.props.onHeightChanged && !prevState.decryptedBlob && this.state.decryptedBlob) {
this.props.onHeightChanged(); this.props.onHeightChanged();
@ -213,6 +213,16 @@ export default class MFileBody extends React.Component<IProps, IState> {
); );
} }
if (this.props.forExport) {
const content = this.props.mxEvent.getContent();
// During export, the content url will point to the MSC, which will later point to a local url
return <span className="mx_MFileBody">
<a href={content.file?.url || content.url}>
{ placeholder }
</a>
</span>;
}
const showDownloadLink = this.props.tileShape || !this.props.showGenericPlaceholder; const showDownloadLink = this.props.tileShape || !this.props.showGenericPlaceholder;
if (isEncrypted) { if (isEncrypted) {

View file

@ -179,6 +179,9 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
}; };
protected getContentUrl(): string { protected getContentUrl(): string {
const content: IMediaEventContent = this.props.mxEvent.getContent();
// During export, the content url will point to the MSC, which will later point to a local url
if (this.props.forExport) return content.url || content.file?.url;
if (this.media.isEncrypted) { if (this.media.isEncrypted) {
return this.state.decryptedUrl; return this.state.decryptedUrl;
} else { } else {
@ -372,7 +375,7 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
let placeholder = null; let placeholder = null;
let gifLabel = null; let gifLabel = null;
if (!this.state.imgLoaded) { if (!this.props.forExport && !this.state.imgLoaded) {
placeholder = this.getPlaceholder(maxWidth, maxHeight); placeholder = this.getPlaceholder(maxWidth, maxHeight);
} }
@ -462,7 +465,7 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
// Overidden by MStickerBody // Overidden by MStickerBody
protected wrapImage(contentUrl: string, children: JSX.Element): JSX.Element { protected wrapImage(contentUrl: string, children: JSX.Element): JSX.Element {
return <a href={contentUrl} onClick={this.onClick}> return <a href={contentUrl} target={this.props.forExport ? "_blank" : undefined} onClick={this.onClick}>
{ children } { children }
</a>; </a>;
} }
@ -490,6 +493,7 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
// Overidden by MStickerBody // Overidden by MStickerBody
protected getFileBody(): string | JSX.Element { protected getFileBody(): string | JSX.Element {
if (this.props.forExport) return null;
// We only ever need the download bar if we're appearing outside of the timeline // We only ever need the download bar if we're appearing outside of the timeline
if (this.props.tileShape) { if (this.props.tileShape) {
return <MFileBody {...this.props} showGenericPlaceholder={false} />; return <MFileBody {...this.props} showGenericPlaceholder={false} />;
@ -510,7 +514,7 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
const contentUrl = this.getContentUrl(); const contentUrl = this.getContentUrl();
let thumbUrl; let thumbUrl;
if (this.isGif() && SettingsStore.getValue("autoplayGifs")) { if (this.props.forExport || (this.isGif() && SettingsStore.getValue("autoplayGifs"))) {
thumbUrl = contentUrl; thumbUrl = contentUrl;
} else { } else {
thumbUrl = this.getThumbUrl(); thumbUrl = this.getThumbUrl();

View file

@ -79,7 +79,10 @@ export default class MVideoBody extends React.PureComponent<IBodyProps, IState>
} }
private getContentUrl(): string|null { private getContentUrl(): string|null {
const media = mediaFromContent(this.props.mxEvent.getContent()); const content = this.props.mxEvent.getContent<IMediaEventContent>();
// During export, the content url will point to the MSC, which will later point to a local url
if (this.props.forExport) return content.file?.url || content.url;
const media = mediaFromContent(content);
if (media.isEncrypted) { if (media.isEncrypted) {
return this.state.decryptedUrl; return this.state.decryptedUrl;
} else { } else {
@ -93,6 +96,9 @@ export default class MVideoBody extends React.PureComponent<IBodyProps, IState>
} }
private getThumbUrl(): string|null { private getThumbUrl(): string|null {
// there's no need of thumbnail when the content is local
if (this.props.forExport) return null;
const content = this.props.mxEvent.getContent<IMediaEventContent>(); const content = this.props.mxEvent.getContent<IMediaEventContent>();
const media = mediaFromContent(content); const media = mediaFromContent(content);
@ -209,6 +215,11 @@ export default class MVideoBody extends React.PureComponent<IBodyProps, IState>
this.props.onHeightChanged(); this.props.onHeightChanged();
}; };
private getFileBody = () => {
if (this.props.forExport) return null;
return this.props.tileShape && <MFileBody {...this.props} showGenericPlaceholder={false} />;
};
render() { render() {
const content = this.props.mxEvent.getContent(); const content = this.props.mxEvent.getContent();
const autoplay = SettingsStore.getValue("autoplayVideo"); const autoplay = SettingsStore.getValue("autoplayVideo");
@ -222,8 +233,8 @@ export default class MVideoBody extends React.PureComponent<IBodyProps, IState>
); );
} }
// Important: If we aren't autoplaying and we haven't decrypred it yet, show a video with a poster. // Important: If we aren't autoplaying and we haven't decrypted it yet, show a video with a poster.
if (content.file !== undefined && this.state.decryptedUrl === null && autoplay) { if (!this.props.forExport && content.file !== undefined && this.state.decryptedUrl === null && autoplay) {
// Need to decrypt the attachment // Need to decrypt the attachment
// The attachment is decrypted in componentDidMount. // The attachment is decrypted in componentDidMount.
// For now add an img tag with a spinner. // For now add an img tag with a spinner.
@ -254,6 +265,8 @@ export default class MVideoBody extends React.PureComponent<IBodyProps, IState>
preload = "none"; preload = "none";
} }
} }
const fileBody = this.getFileBody();
return ( return (
<span className="mx_MVideoBody"> <span className="mx_MVideoBody">
<video <video
@ -270,7 +283,7 @@ export default class MVideoBody extends React.PureComponent<IBodyProps, IState>
poster={poster} poster={poster}
onPlay={this.videoOnPlay} onPlay={this.videoOnPlay}
/> />
{ this.props.tileShape && <MFileBody {...this.props} showGenericPlaceholder={false} /> } { fileBody }
</span> </span>
); );
} }

View file

@ -24,7 +24,7 @@ import { isVoiceMessage } from "../../../utils/EventUtils";
@replaceableComponent("views.messages.MVoiceOrAudioBody") @replaceableComponent("views.messages.MVoiceOrAudioBody")
export default class MVoiceOrAudioBody extends React.PureComponent<IBodyProps> { export default class MVoiceOrAudioBody extends React.PureComponent<IBodyProps> {
public render() { public render() {
if (isVoiceMessage(this.props.mxEvent)) { if (!this.props.forExport && isVoiceMessage(this.props.mxEvent)) {
return <MVoiceMessageBody {...this.props} />; return <MVoiceMessageBody {...this.props} />;
} else { } else {
return <MAudioBody {...this.props} />; return <MAudioBody {...this.props} />;

View file

@ -27,7 +27,7 @@ import { Action } from '../../../dispatcher/actions';
import { RightPanelPhases } from '../../../stores/RightPanelStorePhases'; import { RightPanelPhases } from '../../../stores/RightPanelStorePhases';
import { aboveLeftOf, ContextMenu, ContextMenuTooltipButton, useContextMenu } from '../../structures/ContextMenu'; import { aboveLeftOf, ContextMenu, ContextMenuTooltipButton, useContextMenu } from '../../structures/ContextMenu';
import { isContentActionable, canEditContent } from '../../../utils/EventUtils'; import { isContentActionable, canEditContent } from '../../../utils/EventUtils';
import RoomContext from "../../../contexts/RoomContext"; import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext";
import Toolbar from "../../../accessibility/Toolbar"; import Toolbar from "../../../accessibility/Toolbar";
import { RovingAccessibleTooltipButton, useRovingTabIndex } from "../../../accessibility/RovingTabIndex"; import { RovingAccessibleTooltipButton, useRovingTabIndex } from "../../../accessibility/RovingTabIndex";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
@ -128,11 +128,6 @@ const ReactButton: React.FC<IReactButtonProps> = ({ mxEvent, reactions, onFocusC
</React.Fragment>; </React.Fragment>;
}; };
export enum ActionBarRenderingContext {
Room,
Thread
}
interface IMessageActionBarProps { interface IMessageActionBarProps {
mxEvent: MatrixEvent; mxEvent: MatrixEvent;
reactions?: Relations; reactions?: Relations;
@ -142,7 +137,6 @@ interface IMessageActionBarProps {
permalinkCreator?: RoomPermalinkCreator; permalinkCreator?: RoomPermalinkCreator;
onFocusChange?: (menuDisplayed: boolean) => void; onFocusChange?: (menuDisplayed: boolean) => void;
toggleThreadExpanded: () => void; toggleThreadExpanded: () => void;
renderingContext?: ActionBarRenderingContext;
isQuoteExpanded?: boolean; isQuoteExpanded?: boolean;
} }
@ -150,10 +144,6 @@ interface IMessageActionBarProps {
export default class MessageActionBar extends React.PureComponent<IMessageActionBarProps> { export default class MessageActionBar extends React.PureComponent<IMessageActionBarProps> {
public static contextType = RoomContext; public static contextType = RoomContext;
public static defaultProps = {
renderingContext: ActionBarRenderingContext.Room,
};
public componentDidMount(): void { public componentDidMount(): void {
if (this.props.mxEvent.status && this.props.mxEvent.status !== EventStatus.SENT) { if (this.props.mxEvent.status && this.props.mxEvent.status !== EventStatus.SENT) {
this.props.mxEvent.on("Event.status", this.onSent); this.props.mxEvent.on("Event.status", this.onSent);
@ -217,8 +207,9 @@ export default class MessageActionBar extends React.PureComponent<IMessageAction
private onEditClick = (ev: React.MouseEvent): void => { private onEditClick = (ev: React.MouseEvent): void => {
dis.dispatch({ dis.dispatch({
action: 'edit_event', action: Action.EditEvent,
event: this.props.mxEvent, event: this.props.mxEvent,
timelineRenderingType: this.context.timelineRenderingType,
}); });
}; };
@ -298,7 +289,7 @@ export default class MessageActionBar extends React.PureComponent<IMessageAction
// Like the resend button, the react and reply buttons need to appear before the edit. // Like the resend button, the react and reply buttons need to appear before the edit.
// The only catch is we do the reply button first so that we can make sure the react // The only catch is we do the reply button first so that we can make sure the react
// button is the very first button without having to do length checks for `splice()`. // button is the very first button without having to do length checks for `splice()`.
if (this.context.canReply && this.props.renderingContext === ActionBarRenderingContext.Room) { if (this.context.canReply && this.context.timelineRenderingType !== TimelineRenderingType.Thread) {
toolbarOpts.splice(0, 0, <> toolbarOpts.splice(0, 0, <>
<RovingAccessibleTooltipButton <RovingAccessibleTooltipButton
className="mx_MessageActionBar_maskButton mx_MessageActionBar_replyButton" className="mx_MessageActionBar_maskButton mx_MessageActionBar_replyButton"
@ -334,6 +325,19 @@ export default class MessageActionBar extends React.PureComponent<IMessageAction
/>); />);
} }
} }
// Show thread icon even for deleted messages, but only within main timeline
if (this.context.timelineRenderingType === TimelineRenderingType.Room &&
SettingsStore.getValue("feature_thread") &&
this.props.mxEvent.getThread() &&
!isContentActionable(this.props.mxEvent)
) {
toolbarOpts.unshift(<RovingAccessibleTooltipButton
className="mx_MessageActionBar_maskButton mx_MessageActionBar_threadButton"
title={_t("Thread")}
onClick={this.onThreadClick}
key="thread"
/>);
}
if (allowCancel) { if (allowCancel) {
toolbarOpts.push(cancelSendingButton); toolbarOpts.push(cancelSendingButton);

View file

@ -136,6 +136,7 @@ export default class MessageEvent extends React.Component<IProps> implements IMe
highlightLink={this.props.highlightLink} highlightLink={this.props.highlightLink}
showUrlPreview={this.props.showUrlPreview} showUrlPreview={this.props.showUrlPreview}
tileShape={this.props.tileShape} tileShape={this.props.tileShape}
forExport={this.props.forExport}
maxImageHeight={this.props.maxImageHeight} maxImageHeight={this.props.maxImageHeight}
replacingEventId={this.props.replacingEventId} replacingEventId={this.props.replacingEventId}
editState={this.props.editState} editState={this.props.editState}

View file

@ -29,7 +29,6 @@ interface IProps {
const RedactedBody = React.forwardRef<any, IProps | IBodyProps>(({ mxEvent }, ref) => { const RedactedBody = React.forwardRef<any, IProps | IBodyProps>(({ mxEvent }, ref) => {
const cli: MatrixClient = useContext(MatrixClientContext); const cli: MatrixClient = useContext(MatrixClientContext);
let text = _t("Message deleted"); let text = _t("Message deleted");
const unsigned = mxEvent.getUnsigned(); const unsigned = mxEvent.getUnsigned();
const redactedBecauseUserId = unsigned && unsigned.redacted_because && unsigned.redacted_because.sender; const redactedBecauseUserId = unsigned && unsigned.redacted_because && unsigned.redacted_because.sender;

View file

@ -49,16 +49,18 @@ const EncryptionInfo: React.FC<IProps> = ({
isSelfVerification, isSelfVerification,
}: IProps) => { }: IProps) => {
let content: JSX.Element; let content: JSX.Element;
if (waitingForOtherParty || waitingForNetwork) { if (waitingForOtherParty && isSelfVerification) {
content = (
<div>
{ _t("To proceed, please accept the verification request on your other login.") }
</div>
);
} else if (waitingForOtherParty || waitingForNetwork) {
let text: string; let text: string;
if (waitingForOtherParty) { if (waitingForOtherParty) {
if (isSelfVerification) {
text = _t("Accept on your other login…");
} else {
text = _t("Waiting for %(displayName)s to accept…", { text = _t("Waiting for %(displayName)s to accept…", {
displayName: (member as User).displayName || (member as RoomMember).name || member.userId, displayName: (member as User).displayName || (member as RoomMember).name || member.userId,
}); });
}
} else { } else {
text = _t("Accepting…"); text = _t("Accepting…");
} }

View file

@ -47,6 +47,7 @@ import { useRoomMemberCount } from "../../../hooks/useRoomMembers";
import { Container, MAX_PINNED, WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore"; import { Container, MAX_PINNED, WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore";
import RoomName from "../elements/RoomName"; import RoomName from "../elements/RoomName";
import UIStore from "../../../stores/UIStore"; import UIStore from "../../../stores/UIStore";
import ExportDialog from "../dialogs/ExportDialog";
interface IProps { interface IProps {
room: Room; room: Room;
@ -240,6 +241,12 @@ const RoomSummaryCard: React.FC<IProps> = ({ room, onClose }) => {
}); });
}; };
const onRoomExportClick = async () => {
Modal.createTrackedDialog('export room dialog', '', ExportDialog, {
room,
});
};
const isRoomEncrypted = useIsEncrypted(cli, room); const isRoomEncrypted = useIsEncrypted(cli, room);
const roomContext = useContext(RoomContext); const roomContext = useContext(RoomContext);
const e2eStatus = roomContext.e2eStatus; const e2eStatus = roomContext.e2eStatus;
@ -280,6 +287,9 @@ const RoomSummaryCard: React.FC<IProps> = ({ room, onClose }) => {
<Button className="mx_RoomSummaryCard_icon_files" onClick={onRoomFilesClick}> <Button className="mx_RoomSummaryCard_icon_files" onClick={onRoomFilesClick}>
{ _t("Show files") } { _t("Show files") }
</Button> </Button>
<Button className="mx_RoomSummaryCard_icon_export" onClick={onRoomExportClick}>
{ _t("Export chat") }
</Button>
{ SettingsStore.getValue("feature_thread") && ( { SettingsStore.getValue("feature_thread") && (
<Button className="mx_RoomSummaryCard_icon_threads" onClick={onRoomThreadsClick}> <Button className="mx_RoomSummaryCard_icon_threads" onClick={onRoomThreadsClick}>
{ _t("Show threads") } { _t("Show threads") }

View file

@ -70,8 +70,12 @@ import { mediaFromMxc } from "../../../customisations/Media";
import UIStore from "../../../stores/UIStore"; import UIStore from "../../../stores/UIStore";
import { ComposerInsertPayload } from "../../../dispatcher/payloads/ComposerInsertPayload"; import { ComposerInsertPayload } from "../../../dispatcher/payloads/ComposerInsertPayload";
import SpaceStore from "../../../stores/SpaceStore"; import SpaceStore from "../../../stores/SpaceStore";
import ConfirmSpaceUserActionDialog from "../dialogs/ConfirmSpaceUserActionDialog";
import { bulkSpaceBehaviour } from "../../../utils/space";
import { logger } from "matrix-js-sdk/src/logger"; import { logger } from "matrix-js-sdk/src/logger";
import { shouldShowComponent } from "../../../customisations/helpers/UIComponents";
import { UIComponent } from "../../../settings/UIFeature";
export interface IDevice { export interface IDevice {
deviceId: string; deviceId: string;
@ -393,7 +397,7 @@ const UserOptionsSection: React.FC<{
); );
} }
if (canInvite && (!member || !member.membership || member.membership === 'leave')) { if (canInvite && (member?.membership ?? 'leave') === 'leave' && shouldShowComponent(UIComponent.InviteUsers)) {
const roomId = member && member.roomId ? member.roomId : RoomViewStore.getRoomId(); const roomId = member && member.roomId ? member.roomId : RoomViewStore.getRoomId();
const onInviteUserButton = async () => { const onInviteUserButton = async () => {
try { try {
@ -532,7 +536,7 @@ interface IBaseProps {
stopUpdating(): void; stopUpdating(): void;
} }
const RoomKickButton: React.FC<IBaseProps> = ({ member, startUpdating, stopUpdating }) => { const RoomKickButton = ({ room, member, startUpdating, stopUpdating }: Omit<IBaseRoomProps, "powerLevels">) => {
const cli = useContext(MatrixClientContext); const cli = useContext(MatrixClientContext);
// check if user can be kicked/disinvited // check if user can be kicked/disinvited
@ -542,21 +546,38 @@ const RoomKickButton: React.FC<IBaseProps> = ({ member, startUpdating, stopUpdat
const { finished } = Modal.createTrackedDialog( const { finished } = Modal.createTrackedDialog(
'Confirm User Action Dialog', 'Confirm User Action Dialog',
'onKick', 'onKick',
ConfirmUserActionDialog, room.isSpaceRoom() ? ConfirmSpaceUserActionDialog : ConfirmUserActionDialog,
{ {
member, member,
action: member.membership === "invite" ? _t("Disinvite") : _t("Kick"), action: member.membership === "invite" ? _t("Disinvite") : _t("Kick"),
title: member.membership === "invite" ? _t("Disinvite this user?") : _t("Kick this user?"), title: member.membership === "invite"
? _t("Disinvite from %(roomName)s", { roomName: room.name })
: _t("Kick from %(roomName)s", { roomName: room.name }),
askReason: member.membership === "join", askReason: member.membership === "join",
danger: true, danger: true,
// space-specific props
space: room,
spaceChildFilter: (child: Room) => {
// Return true if the target member is not banned and we have sufficient PL to ban them
const myMember = child.getMember(cli.credentials.userId);
const theirMember = child.getMember(member.userId);
return myMember && theirMember && theirMember.membership === member.membership &&
myMember.powerLevel > theirMember.powerLevel &&
child.currentState.hasSufficientPowerLevelFor("kick", myMember.powerLevel);
}, },
allLabel: _t("Kick them from everything I'm able to"),
specificLabel: _t("Kick them from specific things I'm able to"),
warningMessage: _t("They'll still be able to access whatever you're not an admin of."),
},
room.isSpaceRoom() ? "mx_ConfirmSpaceUserActionDialog_wrapper" : undefined,
); );
const [proceed, reason] = await finished; const [proceed, reason, rooms = []] = await finished;
if (!proceed) return; if (!proceed) return;
startUpdating(); startUpdating();
cli.kick(member.roomId, member.userId, reason || undefined).then(() => {
bulkSpaceBehaviour(room, rooms, room => cli.kick(room.roomId, member.userId, reason || undefined)).then(() => {
// NO-OP; rely on the m.room.member event coming down else we could // NO-OP; rely on the m.room.member event coming down else we could
// get out of sync if we force setState here! // get out of sync if we force setState here!
logger.log("Kick success"); logger.log("Kick success");
@ -656,34 +677,69 @@ const RedactMessagesButton: React.FC<IBaseProps> = ({ member }) => {
</AccessibleButton>; </AccessibleButton>;
}; };
const BanToggleButton: React.FC<IBaseProps> = ({ member, startUpdating, stopUpdating }) => { const BanToggleButton = ({ room, member, startUpdating, stopUpdating }: Omit<IBaseRoomProps, "powerLevels">) => {
const cli = useContext(MatrixClientContext); const cli = useContext(MatrixClientContext);
const isBanned = member.membership === "ban";
const onBanOrUnban = async () => { const onBanOrUnban = async () => {
const { finished } = Modal.createTrackedDialog( const { finished } = Modal.createTrackedDialog(
'Confirm User Action Dialog', 'Confirm User Action Dialog',
'onBanOrUnban', 'onBanOrUnban',
ConfirmUserActionDialog, room.isSpaceRoom() ? ConfirmSpaceUserActionDialog : ConfirmUserActionDialog,
{ {
member, member,
action: member.membership === 'ban' ? _t("Unban") : _t("Ban"), action: isBanned ? _t("Unban") : _t("Ban"),
title: member.membership === 'ban' ? _t("Unban this user?") : _t("Ban this user?"), title: isBanned
askReason: member.membership !== 'ban', ? _t("Unban from %(roomName)s", { roomName: room.name })
danger: member.membership !== 'ban', : _t("Ban from %(roomName)s", { roomName: room.name }),
askReason: !isBanned,
danger: !isBanned,
// space-specific props
space: room,
spaceChildFilter: isBanned
? (child: Room) => {
// Return true if the target member is banned and we have sufficient PL to unban
const myMember = child.getMember(cli.credentials.userId);
const theirMember = child.getMember(member.userId);
return myMember && theirMember && theirMember.membership === "ban" &&
myMember.powerLevel > theirMember.powerLevel &&
child.currentState.hasSufficientPowerLevelFor("ban", myMember.powerLevel);
}
: (child: Room) => {
// Return true if the target member isn't banned and we have sufficient PL to ban
const myMember = child.getMember(cli.credentials.userId);
const theirMember = child.getMember(member.userId);
return myMember && theirMember && theirMember.membership !== "ban" &&
myMember.powerLevel > theirMember.powerLevel &&
child.currentState.hasSufficientPowerLevelFor("ban", myMember.powerLevel);
}, },
allLabel: isBanned
? _t("Unban them from everything I'm able to")
: _t("Ban them from everything I'm able to"),
specificLabel: isBanned
? _t("Unban them from specific things I'm able to")
: _t("Ban them from specific things I'm able to"),
warningMessage: isBanned
? _t("They won't be able to access whatever you're not an admin of.")
: _t("They'll still be able to access whatever you're not an admin of."),
},
room.isSpaceRoom() ? "mx_ConfirmSpaceUserActionDialog_wrapper" : undefined,
); );
const [proceed, reason] = await finished; const [proceed, reason, rooms = []] = await finished;
if (!proceed) return; if (!proceed) return;
startUpdating(); startUpdating();
let promise;
if (member.membership === 'ban') { const fn = (roomId: string) => {
promise = cli.unban(member.roomId, member.userId); if (isBanned) {
return cli.unban(roomId, member.userId);
} else { } else {
promise = cli.ban(member.roomId, member.userId, reason || undefined); return cli.ban(roomId, member.userId, reason || undefined);
} }
promise.then(() => { };
bulkSpaceBehaviour(room, rooms, room => fn(room.roomId)).then(() => {
// NO-OP; rely on the m.room.member event coming down else we could // NO-OP; rely on the m.room.member event coming down else we could
// get out of sync if we force setState here! // get out of sync if we force setState here!
logger.log("Ban success"); logger.log("Ban success");
@ -699,12 +755,12 @@ const BanToggleButton: React.FC<IBaseProps> = ({ member, startUpdating, stopUpda
}; };
let label = _t("Ban"); let label = _t("Ban");
if (member.membership === 'ban') { if (isBanned) {
label = _t("Unban"); label = _t("Unban");
} }
const classes = classNames("mx_UserInfo_field", { const classes = classNames("mx_UserInfo_field", {
mx_UserInfo_destructive: member.membership !== 'ban', mx_UserInfo_destructive: !isBanned,
}); });
return <AccessibleButton className={classes} onClick={onBanOrUnban}> return <AccessibleButton className={classes} onClick={onBanOrUnban}>
@ -817,18 +873,28 @@ const RoomAdminToolsContainer: React.FC<IBaseRoomProps> = ({
const isMe = me.userId === member.userId; const isMe = me.userId === member.userId;
const canAffectUser = member.powerLevel < me.powerLevel || isMe; const canAffectUser = member.powerLevel < me.powerLevel || isMe;
if (canAffectUser && me.powerLevel >= kickPowerLevel) { if (!isMe && canAffectUser && me.powerLevel >= kickPowerLevel) {
kickButton = <RoomKickButton member={member} startUpdating={startUpdating} stopUpdating={stopUpdating} />; kickButton = <RoomKickButton
room={room}
member={member}
startUpdating={startUpdating}
stopUpdating={stopUpdating}
/>;
} }
if (me.powerLevel >= redactPowerLevel && (!SpaceStore.spacesEnabled || !room.isSpaceRoom())) { if (me.powerLevel >= redactPowerLevel && (!SpaceStore.spacesEnabled || !room.isSpaceRoom())) {
redactButton = ( redactButton = (
<RedactMessagesButton member={member} startUpdating={startUpdating} stopUpdating={stopUpdating} /> <RedactMessagesButton member={member} startUpdating={startUpdating} stopUpdating={stopUpdating} />
); );
} }
if (canAffectUser && me.powerLevel >= banPowerLevel) { if (!isMe && canAffectUser && me.powerLevel >= banPowerLevel) {
banButton = <BanToggleButton member={member} startUpdating={startUpdating} stopUpdating={stopUpdating} />; banButton = <BanToggleButton
room={room}
member={member}
startUpdating={startUpdating}
stopUpdating={stopUpdating}
/>;
} }
if (canAffectUser && me.powerLevel >= editPowerLevel && !room.isSpaceRoom()) { if (!isMe && canAffectUser && me.powerLevel >= editPowerLevel && !room.isSpaceRoom()) {
muteButton = ( muteButton = (
<MuteToggleButton <MuteToggleButton
member={member} member={member}

View file

@ -35,7 +35,7 @@ interface IState {
avatarFile: File; avatarFile: File;
originalTopic: string; originalTopic: string;
topic: string; topic: string;
enableProfileSave: boolean; profileFieldsTouched: Record<string, boolean>;
canSetName: boolean; canSetName: boolean;
canSetTopic: boolean; canSetTopic: boolean;
canSetAvatar: boolean; canSetAvatar: boolean;
@ -71,7 +71,7 @@ export default class RoomProfileSettings extends React.Component<IProps, IState>
avatarFile: null, avatarFile: null,
originalTopic: topic, originalTopic: topic,
topic: topic, topic: topic,
enableProfileSave: false, profileFieldsTouched: {},
canSetName: room.currentState.maySendStateEvent('m.room.name', client.getUserId()), canSetName: room.currentState.maySendStateEvent('m.room.name', client.getUserId()),
canSetTopic: room.currentState.maySendStateEvent('m.room.topic', client.getUserId()), canSetTopic: room.currentState.maySendStateEvent('m.room.topic', client.getUserId()),
canSetAvatar: room.currentState.maySendStateEvent('m.room.avatar', client.getUserId()), canSetAvatar: room.currentState.maySendStateEvent('m.room.avatar', client.getUserId()),
@ -88,17 +88,24 @@ export default class RoomProfileSettings extends React.Component<IProps, IState>
this.setState({ this.setState({
avatarUrl: null, avatarUrl: null,
avatarFile: null, avatarFile: null,
enableProfileSave: true, profileFieldsTouched: {
...this.state.profileFieldsTouched,
avatar: true,
},
}); });
}; };
private isSaveEnabled = () => {
return Boolean(Object.values(this.state.profileFieldsTouched).length);
};
private cancelProfileChanges = async (e: React.MouseEvent): Promise<void> => { private cancelProfileChanges = async (e: React.MouseEvent): Promise<void> => {
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
if (!this.state.enableProfileSave) return; if (!this.isSaveEnabled()) return;
this.setState({ this.setState({
enableProfileSave: false, profileFieldsTouched: {},
displayName: this.state.originalDisplayName, displayName: this.state.originalDisplayName,
topic: this.state.originalTopic, topic: this.state.originalTopic,
avatarUrl: this.state.originalAvatarUrl, avatarUrl: this.state.originalAvatarUrl,
@ -110,8 +117,8 @@ export default class RoomProfileSettings extends React.Component<IProps, IState>
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
if (!this.state.enableProfileSave) return; if (!this.isSaveEnabled()) return;
this.setState({ enableProfileSave: false }); this.setState({ profileFieldsTouched: {} });
const client = MatrixClientPeg.get(); const client = MatrixClientPeg.get();
@ -156,18 +163,38 @@ export default class RoomProfileSettings extends React.Component<IProps, IState>
private onDisplayNameChanged = (e: React.ChangeEvent<HTMLInputElement>): void => { private onDisplayNameChanged = (e: React.ChangeEvent<HTMLInputElement>): void => {
this.setState({ displayName: e.target.value }); this.setState({ displayName: e.target.value });
if (this.state.originalDisplayName === e.target.value) { if (this.state.originalDisplayName === e.target.value) {
this.setState({ enableProfileSave: false }); this.setState({
profileFieldsTouched: {
...this.state.profileFieldsTouched,
name: false,
},
});
} else { } else {
this.setState({ enableProfileSave: true }); this.setState({
profileFieldsTouched: {
...this.state.profileFieldsTouched,
name: true,
},
});
} }
}; };
private onTopicChanged = (e: React.ChangeEvent<HTMLTextAreaElement>): void => { private onTopicChanged = (e: React.ChangeEvent<HTMLTextAreaElement>): void => {
this.setState({ topic: e.target.value }); this.setState({ topic: e.target.value });
if (this.state.originalTopic === e.target.value) { if (this.state.originalTopic === e.target.value) {
this.setState({ enableProfileSave: false }); this.setState({
profileFieldsTouched: {
...this.state.profileFieldsTouched,
topic: false,
},
});
} else { } else {
this.setState({ enableProfileSave: true }); this.setState({
profileFieldsTouched: {
...this.state.profileFieldsTouched,
topic: true,
},
});
} }
}; };
@ -176,7 +203,10 @@ export default class RoomProfileSettings extends React.Component<IProps, IState>
this.setState({ this.setState({
avatarUrl: this.state.originalAvatarUrl, avatarUrl: this.state.originalAvatarUrl,
avatarFile: null, avatarFile: null,
enableProfileSave: false, profileFieldsTouched: {
...this.state.profileFieldsTouched,
avatar: false,
},
}); });
return; return;
} }
@ -187,7 +217,10 @@ export default class RoomProfileSettings extends React.Component<IProps, IState>
this.setState({ this.setState({
avatarUrl: String(ev.target.result), avatarUrl: String(ev.target.result),
avatarFile: file, avatarFile: file,
enableProfileSave: true, profileFieldsTouched: {
...this.state.profileFieldsTouched,
avatar: true,
},
}); });
}; };
reader.readAsDataURL(file); reader.readAsDataURL(file);
@ -205,14 +238,14 @@ export default class RoomProfileSettings extends React.Component<IProps, IState>
<AccessibleButton <AccessibleButton
onClick={this.cancelProfileChanges} onClick={this.cancelProfileChanges}
kind="link" kind="link"
disabled={!this.state.enableProfileSave} disabled={!this.isSaveEnabled()}
> >
{ _t("Cancel") } { _t("Cancel") }
</AccessibleButton> </AccessibleButton>
<AccessibleButton <AccessibleButton
onClick={this.saveProfile} onClick={this.saveProfile}
kind="primary" kind="primary"
disabled={!this.state.enableProfileSave} disabled={!this.isSaveEnabled()}
> >
{ _t("Save") } { _t("Save") }
</AccessibleButton> </AccessibleButton>

View file

@ -28,7 +28,6 @@ import { parseEvent } from '../../../editor/deserialize';
import { CommandPartCreator, Part, PartCreator, Type } from '../../../editor/parts'; import { CommandPartCreator, Part, PartCreator, Type } from '../../../editor/parts';
import EditorStateTransfer from '../../../utils/EditorStateTransfer'; import EditorStateTransfer from '../../../utils/EditorStateTransfer';
import BasicMessageComposer, { REGEX_EMOTICON } from "./BasicMessageComposer"; import BasicMessageComposer, { REGEX_EMOTICON } from "./BasicMessageComposer";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { Command, CommandCategories, getCommand } from '../../../SlashCommands'; import { Command, CommandCategories, getCommand } from '../../../SlashCommands';
import { Action } from "../../../dispatcher/actions"; import { Action } from "../../../dispatcher/actions";
import CountlyAnalytics from "../../../CountlyAnalytics"; import CountlyAnalytics from "../../../CountlyAnalytics";
@ -36,7 +35,7 @@ import { getKeyBindingsManager, MessageComposerAction } from '../../../KeyBindin
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import SendHistoryManager from '../../../SendHistoryManager'; import SendHistoryManager from '../../../SendHistoryManager';
import Modal from '../../../Modal'; import Modal from '../../../Modal';
import { MsgType } from 'matrix-js-sdk/src/@types/event'; import { MsgType, UNSTABLE_ELEMENT_REPLY_IN_THREAD } from 'matrix-js-sdk/src/@types/event';
import { Room } from 'matrix-js-sdk/src/models/room'; import { Room } from 'matrix-js-sdk/src/models/room';
import ErrorDialog from "../dialogs/ErrorDialog"; import ErrorDialog from "../dialogs/ErrorDialog";
import QuestionDialog from "../dialogs/QuestionDialog"; import QuestionDialog from "../dialogs/QuestionDialog";
@ -46,6 +45,8 @@ import { createRedactEventDialog } from '../dialogs/ConfirmRedactDialog';
import SettingsStore from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
import { logger } from "matrix-js-sdk/src/logger"; import { logger } from "matrix-js-sdk/src/logger";
import { withMatrixClientHOC, MatrixClientProps } from '../../../contexts/MatrixClientContext';
import RoomContext, { TimelineRenderingType } from '../../../contexts/RoomContext';
function getHtmlReplyFallback(mxEvent: MatrixEvent): string { function getHtmlReplyFallback(mxEvent: MatrixEvent): string {
const html = mxEvent.getContent().formatted_body; const html = mxEvent.getContent().formatted_body;
@ -66,7 +67,11 @@ function getTextReplyFallback(mxEvent: MatrixEvent): string {
return ""; return "";
} }
function createEditContent(model: EditorModel, editedEvent: MatrixEvent): IContent { function createEditContent(
model: EditorModel,
editedEvent: MatrixEvent,
renderingContext?: TimelineRenderingType,
): IContent {
const isEmote = containsEmote(model); const isEmote = containsEmote(model);
if (isEmote) { if (isEmote) {
model = stripEmoteCommand(model); model = stripEmoteCommand(model);
@ -99,41 +104,49 @@ function createEditContent(model: EditorModel, editedEvent: MatrixEvent): IConte
contentBody.formatted_body = `${htmlPrefix} * ${formattedBody}`; contentBody.formatted_body = `${htmlPrefix} * ${formattedBody}`;
} }
return Object.assign({ const relation = {
"m.new_content": newContent, "m.new_content": newContent,
"m.relates_to": { "m.relates_to": {
"rel_type": "m.replace", "rel_type": "m.replace",
"event_id": editedEvent.getId(), "event_id": editedEvent.getId(),
}, },
}, contentBody); };
if (renderingContext === TimelineRenderingType.Thread) {
relation['m.relates_to'][UNSTABLE_ELEMENT_REPLY_IN_THREAD.name] = true;
}
return Object.assign(relation, contentBody);
} }
interface IProps { interface IEditMessageComposerProps extends MatrixClientProps {
editState: EditorStateTransfer; editState: EditorStateTransfer;
className?: string; className?: string;
} }
interface IState { interface IState {
saveDisabled: boolean; saveDisabled: boolean;
} }
@replaceableComponent("views.rooms.EditMessageComposer") @replaceableComponent("views.rooms.EditMessageComposer")
export default class EditMessageComposer extends React.Component<IProps, IState> { class EditMessageComposer extends React.Component<IEditMessageComposerProps, IState> {
static contextType = MatrixClientContext; static contextType = RoomContext;
context!: React.ContextType<typeof MatrixClientContext>; context!: React.ContextType<typeof RoomContext>;
private readonly editorRef = createRef<BasicMessageComposer>(); private readonly editorRef = createRef<BasicMessageComposer>();
private readonly dispatcherRef: string; private readonly dispatcherRef: string;
private model: EditorModel = null; private model: EditorModel = null;
constructor(props: IProps, context: React.ContextType<typeof MatrixClientContext>) { constructor(props: IEditMessageComposerProps, context: React.ContextType<typeof RoomContext>) {
super(props); super(props);
this.context = context; // otherwise React will only set it prior to render due to type def above this.context = context; // otherwise React will only set it prior to render due to type def above
const isRestored = this.createEditorModel(); const isRestored = this.createEditorModel();
const ev = this.props.editState.getEvent(); const ev = this.props.editState.getEvent();
const renderingContext = this.context.timelineRenderingType;
const editContent = createEditContent(this.model, ev, renderingContext);
this.state = { this.state = {
saveDisabled: !isRestored || !this.isContentModified(createEditContent(this.model, ev)["m.new_content"]), saveDisabled: !isRestored || !this.isContentModified(editContent["m.new_content"]),
}; };
window.addEventListener("beforeunload", this.saveStoredEditorState); window.addEventListener("beforeunload", this.saveStoredEditorState);
@ -141,7 +154,7 @@ export default class EditMessageComposer extends React.Component<IProps, IState>
} }
private getRoom(): Room { private getRoom(): Room {
return this.context.getRoom(this.props.editState.getEvent().getRoomId()); return this.props.mxClient.getRoom(this.props.editState.getEvent().getRoomId());
} }
private onKeyDown = (event: KeyboardEvent): void => { private onKeyDown = (event: KeyboardEvent): void => {
@ -162,10 +175,17 @@ export default class EditMessageComposer extends React.Component<IProps, IState>
if (this.editorRef.current?.isModified() || !this.editorRef.current?.isCaretAtStart()) { if (this.editorRef.current?.isModified() || !this.editorRef.current?.isCaretAtStart()) {
return; return;
} }
const previousEvent = findEditableEvent(this.getRoom(), false, const previousEvent = findEditableEvent({
this.props.editState.getEvent().getId()); events: this.events,
isForward: false,
fromEventId: this.props.editState.getEvent().getId(),
});
if (previousEvent) { if (previousEvent) {
dis.dispatch({ action: 'edit_event', event: previousEvent }); dis.dispatch({
action: Action.EditEvent,
event: previousEvent,
timelineRenderingType: this.context.timelineRenderingType,
});
event.preventDefault(); event.preventDefault();
} }
break; break;
@ -174,12 +194,24 @@ export default class EditMessageComposer extends React.Component<IProps, IState>
if (this.editorRef.current?.isModified() || !this.editorRef.current?.isCaretAtEnd()) { if (this.editorRef.current?.isModified() || !this.editorRef.current?.isCaretAtEnd()) {
return; return;
} }
const nextEvent = findEditableEvent(this.getRoom(), true, this.props.editState.getEvent().getId()); const nextEvent = findEditableEvent({
events: this.events,
isForward: true,
fromEventId: this.props.editState.getEvent().getId(),
});
if (nextEvent) { if (nextEvent) {
dis.dispatch({ action: 'edit_event', event: nextEvent }); dis.dispatch({
action: Action.EditEvent,
event: nextEvent,
timelineRenderingType: this.context.timelineRenderingType,
});
} else { } else {
this.clearStoredEditorState(); this.clearStoredEditorState();
dis.dispatch({ action: 'edit_event', event: null }); dis.dispatch({
action: Action.EditEvent,
event: null,
timelineRenderingType: this.context.timelineRenderingType,
});
dis.fire(Action.FocusSendMessageComposer); dis.fire(Action.FocusSendMessageComposer);
} }
event.preventDefault(); event.preventDefault();
@ -189,16 +221,27 @@ export default class EditMessageComposer extends React.Component<IProps, IState>
}; };
private get editorRoomKey(): string { private get editorRoomKey(): string {
return `mx_edit_room_${this.getRoom().roomId}`; return `mx_edit_room_${this.getRoom().roomId}_${this.context.timelineRenderingType}`;
} }
private get editorStateKey(): string { private get editorStateKey(): string {
return `mx_edit_state_${this.props.editState.getEvent().getId()}`; return `mx_edit_state_${this.props.editState.getEvent().getId()}`;
} }
private get events(): MatrixEvent[] {
const liveTimelineEvents = this.context.liveTimeline.getEvents();
const pendingEvents = this.getRoom().getPendingEvents();
const isInThread = Boolean(this.props.editState.getEvent().getThread());
return liveTimelineEvents.concat(isInThread ? [] : pendingEvents);
}
private cancelEdit = (): void => { private cancelEdit = (): void => {
this.clearStoredEditorState(); this.clearStoredEditorState();
dis.dispatch({ action: "edit_event", event: null }); dis.dispatch({
action: Action.EditEvent,
event: null,
timelineRenderingType: this.context.timelineRenderingType,
});
dis.fire(Action.FocusSendMessageComposer); dis.fire(Action.FocusSendMessageComposer);
}; };
@ -326,8 +369,8 @@ export default class EditMessageComposer extends React.Component<IProps, IState>
const position = this.model.positionForOffset(caret.offset, caret.atNodeEnd); const position = this.model.positionForOffset(caret.offset, caret.atNodeEnd);
this.editorRef.current?.replaceEmoticon(position, REGEX_EMOTICON); this.editorRef.current?.replaceEmoticon(position, REGEX_EMOTICON);
} }
const renderingContext = this.context.timelineRenderingType;
const editContent = createEditContent(this.model, editedEvent); const editContent = createEditContent(this.model, editedEvent, renderingContext);
const newContent = editContent["m.new_content"]; const newContent = editContent["m.new_content"];
let shouldSend = true; let shouldSend = true;
@ -381,7 +424,7 @@ export default class EditMessageComposer extends React.Component<IProps, IState>
} }
if (shouldSend) { if (shouldSend) {
this.cancelPreviousPendingEdit(); this.cancelPreviousPendingEdit();
const prom = this.context.sendMessage(roomId, editContent); const prom = this.props.mxClient.sendMessage(roomId, editContent);
this.clearStoredEditorState(); this.clearStoredEditorState();
dis.dispatch({ action: "message_sent" }); dis.dispatch({ action: "message_sent" });
CountlyAnalytics.instance.trackSendMessage(startTime, prom, roomId, true, false, editContent); CountlyAnalytics.instance.trackSendMessage(startTime, prom, roomId, true, false, editContent);
@ -389,7 +432,11 @@ export default class EditMessageComposer extends React.Component<IProps, IState>
} }
// close the event editing and focus composer // close the event editing and focus composer
dis.dispatch({ action: "edit_event", event: null }); dis.dispatch({
action: Action.EditEvent,
event: null,
timelineRenderingType: this.context.timelineRenderingType,
});
dis.fire(Action.FocusSendMessageComposer); dis.fire(Action.FocusSendMessageComposer);
}; };
@ -400,7 +447,7 @@ export default class EditMessageComposer extends React.Component<IProps, IState>
previousEdit.status === EventStatus.QUEUED || previousEdit.status === EventStatus.QUEUED ||
previousEdit.status === EventStatus.NOT_SENT previousEdit.status === EventStatus.NOT_SENT
)) { )) {
this.context.cancelPendingEvent(previousEdit); this.props.mxClient.cancelPendingEvent(previousEdit);
} }
} }
@ -428,7 +475,7 @@ export default class EditMessageComposer extends React.Component<IProps, IState>
private createEditorModel(): boolean { private createEditorModel(): boolean {
const { editState } = this.props; const { editState } = this.props;
const room = this.getRoom(); const room = this.getRoom();
const partCreator = new CommandPartCreator(room, this.context); const partCreator = new CommandPartCreator(room, this.props.mxClient);
let parts; let parts;
let isRestored = false; let isRestored = false;
@ -493,3 +540,6 @@ export default class EditMessageComposer extends React.Component<IProps, IState>
</div>); </div>);
} }
} }
const EditMessageComposerWithMatrixClient = withMatrixClientHOC(EditMessageComposer);
export default EditMessageComposerWithMatrixClient;

View file

@ -53,7 +53,7 @@ import SenderProfile from '../messages/SenderProfile';
import MessageTimestamp from '../messages/MessageTimestamp'; import MessageTimestamp from '../messages/MessageTimestamp';
import TooltipButton from '../elements/TooltipButton'; import TooltipButton from '../elements/TooltipButton';
import ReadReceiptMarker from "./ReadReceiptMarker"; import ReadReceiptMarker from "./ReadReceiptMarker";
import MessageActionBar, { ActionBarRenderingContext } from "../messages/MessageActionBar"; import MessageActionBar from "../messages/MessageActionBar";
import ReactionsRow from '../messages/ReactionsRow'; import ReactionsRow from '../messages/ReactionsRow';
import { getEventDisplayInfo } from '../../../utils/EventUtils'; import { getEventDisplayInfo } from '../../../utils/EventUtils';
import { RightPanelPhases } from "../../../stores/RightPanelStorePhases"; import { RightPanelPhases } from "../../../stores/RightPanelStorePhases";
@ -264,6 +264,8 @@ interface IProps {
// for now. // for now.
tileShape?: TileShape; tileShape?: TileShape;
forExport?: boolean;
// show twelve hour timestamps // show twelve hour timestamps
isTwelveHour?: boolean; isTwelveHour?: boolean;
@ -340,6 +342,7 @@ export default class EventTile extends React.Component<IProps, IState> {
static defaultProps = { static defaultProps = {
// no-op function because onHeightChanged is optional yet some sub-components assume its existence // no-op function because onHeightChanged is optional yet some sub-components assume its existence
onHeightChanged: function() {}, onHeightChanged: function() {},
forExport: false,
layout: Layout.Group, layout: Layout.Group,
}; };
@ -382,7 +385,7 @@ export default class EventTile extends React.Component<IProps, IState> {
* or 'sent' receipt, for example. * or 'sent' receipt, for example.
* @returns {boolean} * @returns {boolean}
*/ */
private get isEligibleForSpecialReceipt() { private get isEligibleForSpecialReceipt(): boolean {
// First, if there are other read receipts then just short-circuit this. // First, if there are other read receipts then just short-circuit this.
if (this.props.readReceipts && this.props.readReceipts.length > 0) return false; if (this.props.readReceipts && this.props.readReceipts.length > 0) return false;
if (!this.props.mxEvent) return false; if (!this.props.mxEvent) return false;
@ -453,6 +456,7 @@ export default class EventTile extends React.Component<IProps, IState> {
componentDidMount() { componentDidMount() {
this.suppressReadReceiptAnimation = false; this.suppressReadReceiptAnimation = false;
const client = this.context; const client = this.context;
if (!this.props.forExport) {
client.on("deviceVerificationChanged", this.onDeviceVerificationChanged); client.on("deviceVerificationChanged", this.onDeviceVerificationChanged);
client.on("userTrustStatusChanged", this.onUserVerificationChanged); client.on("userTrustStatusChanged", this.onUserVerificationChanged);
this.props.mxEvent.on("Event.decrypted", this.onDecrypted); this.props.mxEvent.on("Event.decrypted", this.onDecrypted);
@ -464,6 +468,7 @@ export default class EventTile extends React.Component<IProps, IState> {
client.on("Room.receipt", this.onRoomReceipt); client.on("Room.receipt", this.onRoomReceipt);
this.isListeningForReceipts = true; this.isListeningForReceipts = true;
} }
}
if (SettingsStore.getValue("feature_thread")) { if (SettingsStore.getValue("feature_thread")) {
this.props.mxEvent.once(ThreadEvent.Ready, this.updateThread); this.props.mxEvent.once(ThreadEvent.Ready, this.updateThread);
@ -698,6 +703,7 @@ export default class EventTile extends React.Component<IProps, IState> {
} }
shouldHighlight() { shouldHighlight() {
if (this.props.forExport) return false;
const actions = this.context.getPushActionsForEvent(this.props.mxEvent.replacingEvent() || this.props.mxEvent); const actions = this.context.getPushActionsForEvent(this.props.mxEvent.replacingEvent() || this.props.mxEvent);
if (!actions || !actions.tweaks) { return false; } if (!actions || !actions.tweaks) { return false; }
@ -1056,17 +1062,14 @@ export default class EventTile extends React.Component<IProps, IState> {
} }
} }
const renderingContext = this.props.tileShape === TileShape.Thread const showMessageActionBar = !isEditing && !this.props.forExport;
? ActionBarRenderingContext.Thread const actionBar = showMessageActionBar ? <MessageActionBar
: ActionBarRenderingContext.Room;
const actionBar = !isEditing ? <MessageActionBar
mxEvent={this.props.mxEvent} mxEvent={this.props.mxEvent}
reactions={this.state.reactions} reactions={this.state.reactions}
permalinkCreator={this.props.permalinkCreator} permalinkCreator={this.props.permalinkCreator}
getTile={this.getTile} getTile={this.getTile}
getReplyThread={this.getReplyThread} getReplyThread={this.getReplyThread}
onFocusChange={this.onActionBarFocusChange} onFocusChange={this.onActionBarFocusChange}
renderingContext={renderingContext}
isQuoteExpanded={isQuoteExpanded} isQuoteExpanded={isQuoteExpanded}
toggleThreadExpanded={() => this.setQuoteExpanded(!isQuoteExpanded)} toggleThreadExpanded={() => this.setQuoteExpanded(!isQuoteExpanded)}
/> : undefined; /> : undefined;
@ -1171,6 +1174,7 @@ export default class EventTile extends React.Component<IProps, IState> {
showUrlPreview={this.props.showUrlPreview} showUrlPreview={this.props.showUrlPreview}
onHeightChanged={this.props.onHeightChanged} onHeightChanged={this.props.onHeightChanged}
tileShape={this.props.tileShape} tileShape={this.props.tileShape}
editState={this.props.editState}
/> />
</div>, </div>,
]); ]);
@ -1204,6 +1208,8 @@ export default class EventTile extends React.Component<IProps, IState> {
showUrlPreview={this.props.showUrlPreview} showUrlPreview={this.props.showUrlPreview}
onHeightChanged={this.props.onHeightChanged} onHeightChanged={this.props.onHeightChanged}
tileShape={this.props.tileShape} tileShape={this.props.tileShape}
editState={this.props.editState}
replacingEventId={this.props.replacingEventId}
/> />
{ actionBar } { actionBar }
</div>, </div>,
@ -1224,6 +1230,7 @@ export default class EventTile extends React.Component<IProps, IState> {
showUrlPreview={this.props.showUrlPreview} showUrlPreview={this.props.showUrlPreview}
tileShape={this.props.tileShape} tileShape={this.props.tileShape}
onHeightChanged={this.props.onHeightChanged} onHeightChanged={this.props.onHeightChanged}
editState={this.props.editState}
/> />
</div>, </div>,
<a <a
@ -1247,6 +1254,7 @@ export default class EventTile extends React.Component<IProps, IState> {
parentEv={this.props.mxEvent} parentEv={this.props.mxEvent}
onHeightChanged={this.props.onHeightChanged} onHeightChanged={this.props.onHeightChanged}
ref={this.replyThread} ref={this.replyThread}
forExport={this.props.forExport}
permalinkCreator={this.props.permalinkCreator} permalinkCreator={this.props.permalinkCreator}
layout={this.props.layout} layout={this.props.layout}
alwaysShowTimestamps={this.props.alwaysShowTimestamps || this.state.hover} alwaysShowTimestamps={this.props.alwaysShowTimestamps || this.state.hover}
@ -1280,6 +1288,7 @@ export default class EventTile extends React.Component<IProps, IState> {
{ thread } { thread }
<EventTileType ref={this.tile} <EventTileType ref={this.tile}
mxEvent={this.props.mxEvent} mxEvent={this.props.mxEvent}
forExport={this.props.forExport}
replacingEventId={this.props.replacingEventId} replacingEventId={this.props.replacingEventId}
editState={this.props.editState} editState={this.props.editState}
highlights={this.props.highlights} highlights={this.props.highlights}
@ -1305,7 +1314,7 @@ export default class EventTile extends React.Component<IProps, IState> {
// XXX this'll eventually be dynamic based on the fields once we have extensible event types // XXX this'll eventually be dynamic based on the fields once we have extensible event types
const messageTypes = ['m.room.message', 'm.sticker']; const messageTypes = ['m.room.message', 'm.sticker'];
function isMessageEvent(ev) { function isMessageEvent(ev: MatrixEvent): boolean {
return (messageTypes.includes(ev.getType())); return (messageTypes.includes(ev.getType()));
} }

View file

@ -44,6 +44,8 @@ import MemberTile from "./MemberTile";
import BaseAvatar from '../avatars/BaseAvatar'; import BaseAvatar from '../avatars/BaseAvatar';
import { throttle } from 'lodash'; import { throttle } from 'lodash';
import SpaceStore from "../../../stores/SpaceStore"; import SpaceStore from "../../../stores/SpaceStore";
import { shouldShowComponent } from "../../../customisations/helpers/UIComponents";
import { UIComponent } from "../../../settings/UIFeature";
import { JoinRule } from "matrix-js-sdk/src/@types/partials"; import { JoinRule } from "matrix-js-sdk/src/@types/partials";
const getSearchQueryLSKey = (roomId: string) => `mx_MemberList_searchQuarry_${roomId}`; const getSearchQueryLSKey = (roomId: string) => `mx_MemberList_searchQuarry_${roomId}`;
@ -535,7 +537,7 @@ export default class MemberList extends React.Component<IProps, IState> {
const room = cli.getRoom(this.props.roomId); const room = cli.getRoom(this.props.roomId);
let inviteButton; let inviteButton;
if (room && room.getMyMembership() === 'join') { if (room?.getMyMembership() === 'join' && shouldShowComponent(UIComponent.InviteUsers)) {
let inviteButtonText = _t("Invite to this room"); let inviteButtonText = _t("Invite to this room");
const chat = CommunityPrototypeStore.instance.getSelectedCommunityGeneralChat(); const chat = CommunityPrototypeStore.instance.getSelectedCommunityGeneralChat();
if (chat && chat.roomId === this.props.roomId) { if (chat && chat.roomId === this.props.roomId) {

View file

@ -45,13 +45,15 @@ import { RecordingState } from "../../../audio/VoiceRecording";
import Tooltip, { Alignment } from "../elements/Tooltip"; import Tooltip, { Alignment } from "../elements/Tooltip";
import ResizeNotifier from "../../../utils/ResizeNotifier"; import ResizeNotifier from "../../../utils/ResizeNotifier";
import { E2EStatus } from '../../../utils/ShieldUtils'; import { E2EStatus } from '../../../utils/ShieldUtils';
import SendMessageComposer from "./SendMessageComposer"; import SendMessageComposer, { SendMessageComposer as SendMessageComposerClass } from "./SendMessageComposer";
import { ComposerInsertPayload } from "../../../dispatcher/payloads/ComposerInsertPayload"; import { ComposerInsertPayload } from "../../../dispatcher/payloads/ComposerInsertPayload";
import { Action } from "../../../dispatcher/actions"; import { Action } from "../../../dispatcher/actions";
import EditorModel from "../../../editor/model"; import EditorModel from "../../../editor/model";
import EmojiPicker from '../emojipicker/EmojiPicker'; import EmojiPicker from '../emojipicker/EmojiPicker';
import MemberStatusMessageAvatar from "../avatars/MemberStatusMessageAvatar"; import MemberStatusMessageAvatar from "../avatars/MemberStatusMessageAvatar";
import UIStore, { UI_EVENTS } from '../../../stores/UIStore'; import UIStore, { UI_EVENTS } from '../../../stores/UIStore';
import Modal from "../../../Modal";
import InfoDialog from "../dialogs/InfoDialog";
let instanceCount = 0; let instanceCount = 0;
const NARROW_MODE_BREAKPOINT = 500; const NARROW_MODE_BREAKPOINT = 500;
@ -193,6 +195,31 @@ class UploadButton extends React.Component<IUploadButtonProps> {
} }
} }
// TODO: [polls] Make this component actually do something
class PollButton extends React.PureComponent {
private onCreateClick = () => {
Modal.createTrackedDialog('Polls', 'Not Yet Implemented', InfoDialog, {
// XXX: Deliberately not translated given this dialog is meant to be replaced and we don't
// want to clutter the language files with short-lived strings.
title: "Polls are currently in development",
description: "" +
"Thanks for testing polls! We haven't quite gotten a chance to write the feature yet " +
"though. Check back later for updates.",
hasCloseButton: true,
});
};
render() {
return (
<AccessibleTooltipButton
className="mx_MessageComposer_button mx_MessageComposer_poll"
onClick={this.onCreateClick}
title={_t('Create poll')}
/>
);
}
}
interface IProps { interface IProps {
room: Room; room: Room;
resizeNotifier: ResizeNotifier; resizeNotifier: ResizeNotifier;
@ -219,8 +246,8 @@ interface IState {
@replaceableComponent("views.rooms.MessageComposer") @replaceableComponent("views.rooms.MessageComposer")
export default class MessageComposer extends React.Component<IProps, IState> { export default class MessageComposer extends React.Component<IProps, IState> {
private dispatcherRef: string; private dispatcherRef: string;
private messageComposerInput: SendMessageComposer; private messageComposerInput = createRef<SendMessageComposerClass>();
private voiceRecordingButton: VoiceRecordComposerTile; private voiceRecordingButton = createRef<VoiceRecordComposerTile>();
private ref: React.RefObject<HTMLDivElement> = createRef(); private ref: React.RefObject<HTMLDivElement> = createRef();
private instanceId: number; private instanceId: number;
@ -378,14 +405,14 @@ export default class MessageComposer extends React.Component<IProps, IState> {
} }
private sendMessage = async () => { private sendMessage = async () => {
if (this.state.haveRecording && this.voiceRecordingButton) { if (this.state.haveRecording && this.voiceRecordingButton.current) {
// There shouldn't be any text message to send when a voice recording is active, so // There shouldn't be any text message to send when a voice recording is active, so
// just send out the voice recording. // just send out the voice recording.
await this.voiceRecordingButton.send(); await this.voiceRecordingButton.current?.send();
return; return;
} }
this.messageComposerInput.sendMessage(); this.messageComposerInput.current?.sendMessage();
}; };
private onChange = (model: EditorModel) => { private onChange = (model: EditorModel) => {
@ -432,6 +459,11 @@ export default class MessageComposer extends React.Component<IProps, IState> {
private renderButtons(menuPosition): JSX.Element | JSX.Element[] { private renderButtons(menuPosition): JSX.Element | JSX.Element[] {
const buttons: JSX.Element[] = []; const buttons: JSX.Element[] = [];
if (!this.state.haveRecording) { if (!this.state.haveRecording) {
if (SettingsStore.getValue("feature_polls")) {
buttons.push(
<PollButton key="polls" />,
);
}
buttons.push( buttons.push(
<UploadButton key="controls_upload" roomId={this.props.room.roomId} />, <UploadButton key="controls_upload" roomId={this.props.room.roomId} />,
); );
@ -460,7 +492,7 @@ export default class MessageComposer extends React.Component<IProps, IState> {
buttons.push( buttons.push(
<AccessibleTooltipButton <AccessibleTooltipButton
className="mx_MessageComposer_button mx_MessageComposer_voiceMessage" className="mx_MessageComposer_button mx_MessageComposer_voiceMessage"
onClick={() => this.voiceRecordingButton?.onRecordStartEndClick()} onClick={() => this.voiceRecordingButton.current?.onRecordStartEndClick()}
title={_t("Send voice message")} title={_t("Send voice message")}
/>, />,
); );
@ -521,7 +553,7 @@ export default class MessageComposer extends React.Component<IProps, IState> {
if (!this.state.tombstone && this.state.canSendMessages) { if (!this.state.tombstone && this.state.canSendMessages) {
controls.push( controls.push(
<SendMessageComposer <SendMessageComposer
ref={(c) => this.messageComposerInput = c} ref={this.messageComposerInput}
key="controls_input" key="controls_input"
room={this.props.room} room={this.props.room}
placeholder={this.renderPlaceholderText()} placeholder={this.renderPlaceholderText()}
@ -535,7 +567,7 @@ export default class MessageComposer extends React.Component<IProps, IState> {
controls.push(<VoiceRecordComposerTile controls.push(<VoiceRecordComposerTile
key="controls_voice_record" key="controls_voice_record"
ref={c => this.voiceRecordingButton = c} ref={this.voiceRecordingButton}
room={this.props.room} />); room={this.props.room} />);
} else if (this.state.tombstone) { } else if (this.state.tombstone) {
const replacementRoomId = this.state.tombstone.getContent()['replacement_room']; const replacementRoomId = this.state.tombstone.getContent()['replacement_room'];

View file

@ -28,15 +28,17 @@ import AccessibleButton from "../elements/AccessibleButton";
import MiniAvatarUploader, { AVATAR_SIZE } from "../elements/MiniAvatarUploader"; import MiniAvatarUploader, { AVATAR_SIZE } from "../elements/MiniAvatarUploader";
import RoomAvatar from "../avatars/RoomAvatar"; import RoomAvatar from "../avatars/RoomAvatar";
import defaultDispatcher from "../../../dispatcher/dispatcher"; import defaultDispatcher from "../../../dispatcher/dispatcher";
import dis from "../../../dispatcher/dispatcher";
import { ViewUserPayload } from "../../../dispatcher/payloads/ViewUserPayload"; import { ViewUserPayload } from "../../../dispatcher/payloads/ViewUserPayload";
import { Action } from "../../../dispatcher/actions"; import { Action } from "../../../dispatcher/actions";
import dis from "../../../dispatcher/dispatcher";
import SpaceStore from "../../../stores/SpaceStore"; import SpaceStore from "../../../stores/SpaceStore";
import { showSpaceInvite } from "../../../utils/space"; import { showSpaceInvite } from "../../../utils/space";
import { privateShouldBeEncrypted } from "../../../createRoom"; import { privateShouldBeEncrypted } from "../../../createRoom";
import EventTileBubble from "../messages/EventTileBubble"; import EventTileBubble from "../messages/EventTileBubble";
import { ROOM_SECURITY_TAB } from "../dialogs/RoomSettingsDialog"; import { ROOM_SECURITY_TAB } from "../dialogs/RoomSettingsDialog";
import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { shouldShowComponent } from "../../../customisations/helpers/UIComponents";
import { UIComponent } from "../../../settings/UIFeature";
function hasExpectedEncryptionSettings(matrixClient: MatrixClient, room: Room): boolean { function hasExpectedEncryptionSettings(matrixClient: MatrixClient, room: Room): boolean {
const isEncrypted: boolean = matrixClient.isRoomEncrypted(room.roomId); const isEncrypted: boolean = matrixClient.isRoomEncrypted(room.roomId);
@ -150,7 +152,7 @@ const NewRoomIntro = () => {
{ _t("Invite to just this room") } { _t("Invite to just this room") }
</AccessibleButton> } </AccessibleButton> }
</div>; </div>;
} else if (room.canInvite(cli.getUserId())) { } else if (room.canInvite(cli.getUserId()) && shouldShowComponent(UIComponent.InviteUsers)) {
buttons = <div className="mx_NewRoomIntro_buttons"> buttons = <div className="mx_NewRoomIntro_buttons">
<AccessibleButton <AccessibleButton
className="mx_NewRoomIntro_inviteButton" className="mx_NewRoomIntro_inviteButton"

View file

@ -49,6 +49,8 @@ import { showAddExistingRooms, showCreateNewRoom, showSpaceInvite } from "../../
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import RoomAvatar from "../avatars/RoomAvatar"; import RoomAvatar from "../avatars/RoomAvatar";
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import { shouldShowComponent } from "../../../customisations/helpers/UIComponents";
import { UIComponent } from "../../../settings/UIFeature";
import { JoinRule } from "matrix-js-sdk/src/@types/partials"; import { JoinRule } from "matrix-js-sdk/src/@types/partials";
interface IProps { interface IProps {
@ -134,6 +136,9 @@ const TAG_AESTHETICS: ITagAestheticsMap = {
MatrixClientPeg.get().getUserId()); MatrixClientPeg.get().getUserId());
return <IconizedContextMenuOptionList first> return <IconizedContextMenuOptionList first>
{
shouldShowComponent(UIComponent.CreateRooms)
? (<>
<IconizedContextMenuOption <IconizedContextMenuOption
label={_t("Create new room")} label={_t("Create new room")}
iconClassName="mx_RoomList_iconPlus" iconClassName="mx_RoomList_iconPlus"
@ -160,6 +165,9 @@ const TAG_AESTHETICS: ITagAestheticsMap = {
tooltip={canAddRooms ? undefined tooltip={canAddRooms ? undefined
: _t("You do not have permissions to add rooms to this space")} : _t("You do not have permissions to add rooms to this space")}
/> />
</>)
: null
}
<IconizedContextMenuOption <IconizedContextMenuOption
label={_t("Explore rooms")} label={_t("Explore rooms")}
iconClassName="mx_RoomList_iconBrowse" iconClassName="mx_RoomList_iconBrowse"
@ -450,8 +458,8 @@ export default class RoomList extends React.PureComponent<IProps, IState> {
} }
private renderSublists(): React.ReactElement[] { private renderSublists(): React.ReactElement[] {
// show a skeleton UI if the user is in no rooms and they are not filtering // show a skeleton UI if the user is in no rooms and they are not filtering and have no suggested rooms
const showSkeleton = !this.state.isNameFiltering && const showSkeleton = !this.state.isNameFiltering && !this.state.suggestedRooms?.length &&
Object.values(RoomListStore.instance.unfilteredLists).every(list => !list?.length); Object.values(RoomListStore.instance.unfilteredLists).every(list => !list?.length);
return TAG_ORDER.reduce((tags, tagId) => { return TAG_ORDER.reduce((tags, tagId) => {

View file

@ -35,6 +35,8 @@ import InviteReason from "../elements/InviteReason";
import { IOOBData } from "../../../stores/ThreepidInviteStore"; import { IOOBData } from "../../../stores/ThreepidInviteStore";
import Spinner from "../elements/Spinner"; import Spinner from "../elements/Spinner";
import AccessibleButton from "../elements/AccessibleButton"; import AccessibleButton from "../elements/AccessibleButton";
import { UIFeature } from "../../../settings/UIFeature";
import SettingsStore from "../../../settings/SettingsStore";
const MemberEventHtmlReasonField = "io.element.html_reason"; const MemberEventHtmlReasonField = "io.element.html_reason";
@ -339,8 +341,10 @@ export default class RoomPreviewBar extends React.Component<IProps, IState> {
} }
case MessageCase.NotLoggedIn: { case MessageCase.NotLoggedIn: {
title = _t("Join the conversation with an account"); title = _t("Join the conversation with an account");
if (SettingsStore.getValue(UIFeature.Registration)) {
primaryActionLabel = _t("Sign Up"); primaryActionLabel = _t("Sign Up");
primaryActionHandler = this.onRegisterClick; primaryActionHandler = this.onRegisterClick;
}
secondaryActionLabel = _t("Sign In"); secondaryActionLabel = _t("Sign In");
secondaryActionHandler = this.onLoginClick; secondaryActionHandler = this.onLoginClick;
if (this.props.previewLoading) { if (this.props.previewLoading) {

View file

@ -55,6 +55,8 @@ import { ListNotificationState } from "../../../stores/notifications/ListNotific
import IconizedContextMenu from "../context_menus/IconizedContextMenu"; import IconizedContextMenu from "../context_menus/IconizedContextMenu";
import { getKeyBindingsManager, RoomListAction } from "../../../KeyBindingsManager"; import { getKeyBindingsManager, RoomListAction } from "../../../KeyBindingsManager";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import { shouldShowComponent } from "../../../customisations/helpers/UIComponents";
import { UIComponent } from "../../../settings/UIFeature";
const SHOW_N_BUTTON_HEIGHT = 28; // As defined by CSS const SHOW_N_BUTTON_HEIGHT = 28; // As defined by CSS
const RESIZE_HANDLE_HEIGHT = 4; // As defined by CSS const RESIZE_HANDLE_HEIGHT = 4; // As defined by CSS
@ -675,7 +677,7 @@ export default class RoomSublist extends React.Component<IProps, IState> {
); );
let addRoomButton = null; let addRoomButton = null;
if (!!this.props.onAddRoom) { if (!!this.props.onAddRoom && shouldShowComponent(UIComponent.CreateRooms)) {
addRoomButton = ( addRoomButton = (
<AccessibleTooltipButton <AccessibleTooltipButton
tabIndex={tabIndex} tabIndex={tabIndex}
@ -687,6 +689,7 @@ export default class RoomSublist extends React.Component<IProps, IState> {
/> />
); );
} else if (this.props.addRoomContextMenu) { } else if (this.props.addRoomContextMenu) {
// We assume that shouldShowComponent() is checked by the context menu itself.
addRoomButton = ( addRoomButton = (
<ContextMenuTooltipButton <ContextMenuTooltipButton
tabIndex={tabIndex} tabIndex={tabIndex}

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