Merge branch 'develop' into regional-indicators

This commit is contained in:
Robin Townsend 2021-08-05 17:41:37 -04:00
commit 4ace2353e3
137 changed files with 5187 additions and 2038 deletions

1
.github/CODEOWNERS vendored Normal file
View file

@ -0,0 +1 @@
* @matrix-org/element-web

1
.node-version Normal file
View file

@ -0,0 +1 @@
14

View file

@ -1,3 +1,122 @@
Changes in [3.27.0](https://github.com/vector-im/element-desktop/releases/tag/v3.27.0) (2021-07-02)
===================================================================================================
## 🔒 SECURITY FIXES
* Sanitize untrusted variables from message previews before translation
Fixes vector-im/element-web#18314
## ✨ Features
* Fix editing of `<sub>` & `<sup`> & `<u>`
[\#6469](https://github.com/matrix-org/matrix-react-sdk/pull/6469)
Fixes vector-im/element-web#18211
* Zoom images in lightbox to where the cursor points
[\#6418](https://github.com/matrix-org/matrix-react-sdk/pull/6418)
Fixes vector-im/element-web#17870
* Avoid hitting the settings store from TextForEvent
[\#6205](https://github.com/matrix-org/matrix-react-sdk/pull/6205)
Fixes vector-im/element-web#17650
* Initial MSC3083 + MSC3244 support
[\#6212](https://github.com/matrix-org/matrix-react-sdk/pull/6212)
Fixes vector-im/element-web#17686 and vector-im/element-web#17661
* Navigate to the first room with notifications when clicked on space notification dot
[\#5974](https://github.com/matrix-org/matrix-react-sdk/pull/5974)
* Add matrix: to the list of permitted URL schemes
[\#6388](https://github.com/matrix-org/matrix-react-sdk/pull/6388)
* Add "Copy Link" to room context menu
[\#6374](https://github.com/matrix-org/matrix-react-sdk/pull/6374)
* 💭 Message bubble layout
[\#6291](https://github.com/matrix-org/matrix-react-sdk/pull/6291)
Fixes vector-im/element-web#4635, vector-im/element-web#17773 vector-im/element-web#16220 and vector-im/element-web#7687
* Play only one audio file at a time
[\#6417](https://github.com/matrix-org/matrix-react-sdk/pull/6417)
Fixes vector-im/element-web#17439
* Move download button for media to the action bar
[\#6386](https://github.com/matrix-org/matrix-react-sdk/pull/6386)
Fixes vector-im/element-web#17943
* Improved display of one-to-one call history with summary boxes for each call
[\#6121](https://github.com/matrix-org/matrix-react-sdk/pull/6121)
Fixes vector-im/element-web#16409
* Notification settings UI refresh
[\#6352](https://github.com/matrix-org/matrix-react-sdk/pull/6352)
Fixes vector-im/element-web#17782
* Fix EventIndex double handling events and erroring
[\#6385](https://github.com/matrix-org/matrix-react-sdk/pull/6385)
Fixes vector-im/element-web#18008
* Improve reply rendering
[\#3553](https://github.com/matrix-org/matrix-react-sdk/pull/3553)
Fixes vector-im/riot-web#9217, vector-im/riot-web#7633, vector-im/riot-web#7530, vector-im/riot-web#7169, vector-im/riot-web#7151, vector-im/riot-web#6692 vector-im/riot-web#6579 and vector-im/element-web#17440
## 🐛 Bug Fixes
* Fix CreateRoomDialog exploding when making public room outside of a space
[\#6493](https://github.com/matrix-org/matrix-react-sdk/pull/6493)
* Fix regression where registration would soft-crash on captcha
[\#6505](https://github.com/matrix-org/matrix-react-sdk/pull/6505)
Fixes vector-im/element-web#18284
* only send join rule event if we have a join rule to put in it
[\#6517](https://github.com/matrix-org/matrix-react-sdk/pull/6517)
* Improve the new download button's discoverability and interactions.
[\#6510](https://github.com/matrix-org/matrix-react-sdk/pull/6510)
* Fix voice recording UI looking broken while microphone permissions are being requested.
[\#6479](https://github.com/matrix-org/matrix-react-sdk/pull/6479)
Fixes vector-im/element-web#18223
* Match colors of room and user avatars in DMs
[\#6393](https://github.com/matrix-org/matrix-react-sdk/pull/6393)
Fixes vector-im/element-web#2449
* Fix onPaste handler to work with copying files from Finder
[\#5389](https://github.com/matrix-org/matrix-react-sdk/pull/5389)
Fixes vector-im/element-web#15536 and vector-im/element-web#16255
* Fix infinite pagination loop when offline
[\#6478](https://github.com/matrix-org/matrix-react-sdk/pull/6478)
Fixes vector-im/element-web#18242
* Fix blurhash rounded corners missing regression
[\#6467](https://github.com/matrix-org/matrix-react-sdk/pull/6467)
Fixes vector-im/element-web#18110
* Fix position of the space hierarchy spinner
[\#6462](https://github.com/matrix-org/matrix-react-sdk/pull/6462)
Fixes vector-im/element-web#18182
* Fix display of image messages that lack thumbnails
[\#6456](https://github.com/matrix-org/matrix-react-sdk/pull/6456)
Fixes vector-im/element-web#18175
* Fix crash with large audio files.
[\#6436](https://github.com/matrix-org/matrix-react-sdk/pull/6436)
Fixes vector-im/element-web#18149
* Make diff colors in codeblocks more pleasant
[\#6355](https://github.com/matrix-org/matrix-react-sdk/pull/6355)
Fixes vector-im/element-web#17939
* Show the correct audio file duration while loading the file.
[\#6435](https://github.com/matrix-org/matrix-react-sdk/pull/6435)
Fixes vector-im/element-web#18160
* Fix various timeline settings not applying immediately.
[\#6261](https://github.com/matrix-org/matrix-react-sdk/pull/6261)
Fixes vector-im/element-web#17748
* Fix issues with room list duplication
[\#6391](https://github.com/matrix-org/matrix-react-sdk/pull/6391)
Fixes vector-im/element-web#14508
* Fix grecaptcha throwing useless error sometimes
[\#6401](https://github.com/matrix-org/matrix-react-sdk/pull/6401)
Fixes vector-im/element-web#15142
* Update Emojibase and Twemoji and switch to IamCal (Slack-style) shortcodes
[\#6347](https://github.com/matrix-org/matrix-react-sdk/pull/6347)
Fixes vector-im/element-web#13857 and vector-im/element-web#13334
* Respect compound emojis in default avatar initial generation
[\#6397](https://github.com/matrix-org/matrix-react-sdk/pull/6397)
Fixes vector-im/element-web#18040
* Fix bug where the 'other homeserver' field in the server selection dialog would become briefly focus and then unfocus when clicked.
[\#6394](https://github.com/matrix-org/matrix-react-sdk/pull/6394)
Fixes vector-im/element-web#18031
* Standardise spelling and casing of homeserver, identity server, and integration manager
[\#6365](https://github.com/matrix-org/matrix-react-sdk/pull/6365)
* Fix widgets not receiving decrypted events when they have permission.
[\#6371](https://github.com/matrix-org/matrix-react-sdk/pull/6371)
Fixes vector-im/element-web#17615
* Prevent client hangs when calculating blurhashes
[\#6366](https://github.com/matrix-org/matrix-react-sdk/pull/6366)
Fixes vector-im/element-web#17945
* Exclude state events from widgets reading room events
[\#6378](https://github.com/matrix-org/matrix-react-sdk/pull/6378)
* Cache feature_spaces\* flags to improve performance
[\#6381](https://github.com/matrix-org/matrix-react-sdk/pull/6381)
Changes in [3.26.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.26.0) (2021-07-19) Changes in [3.26.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.26.0) (2021-07-19)
===================================================================================================== =====================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.26.0-rc.1...v3.26.0) [Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.26.0-rc.1...v3.26.0)

View file

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

View file

@ -1,6 +1,6 @@
{ {
"name": "matrix-react-sdk", "name": "matrix-react-sdk",
"version": "3.26.0", "version": "3.27.0",
"description": "SDK for matrix.org using React", "description": "SDK for matrix.org using React",
"author": "matrix.org", "author": "matrix.org",
"repository": { "repository": {
@ -80,13 +80,14 @@
"katex": "^0.12.0", "katex": "^0.12.0",
"linkifyjs": "^2.1.9", "linkifyjs": "^2.1.9",
"lodash": "^4.17.20", "lodash": "^4.17.20",
"matrix-js-sdk": "12.1.0", "matrix-js-sdk": "12.2.0",
"matrix-widget-api": "^0.1.0-beta.15", "matrix-widget-api": "^0.1.0-beta.15",
"minimist": "^1.2.5", "minimist": "^1.2.5",
"opus-recorder": "^8.0.3", "opus-recorder": "^8.0.3",
"pako": "^2.0.3", "pako": "^2.0.3",
"parse5": "^6.0.1", "parse5": "^6.0.1",
"png-chunks-extract": "^1.0.0", "png-chunks-extract": "^1.0.0",
"posthog-js": "1.12.2",
"prop-types": "^15.7.2", "prop-types": "^15.7.2",
"qrcode": "^1.4.4", "qrcode": "^1.4.4",
"re-resizable": "^6.9.0", "re-resizable": "^6.9.0",
@ -123,6 +124,7 @@
"@babel/traverse": "^7.12.12", "@babel/traverse": "^7.12.12",
"@matrix-org/olm": "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.3.tgz", "@matrix-org/olm": "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.3.tgz",
"@peculiar/webcrypto": "^1.1.4", "@peculiar/webcrypto": "^1.1.4",
"@sentry/types": "^6.10.0",
"@sinonjs/fake-timers": "^7.0.2", "@sinonjs/fake-timers": "^7.0.2",
"@types/classnames": "^2.2.11", "@types/classnames": "^2.2.11",
"@types/commonmark": "^0.27.4", "@types/commonmark": "^0.27.4",
@ -147,13 +149,14 @@
"@typescript-eslint/eslint-plugin": "^4.17.0", "@typescript-eslint/eslint-plugin": "^4.17.0",
"@typescript-eslint/parser": "^4.17.0", "@typescript-eslint/parser": "^4.17.0",
"@wojtekmaj/enzyme-adapter-react-17": "^0.6.1", "@wojtekmaj/enzyme-adapter-react-17": "^0.6.1",
"allchange": "github:matrix-org/allchange",
"babel-jest": "^26.6.3", "babel-jest": "^26.6.3",
"chokidar": "^3.5.1", "chokidar": "^3.5.1",
"concurrently": "^5.3.0", "concurrently": "^5.3.0",
"enzyme": "^3.11.0", "enzyme": "^3.11.0",
"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#main", "eslint-plugin-matrix-org": "github:matrix-org/eslint-plugin-matrix-org#2306b3d4da4eba908b256014b979f1d3d43d2945",
"eslint-plugin-react": "^7.22.0", "eslint-plugin-react": "^7.22.0",
"eslint-plugin-react-hooks": "^4.2.0", "eslint-plugin-react-hooks": "^4.2.0",
"glob": "^7.1.6", "glob": "^7.1.6",
@ -166,6 +169,7 @@
"matrix-web-i18n": "github:matrix-org/matrix-web-i18n", "matrix-web-i18n": "github:matrix-org/matrix-web-i18n",
"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",
"stylelint": "^13.9.0", "stylelint": "^13.9.0",
"stylelint-config-standard": "^20.0.0", "stylelint-config-standard": "^20.0.0",
"stylelint-scss": "^3.18.0", "stylelint-scss": "^3.18.0",
@ -189,7 +193,8 @@
"decoderWorker\\.min\\.js": "<rootDir>/__mocks__/empty.js", "decoderWorker\\.min\\.js": "<rootDir>/__mocks__/empty.js",
"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",
"RecorderWorklet": "<rootDir>/__mocks__/empty.js"
}, },
"transformIgnorePatterns": [ "transformIgnorePatterns": [
"/node_modules/(?!matrix-js-sdk).+$" "/node_modules/(?!matrix-js-sdk).+$"

4
release_config.yaml Normal file
View file

@ -0,0 +1,4 @@
subprojects:
matrix-js-sdk:
includeByDefault: false

View file

@ -67,7 +67,6 @@
@import "./views/dialogs/_AddExistingToSpaceDialog.scss"; @import "./views/dialogs/_AddExistingToSpaceDialog.scss";
@import "./views/dialogs/_AddressPickerDialog.scss"; @import "./views/dialogs/_AddressPickerDialog.scss";
@import "./views/dialogs/_Analytics.scss"; @import "./views/dialogs/_Analytics.scss";
@import "./views/dialogs/_BetaFeedbackDialog.scss";
@import "./views/dialogs/_BugReportDialog.scss"; @import "./views/dialogs/_BugReportDialog.scss";
@import "./views/dialogs/_ChangelogDialog.scss"; @import "./views/dialogs/_ChangelogDialog.scss";
@import "./views/dialogs/_ChatCreateOrReuseChatDialog.scss"; @import "./views/dialogs/_ChatCreateOrReuseChatDialog.scss";
@ -76,16 +75,20 @@
@import "./views/dialogs/_CreateCommunityPrototypeDialog.scss"; @import "./views/dialogs/_CreateCommunityPrototypeDialog.scss";
@import "./views/dialogs/_CreateGroupDialog.scss"; @import "./views/dialogs/_CreateGroupDialog.scss";
@import "./views/dialogs/_CreateRoomDialog.scss"; @import "./views/dialogs/_CreateRoomDialog.scss";
@import "./views/dialogs/_CreateSubspaceDialog.scss";
@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/_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/_GroupAddressPicker.scss"; @import "./views/dialogs/_GroupAddressPicker.scss";
@import "./views/dialogs/_HostSignupDialog.scss"; @import "./views/dialogs/_HostSignupDialog.scss";
@import "./views/dialogs/_IncomingSasDialog.scss"; @import "./views/dialogs/_IncomingSasDialog.scss";
@import "./views/dialogs/_InviteDialog.scss"; @import "./views/dialogs/_InviteDialog.scss";
@import "./views/dialogs/_JoinRuleDropdown.scss";
@import "./views/dialogs/_KeyboardShortcutsDialog.scss"; @import "./views/dialogs/_KeyboardShortcutsDialog.scss";
@import "./views/dialogs/_LeaveSpaceDialog.scss";
@import "./views/dialogs/_ManageRestrictedJoinRuleDialog.scss"; @import "./views/dialogs/_ManageRestrictedJoinRuleDialog.scss";
@import "./views/dialogs/_MessageEditHistoryDialog.scss"; @import "./views/dialogs/_MessageEditHistoryDialog.scss";
@import "./views/dialogs/_ModalWidgetDialog.scss"; @import "./views/dialogs/_ModalWidgetDialog.scss";
@ -263,6 +266,7 @@
@import "./views/spaces/_SpacePublicShare.scss"; @import "./views/spaces/_SpacePublicShare.scss";
@import "./views/terms/_InlineTermsAgreement.scss"; @import "./views/terms/_InlineTermsAgreement.scss";
@import "./views/toasts/_AnalyticsToast.scss"; @import "./views/toasts/_AnalyticsToast.scss";
@import "./views/toasts/_IncomingCallToast.scss";
@import "./views/toasts/_NonUrgentEchoFailureToast.scss"; @import "./views/toasts/_NonUrgentEchoFailureToast.scss";
@import "./views/verification/_VerificationShowSas.scss"; @import "./views/verification/_VerificationShowSas.scss";
@import "./views/voip/_CallContainer.scss"; @import "./views/voip/_CallContainer.scss";

View file

@ -297,7 +297,7 @@ $activeBorderColor: $secondary-fg-color;
.mx_SpaceButton:hover, .mx_SpaceButton:hover,
.mx_SpaceButton:focus-within, .mx_SpaceButton:focus-within,
.mx_SpaceButton_hasMenuOpen { .mx_SpaceButton_hasMenuOpen {
&:not(.mx_SpaceButton_home):not(.mx_SpaceButton_invite) { &:not(.mx_SpaceButton_invite) {
// Hide the badge container on hover because it'll be a menu button // Hide the badge container on hover because it'll be a menu button
.mx_SpacePanel_badgeContainer { .mx_SpacePanel_badgeContainer {
width: 0; width: 0;
@ -368,6 +368,14 @@ $activeBorderColor: $secondary-fg-color;
.mx_SpacePanel_iconExplore::before { .mx_SpacePanel_iconExplore::before {
mask-image: url('$(res)/img/element-icons/roomlist/browse.svg'); mask-image: url('$(res)/img/element-icons/roomlist/browse.svg');
} }
.mx_SpacePanel_noIcon {
display: none;
& + .mx_IconizedContextMenu_label {
padding-left: 5px !important; // override default iconized label style to align with header
}
}
} }

View file

@ -61,6 +61,7 @@ limitations under the License.
.mx_AccessibleButton_kind_link { .mx_AccessibleButton_kind_link {
padding: 0; padding: 0;
font-size: inherit;
} }
.mx_SearchBox { .mx_SearchBox {

View file

@ -335,24 +335,17 @@ $SpaceRoomViewInnerWidth: 428px;
word-wrap: break-word; word-wrap: break-word;
} }
> hr {
border: none;
height: 1px;
background-color: $groupFilterPanel-bg-color;
}
.mx_SearchBox { .mx_SearchBox {
margin: 0 0 20px; margin: 0 0 20px;
flex: 0; flex: 0;
} }
.mx_SpaceFeedbackPrompt { .mx_SpaceFeedbackPrompt {
margin-bottom: 16px; padding: 7px; // 8px - 1px border
border: 1px solid $menu-border-color;
// hide the HR as we have our own border-radius: 8px;
& + hr { width: max-content;
display: none; margin: 0 0 -40px auto; // collapse its own height to not push other components down
}
} }
.mx_SpaceRoomDirectory_list { .mx_SpaceRoomDirectory_list {
@ -513,66 +506,3 @@ $SpaceRoomViewInnerWidth: 428px;
} }
} }
} }
.mx_SpaceFeedbackPrompt {
margin-top: 18px;
margin-bottom: 12px;
> hr {
border: none;
border-top: 1px solid $input-border-color;
margin-bottom: 12px;
}
> div {
display: flex;
flex-direction: row;
font-size: $font-15px;
line-height: $font-24px;
> span {
color: $secondary-fg-color;
position: relative;
padding-left: 32px;
font-size: inherit;
line-height: inherit;
margin-right: auto;
&::before {
content: '';
position: absolute;
left: 0;
top: 2px;
height: 20px;
width: 20px;
background-color: $secondary-fg-color;
mask-repeat: no-repeat;
mask-size: contain;
mask-image: url('$(res)/img/element-icons/room/room-summary.svg');
mask-position: center;
}
}
.mx_AccessibleButton_kind_link {
color: $accent-color;
position: relative;
padding: 0 0 0 24px;
margin-left: 8px;
font-size: inherit;
line-height: inherit;
&::before {
content: '';
position: absolute;
left: 0;
height: 16px;
width: 16px;
background-color: $accent-color;
mask-repeat: no-repeat;
mask-size: contain;
mask-image: url('$(res)/img/element-icons/chat-bubbles.svg');
mask-position: center;
}
}
}
}

View file

@ -28,7 +28,7 @@ limitations under the License.
margin: 0 4px; margin: 0 4px;
grid-row: 2 / 4; grid-row: 2 / 4;
grid-column: 1; grid-column: 1;
background-color: $dark-panel-bg-color; background-color: $toast-bg-color;
box-shadow: 0px 4px 20px rgba(0, 0, 0, 0.5); box-shadow: 0px 4px 20px rgba(0, 0, 0, 0.5);
border-radius: 8px; border-radius: 8px;
} }
@ -37,7 +37,7 @@ limitations under the License.
grid-row: 1 / 3; grid-row: 1 / 3;
grid-column: 1; grid-column: 1;
color: $primary-fg-color; color: $primary-fg-color;
background-color: $dark-panel-bg-color; background-color: $toast-bg-color;
box-shadow: 0px 4px 20px rgba(0, 0, 0, 0.5); box-shadow: 0px 4px 20px rgba(0, 0, 0, 0.5);
border-radius: 8px; border-radius: 8px;
overflow: hidden; overflow: hidden;

View file

@ -99,6 +99,10 @@ limitations under the License.
.mx_IconizedContextMenu_icon + .mx_IconizedContextMenu_label { .mx_IconizedContextMenu_icon + .mx_IconizedContextMenu_label {
padding-left: 14px; padding-left: 14px;
} }
.mx_BetaCard_betaPill {
margin-left: 16px;
}
} }
} }
@ -145,12 +149,17 @@ limitations under the License.
} }
} }
.mx_IconizedContextMenu_checked { .mx_IconizedContextMenu_checked,
.mx_IconizedContextMenu_unchecked {
margin-left: 16px; margin-left: 16px;
margin-right: -5px; margin-right: -5px;
}
&::before { .mx_IconizedContextMenu_checked::before {
mask-image: url('$(res)/img/element-icons/roomlist/checkmark.svg'); mask-image: url('$(res)/img/element-icons/roomlist/checkmark.svg');
} }
.mx_IconizedContextMenu_unchecked::before {
content: unset;
} }
} }

View file

@ -50,64 +50,11 @@ limitations under the License.
line-height: $font-15px; line-height: $font-15px;
} }
.mx_AddExistingToSpace_entry { .mx_AccessibleButton_kind_link {
display: flex; font-size: $font-12px;
margin-top: 12px; line-height: $font-15px;
margin-top: 8px;
// we can't target .mx_BaseAvatar here as it'll break the decorated avatar styling padding: 0;
.mx_DecoratedRoomAvatar {
margin-right: 12px;
}
.mx_AddExistingToSpace_entry_name {
font-size: $font-15px;
line-height: 30px;
flex-grow: 1;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
margin-right: 12px;
}
.mx_Checkbox {
align-items: center;
}
}
}
.mx_AddExistingToSpace_section_spaces {
.mx_BaseAvatar {
margin-right: 12px;
}
.mx_BaseAvatar_image {
border-radius: 8px;
}
}
.mx_AddExistingToSpace_section_experimental {
position: relative;
border-radius: 8px;
margin: 12px 0;
padding: 8px 8px 8px 42px;
background-color: $header-panel-bg-color;
font-size: $font-12px;
line-height: $font-15px;
color: $secondary-fg-color;
&::before {
content: '';
position: absolute;
left: 10px;
top: calc(50% - 8px); // vertical centering
height: 16px;
width: 16px;
background-color: $secondary-fg-color;
mask-repeat: no-repeat;
mask-size: contain;
mask-image: url('$(res)/img/element-icons/room/room-summary.svg');
mask-position: center;
} }
} }
@ -205,77 +152,106 @@ limitations under the License.
min-height: 0; min-height: 0;
height: 80vh; height: 80vh;
.mx_Dialog_title {
display: flex;
.mx_BaseAvatar_image {
border-radius: 8px;
margin: 0;
vertical-align: unset;
}
.mx_BaseAvatar {
display: inline-flex;
margin: auto 16px auto 5px;
vertical-align: middle;
}
> div {
> h1 {
font-weight: $font-semi-bold;
font-size: $font-18px;
line-height: $font-22px;
margin: 0;
}
.mx_AddExistingToSpaceDialog_onlySpace {
color: $secondary-fg-color;
font-size: $font-15px;
line-height: $font-24px;
}
}
.mx_Dropdown_input {
border: none;
> .mx_Dropdown_option {
padding-left: 0;
flex: unset;
height: unset;
color: $secondary-fg-color;
font-size: $font-15px;
line-height: $font-24px;
.mx_BaseAvatar {
display: none;
}
}
.mx_Dropdown_menu {
.mx_AddExistingToSpaceDialog_dropdownOptionActive {
color: $accent-color;
padding-right: 32px;
position: relative;
&::before {
content: '';
width: 20px;
height: 20px;
top: 8px;
right: 0;
position: absolute;
mask-position: center;
mask-size: contain;
mask-repeat: no-repeat;
background-color: $accent-color;
mask-image: url('$(res)/img/element-icons/roomlist/checkmark.svg');
}
}
}
}
}
.mx_AddExistingToSpace { .mx_AddExistingToSpace {
display: contents; display: contents;
} }
} }
.mx_SubspaceSelector {
display: flex;
.mx_BaseAvatar_image {
border-radius: 8px;
margin: 0;
vertical-align: unset;
}
.mx_BaseAvatar {
display: inline-flex;
margin: auto 16px auto 5px;
vertical-align: middle;
}
> div {
> h1 {
font-weight: $font-semi-bold;
font-size: $font-18px;
line-height: $font-22px;
margin: 0;
}
}
.mx_Dropdown_input {
border: none;
> .mx_Dropdown_option {
padding-left: 0;
flex: unset;
height: unset;
color: $secondary-fg-color;
font-size: $font-15px;
line-height: $font-24px;
.mx_BaseAvatar {
display: none;
}
}
.mx_Dropdown_menu {
.mx_SubspaceSelector_dropdownOptionActive {
color: $accent-color;
padding-right: 32px;
position: relative;
&::before {
content: '';
width: 20px;
height: 20px;
top: 8px;
right: 0;
position: absolute;
mask-position: center;
mask-size: contain;
mask-repeat: no-repeat;
background-color: $accent-color;
mask-image: url('$(res)/img/element-icons/roomlist/checkmark.svg');
}
}
}
}
.mx_SubspaceSelector_onlySpace {
color: $secondary-fg-color;
font-size: $font-15px;
line-height: $font-24px;
}
}
.mx_AddExistingToSpace_entry {
display: flex;
margin-top: 12px;
.mx_DecoratedRoomAvatar, // we can't target .mx_BaseAvatar here as it'll break the decorated avatar styling
.mx_BaseAvatar.mx_RoomAvatar_isSpaceRoom {
margin-right: 12px;
}
img.mx_RoomAvatar_isSpaceRoom,
.mx_RoomAvatar_isSpaceRoom img {
border-radius: 8px;
}
.mx_AddExistingToSpace_entry_name {
font-size: $font-15px;
line-height: 30px;
flex-grow: 1;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
margin-right: 12px;
}
.mx_Checkbox {
align-items: center;
}
}

View file

@ -109,56 +109,4 @@ limitations under the License.
margin: 0 85px 0 0; margin: 0 85px 0 0;
font-size: $font-12px; font-size: $font-12px;
} }
.mx_Dropdown {
margin-bottom: 8px;
font-weight: normal;
font-family: $font-family;
font-size: $font-14px;
color: $primary-fg-color;
.mx_Dropdown_input {
border: 1px solid $input-border-color;
}
.mx_Dropdown_option {
font-size: $font-14px;
line-height: $font-32px;
height: 32px;
min-height: 32px;
> div {
padding-left: 30px;
position: relative;
&::before {
content: "";
position: absolute;
height: 16px;
width: 16px;
left: 6px;
top: 8px;
mask-repeat: no-repeat;
mask-position: center;
background-color: $secondary-fg-color;
}
}
}
.mx_CreateRoomDialog_dropdown_invite::before {
mask-image: url('$(res)/img/element-icons/lock.svg');
mask-size: contain;
}
.mx_CreateRoomDialog_dropdown_public::before {
mask-image: url('$(res)/img/globe.svg');
mask-size: 12px;
}
.mx_CreateRoomDialog_dropdown_restricted::before {
mask-image: url('$(res)/img/element-icons/community-members.svg');
mask-size: contain;
}
}
} }

View file

@ -0,0 +1,81 @@
/*
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_CreateSubspaceDialog_wrapper {
.mx_Dialog {
display: flex;
flex-direction: column;
}
}
.mx_CreateSubspaceDialog {
width: 480px;
color: $primary-fg-color;
display: flex;
flex-direction: column;
flex-wrap: nowrap;
min-height: 0;
.mx_CreateSubspaceDialog_content {
flex-grow: 1;
.mx_CreateSubspaceDialog_betaNotice {
padding: 12px 16px;
border-radius: 8px;
background-color: $header-panel-bg-color;
.mx_BetaCard_betaPill {
margin-right: 8px;
vertical-align: middle;
}
}
.mx_JoinRuleDropdown + p {
color: $muted-fg-color;
font-size: $font-12px;
}
}
.mx_CreateSubspaceDialog_footer {
display: flex;
margin-top: 20px;
.mx_CreateSubspaceDialog_footer_prompt {
flex-grow: 1;
font-size: $font-12px;
line-height: $font-15px;
color: $secondary-fg-color;
> * {
vertical-align: middle;
}
}
.mx_AccessibleButton {
display: inline-block;
align-self: center;
}
.mx_AccessibleButton_kind_primary {
margin-left: 16px;
padding: 8px 36px;
}
.mx_AccessibleButton_kind_link {
padding: 0;
}
}
}

View file

@ -14,8 +14,8 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
.mx_BetaFeedbackDialog { .mx_GenericFeatureFeedbackDialog {
.mx_BetaFeedbackDialog_subheading { .mx_GenericFeatureFeedbackDialog_subheading {
color: $primary-fg-color; color: $primary-fg-color;
font-size: $font-14px; font-size: $font-14px;
line-height: $font-20px; line-height: $font-20px;

View file

@ -0,0 +1,67 @@
/*
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_JoinRuleDropdown {
margin-bottom: 8px;
font-weight: normal;
font-family: $font-family;
font-size: $font-14px;
color: $primary-fg-color;
.mx_Dropdown_input {
border: 1px solid $input-border-color;
}
.mx_Dropdown_option {
font-size: $font-14px;
line-height: $font-32px;
height: 32px;
min-height: 32px;
> div {
padding-left: 30px;
position: relative;
&::before {
content: "";
position: absolute;
height: 16px;
width: 16px;
left: 6px;
top: 8px;
mask-repeat: no-repeat;
mask-position: center;
background-color: $secondary-fg-color;
}
}
}
.mx_JoinRuleDropdown_invite::before {
mask-image: url('$(res)/img/element-icons/lock.svg');
mask-size: contain;
}
.mx_JoinRuleDropdown_public::before {
mask-image: url('$(res)/img/globe.svg');
mask-size: 12px;
}
.mx_JoinRuleDropdown_restricted::before {
mask-image: url('$(res)/img/element-icons/community-members.svg');
mask-size: contain;
}
}

View file

@ -0,0 +1,96 @@
/*
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_LeaveSpaceDialog_wrapper {
.mx_Dialog {
display: flex;
flex-direction: column;
padding: 24px 32px;
}
}
.mx_LeaveSpaceDialog {
width: 440px;
display: flex;
flex-direction: column;
flex-wrap: nowrap;
max-height: 520px;
.mx_Dialog_content {
flex-grow: 1;
margin: 0;
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 {
position: relative;
border-radius: 8px;
margin: 12px 0 0;
padding: 12px 8px 12px 42px;
background-color: $header-panel-bg-color;
font-size: $font-12px;
line-height: $font-15px;
color: $secondary-fg-color;
&::before {
content: '';
position: absolute;
left: 10px;
top: calc(50% - 8px); // vertical centering
height: 16px;
width: 16px;
background-color: $secondary-fg-color;
mask-repeat: no-repeat;
mask-size: contain;
mask-image: url('$(res)/img/element-icons/room/room-summary.svg');
mask-position: center;
}
}
> p {
color: $primary-fg-color;
}
}
.mx_Dialog_buttons {
margin-top: 20px;
.mx_Dialog_primary {
background-color: $notice-primary-color !important; // override default colour
border-color: $notice-primary-color;
}
}
}

View file

@ -35,7 +35,6 @@ limitations under the License.
.mx_desktopCapturerSourcePicker_source_thumbnail { .mx_desktopCapturerSourcePicker_source_thumbnail {
margin: 4px; margin: 4px;
padding: 4px; padding: 4px;
width: 312px;
border-width: 2px; border-width: 2px;
border-radius: 8px; border-radius: 8px;
border-style: solid; border-style: solid;
@ -53,6 +52,5 @@ limitations under the License.
white-space: nowrap; white-space: nowrap;
text-overflow: ellipsis; text-overflow: ellipsis;
overflow: hidden; overflow: hidden;
width: 312px;
} }
} }

View file

@ -23,7 +23,7 @@ limitations under the License.
background-color: $dark-panel-bg-color; background-color: $dark-panel-bg-color;
border-radius: 8px; border-radius: 8px;
margin: 10px auto; margin: 10px auto;
max-width: 75%; width: 75%;
box-sizing: border-box; box-sizing: border-box;
height: 60px; height: 60px;

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2015, 2016, 2021 The Matrix.org Foundation C.I.C. Copyright 2015 - 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -60,6 +60,8 @@ limitations under the License.
} }
.mx_MFileBody_info { .mx_MFileBody_info {
cursor: pointer;
.mx_MFileBody_info_icon { .mx_MFileBody_info_icon {
background-color: $message-body-panel-icon-bg-color; background-color: $message-body-panel-icon-bg-color;
border-radius: 20px; border-radius: 20px;

View file

@ -43,8 +43,10 @@ limitations under the License.
margin-bottom: 7px; margin-bottom: 7px;
mask-image: url('$(res)/img/feather-customised/minimise.svg'); mask-image: url('$(res)/img/feather-customised/minimise.svg');
} }
}
&:hover .mx_ViewSourceEvent_toggle { .mx_EventTile:hover {
.mx_ViewSourceEvent_toggle {
visibility: visible; visibility: visible;
} }
} }

View file

@ -38,18 +38,22 @@ limitations under the License.
padding-top: 0; padding-top: 0;
} }
&::before {
content: '';
position: absolute;
top: -1px;
bottom: -1px;
left: -60px;
right: -60px;
z-index: -1;
border-radius: 4px;
}
&:hover, &:hover,
&.mx_EventTile_selected { &.mx_EventTile_selected {
&::before { &::before {
content: '';
position: absolute;
top: -1px;
bottom: -1px;
left: -60px;
right: -60px;
z-index: -1;
background: $eventbubble-bg-hover; background: $eventbubble-bg-hover;
border-radius: 4px;
} }
.mx_EventTile_avatar { .mx_EventTile_avatar {
@ -267,7 +271,7 @@ limitations under the License.
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: start;
padding: 5px 0; padding: 5px 0;
.mx_EventTile_avatar { .mx_EventTile_avatar {
@ -275,12 +279,27 @@ limitations under the License.
order: -1; order: -1;
margin-right: 5px; margin-right: 5px;
} }
.mx_EventTile_line,
.mx_EventTile_info {
min-width: 100%;
}
.mx_EventTile_e2eIcon {
margin-left: 9px;
}
.mx_EventTile_line > a {
right: auto;
top: -15px;
left: -68px;
}
} }
.mx_EventListSummary[data-layout=bubble] { .mx_EventListSummary[data-layout=bubble] {
--maxWidth: 80%; --maxWidth: 70%;
margin-left: calc(var(--avatarSize) + var(--gutterSize)); margin-left: calc(var(--avatarSize) + var(--gutterSize));
margin-right: calc(var(--gutterSize) + var(--avatarSize)); margin-right: 94px;
.mx_EventListSummary_toggle { .mx_EventListSummary_toggle {
float: none; float: none;
margin: 0; margin: 0;

View file

@ -59,7 +59,6 @@ $hover-select-border: 4px;
font-size: $font-14px; font-size: $font-14px;
display: inline-block; /* anti-zalgo, with overflow hidden */ display: inline-block; /* anti-zalgo, with overflow hidden */
overflow: hidden; overflow: hidden;
cursor: pointer;
padding-bottom: 0px; padding-bottom: 0px;
padding-top: 0px; padding-top: 0px;
margin: 0px; margin: 0px;
@ -132,15 +131,6 @@ $hover-select-border: 4px;
} }
} }
&.mx_EventTile_info .mx_EventTile_line,
& ~ .mx_EventListSummary > :not(.mx_EventTile) .mx_EventTile_avatar ~ .mx_EventTile_line {
padding-left: calc($left-gutter + 18px);
}
& ~ .mx_EventListSummary .mx_EventTile_line {
padding-left: calc($left-gutter);
}
&.mx_EventTile_selected.mx_EventTile_info .mx_EventTile_line { &.mx_EventTile_selected.mx_EventTile_info .mx_EventTile_line {
padding-left: calc($left-gutter + 18px - $hover-select-border); padding-left: calc($left-gutter + 18px - $hover-select-border);
} }
@ -276,10 +266,19 @@ $hover-select-border: 4px;
.mx_ReactionsRow { .mx_ReactionsRow {
margin: 0; margin: 0;
padding: 6px 60px; padding: 4px 64px;
} }
} }
.mx_EventTile:not([data-layout=bubble]).mx_EventTile_info .mx_EventTile_line,
.mx_EventListSummary:not([data-layout=bubble]) > :not(.mx_EventTile) .mx_EventTile_avatar ~ .mx_EventTile_line {
padding-left: calc($left-gutter + 18px);
}
.mx_EventListSummary:not([data-layout=bubble]) .mx_EventTile_line {
padding-left: calc($left-gutter);
}
/* all the overflow-y: hidden; are to trap Zalgos - /* all the overflow-y: hidden; are to trap Zalgos -
but they introduce an implicit overflow-x: auto. but they introduce an implicit overflow-x: auto.
so make that explicitly hidden too to avoid random so make that explicitly hidden too to avoid random
@ -311,17 +310,19 @@ $hover-select-border: 4px;
} }
.mx_RoomView_timeline_rr_enabled { .mx_RoomView_timeline_rr_enabled {
.mx_EventTile[data-layout=group] {
.mx_EventTile:not([data-layout=bubble]) {
.mx_EventTile_line { .mx_EventTile_line {
/* ideally should be 100px, but 95px gives us a max thumbnail size of 800x600, which is nice */ /* ideally should be 100px, but 95px gives us a max thumbnail size of 800x600, which is nice */
margin-right: 110px; margin-right: 110px;
} }
} }
// on ELS we need the margin to allow interaction with the expand/collapse button which is normally in the RR gutter // on ELS we need the margin to allow interaction with the expand/collapse button which is normally in the RR gutter
} }
.mx_SenderProfile {
cursor: pointer;
}
.mx_EventTile_bubbleContainer { .mx_EventTile_bubbleContainer {
display: grid; display: grid;
grid-template-columns: 1fr 100px; grid-template-columns: 1fr 100px;
@ -456,8 +457,14 @@ $hover-select-border: 4px;
/* Various markdown overrides */ /* Various markdown overrides */
.mx_EventTile_body pre { .mx_EventTile_body {
border: 1px solid transparent; a:hover {
text-decoration: underline;
}
pre {
border: 1px solid transparent;
}
} }
.mx_EventTile_content .markdown-body { .mx_EventTile_content .markdown-body {

View file

@ -46,6 +46,21 @@ limitations under the License.
mask-image: url('$(res)/img/element-icons/trashcan.svg'); mask-image: url('$(res)/img/element-icons/trashcan.svg');
} }
.mx_VoiceRecordComposerTile_uploadingState {
margin-right: 10px;
color: $secondary-fg-color;
}
.mx_VoiceRecordComposerTile_failedState {
margin-right: 21px;
.mx_VoiceRecordComposerTile_uploadState_badge {
display: inline-block;
margin-right: 4px;
vertical-align: middle;
}
}
.mx_MessageComposer_row .mx_VoiceMessagePrimaryContainer { .mx_MessageComposer_row .mx_VoiceMessagePrimaryContainer {
// Note: remaining class properties are in the PlayerContainer CSS. // Note: remaining class properties are in the PlayerContainer CSS.
@ -68,7 +83,7 @@ limitations under the License.
height: 10px; height: 10px;
position: absolute; position: absolute;
left: 12px; // 12px from the left edge for container padding left: 12px; // 12px from the left edge for container padding
top: 18px; // vertically center (middle align with clock) top: 16px; // vertically center (middle align with clock)
border-radius: 10px; border-radius: 10px;
} }
} }

View file

@ -16,6 +16,7 @@ limitations under the License.
.mx_ProfileSettings_controls_topic { .mx_ProfileSettings_controls_topic {
& > textarea { & > textarea {
font-family: inherit;
resize: vertical; resize: vertical;
} }
} }

View file

@ -72,6 +72,13 @@ limitations under the License.
padding-right: 10px; padding-right: 10px;
} }
.mx_SettingsTab_section .mx_SettingsFlag .mx_SettingsFlag_microcopy {
margin-top: 4px;
font-size: $font-12px;
line-height: $font-15px;
color: $secondary-fg-color;
}
.mx_SettingsTab_section .mx_SettingsFlag .mx_ToggleSwitch { .mx_SettingsTab_section .mx_SettingsFlag .mx_ToggleSwitch {
float: right; float: right;
} }

View file

@ -43,6 +43,12 @@ $spacePanelWidth: 71px;
color: $secondary-fg-color; color: $secondary-fg-color;
margin: 0; margin: 0;
} }
.mx_SpaceFeedbackPrompt {
border-top: 1px solid $input-border-color;
padding-top: 12px;
margin-top: 16px;
}
} }
// XXX remove this when spaces leaves Beta // XXX remove this when spaces leaves Beta
@ -99,3 +105,25 @@ $spacePanelWidth: 71px;
} }
} }
} }
.mx_SpaceFeedbackPrompt {
font-size: $font-15px;
line-height: $font-24px;
> span {
color: $secondary-fg-color;
position: relative;
font-size: inherit;
line-height: inherit;
margin-right: auto;
}
.mx_AccessibleButton_kind_link {
color: $accent-color;
position: relative;
padding: 0;
margin-left: 8px;
font-size: inherit;
line-height: inherit;
}
}

View file

@ -0,0 +1,149 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Copyright 2021 Šimon Brandner <simon.bra.ag@gmail.com>
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_IncomingCallToast {
display: flex;
flex-direction: row;
pointer-events: initial; // restore pointer events so the user can accept/decline
.mx_IncomingCallToast_content {
display: flex;
flex-direction: column;
margin-left: 8px;
.mx_CallEvent_caller {
font-weight: bold;
font-size: $font-15px;
line-height: $font-18px;
margin-top: 2px;
}
.mx_CallEvent_type {
font-size: $font-12px;
line-height: $font-15px;
color: $tertiary-fg-color;
margin-top: 4px;
margin-bottom: 6px;
display: flex;
flex-direction: row;
align-items: center;
.mx_CallEvent_type_icon {
height: 16px;
width: 16px;
margin-right: 6px;
&::before {
content: '';
position: absolute;
height: inherit;
width: inherit;
background-color: $tertiary-fg-color;
mask-repeat: no-repeat;
mask-size: contain;
}
}
}
&.mx_IncomingCallToast_content_voice {
.mx_CallEvent_type .mx_CallEvent_type_icon::before,
.mx_IncomingCallToast_buttons .mx_IncomingCallToast_button_accept span::before {
mask-image: url('$(res)/img/element-icons/call/voice-call.svg');
}
}
&.mx_IncomingCallToast_content_video {
.mx_CallEvent_type .mx_CallEvent_type_icon::before,
.mx_IncomingCallToast_buttons .mx_IncomingCallToast_button_accept span::before {
mask-image: url('$(res)/img/element-icons/call/video-call.svg');
}
}
.mx_IncomingCallToast_buttons {
margin-top: 8px;
display: flex;
flex-direction: row;
gap: 12px;
.mx_IncomingCallToast_button {
height: 24px;
padding: 0px 8px;
flex-shrink: 0;
flex-grow: 1;
margin-right: 0;
font-size: $font-15px;
line-height: $font-24px;
span {
padding: 8px 0;
display: flex;
align-items: center;
&::before {
content: '';
display: inline-block;
background-color: $button-fg-color;
mask-position: center;
mask-repeat: no-repeat;
margin-right: 8px;
}
}
&.mx_IncomingCallToast_button_accept span::before {
mask-size: 13px;
width: 13px;
height: 13px;
}
&.mx_IncomingCallToast_button_decline span::before {
mask-image: url('$(res)/img/element-icons/call/hangup.svg');
mask-size: 16px;
width: 16px;
height: 16px;
}
}
}
}
.mx_IncomingCallToast_iconButton {
display: flex;
height: 20px;
width: 20px;
&::before {
content: '';
height: inherit;
width: inherit;
background-color: $tertiary-fg-color;
mask-repeat: no-repeat;
mask-size: contain;
mask-position: center;
}
}
.mx_IncomingCallToast_silence::before {
mask-image: url('$(res)/img/voip/silence.svg');
}
.mx_IncomingCallToast_unSilence::before {
mask-image: url('$(res)/img/voip/un-silence.svg');
}
}

View file

@ -28,7 +28,6 @@ limitations under the License.
.mx_CallPreview { .mx_CallPreview {
pointer-events: initial; // restore pointer events so the user can leave/interact pointer-events: initial; // restore pointer events so the user can leave/interact
cursor: pointer;
.mx_VideoFeed_remote.mx_VideoFeed_voice { .mx_VideoFeed_remote.mx_VideoFeed_voice {
min-height: 150px; min-height: 150px;
@ -43,84 +42,4 @@ limitations under the License.
.mx_AppTile_persistedWrapper div { .mx_AppTile_persistedWrapper div {
min-width: 350px; min-width: 350px;
} }
.mx_IncomingCallBox {
min-width: 250px;
background-color: $voipcall-plinth-color;
padding: 8px;
box-shadow: 0px 14px 24px rgba(0, 0, 0, 0.08);
border-radius: 8px;
pointer-events: initial; // restore pointer events so the user can accept/decline
cursor: pointer;
.mx_IncomingCallBox_CallerInfo {
display: flex;
direction: row;
img, .mx_BaseAvatar_initial {
margin: 8px;
}
> div {
display: flex;
flex-direction: column;
justify-content: center;
}
h1, p {
margin: 0px;
padding: 0px;
font-size: $font-14px;
line-height: $font-16px;
}
h1 {
font-weight: bold;
}
}
.mx_IncomingCallBox_buttons {
padding: 8px;
display: flex;
flex-direction: row;
> .mx_IncomingCallBox_spacer {
width: 8px;
}
> * {
flex-shrink: 0;
flex-grow: 1;
margin-right: 0;
font-size: $font-15px;
line-height: $font-24px;
}
}
.mx_IncomingCallBox_iconButton {
position: absolute;
right: 8px;
&::before {
content: '';
height: 20px;
width: 20px;
background-color: $icon-button-color;
mask-repeat: no-repeat;
mask-size: contain;
mask-position: center;
}
}
.mx_IncomingCallBox_silence::before {
mask-image: url('$(res)/img/voip/silence.svg');
}
.mx_IncomingCallBox_unSilence::before {
mask-image: url('$(res)/img/voip/un-silence.svg');
}
}
} }

View file

@ -39,7 +39,7 @@ limitations under the License.
.mx_CallView_pip { .mx_CallView_pip {
width: 320px; width: 320px;
padding-bottom: 8px; padding-bottom: 8px;
background-color: $voipcall-plinth-color; background-color: $toast-bg-color;
box-shadow: 0px 4px 20px rgba(0, 0, 0, 0.20); box-shadow: 0px 4px 20px rgba(0, 0, 0, 0.20);
border-radius: 8px; border-radius: 8px;
@ -76,16 +76,22 @@ limitations under the License.
&.mx_VideoFeed_voice { &.mx_VideoFeed_voice {
// We don't want to collide with the call controls that have 52px of height // We don't want to collide with the call controls that have 52px of height
padding-bottom: 52px; margin-bottom: 52px;
background-color: $inverted-bg-color; background-color: $inverted-bg-color;
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
} }
&.mx_VideoFeed_video { .mx_VideoFeed_video {
height: 100%;
background-color: #000; background-color: #000;
} }
.mx_VideoFeed_mic {
left: 10px;
bottom: 10px;
}
} }
} }
@ -202,6 +208,7 @@ limitations under the License.
align-items: center; align-items: center;
justify-content: left; justify-content: left;
flex-shrink: 0; flex-shrink: 0;
cursor: pointer;
} }
.mx_CallView_header_callType { .mx_CallView_header_callType {
@ -279,7 +286,7 @@ limitations under the License.
max-width: 240px; max-width: 240px;
} }
.mx_CallView_header_phoneIcon { .mx_CallView_header_callTypeIcon {
display: inline-block; display: inline-block;
margin-right: 6px; margin-right: 6px;
height: 16px; height: 16px;
@ -293,12 +300,19 @@ limitations under the License.
height: 16px; height: 16px;
width: 16px; width: 16px;
background-color: $warning-color; background-color: $secondary-fg-color;
mask-repeat: no-repeat; mask-repeat: no-repeat;
mask-size: contain; mask-size: contain;
mask-position: center; mask-position: center;
}
&.mx_CallView_header_callTypeIcon_voice::before {
mask-image: url('$(res)/img/element-icons/call/voice-call.svg'); mask-image: url('$(res)/img/element-icons/call/voice-call.svg');
} }
&.mx_CallView_header_callTypeIcon_video::before {
mask-image: url('$(res)/img/element-icons/call/video-call.svg');
}
} }
.mx_CallView_callControls { .mx_CallView_callControls {
@ -306,7 +320,6 @@ limitations under the License.
display: flex; display: flex;
justify-content: center; justify-content: center;
bottom: 5px; bottom: 5px;
width: 100%;
opacity: 1; opacity: 1;
transition: opacity 0.5s; transition: opacity 0.5s;
z-index: 200; // To be above _all_ feeds z-index: 200; // To be above _all_ feeds

View file

@ -35,12 +35,23 @@ limitations under the License.
width: 100%; width: 100%;
&.mx_VideoFeed_voice { &.mx_VideoFeed_voice {
border-radius: 4px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
aspect-ratio: 16 / 9; aspect-ratio: 16 / 9;
} }
.mx_VideoFeed_video {
border-radius: 4px;
}
.mx_VideoFeed_mic {
left: 6px;
bottom: 6px;
}
} }
&.mx_CallViewSidebar_pipMode { &.mx_CallViewSidebar_pipMode {

View file

@ -69,7 +69,6 @@ limitations under the License.
overflow: hidden; overflow: hidden;
max-width: 185px; max-width: 185px;
text-align: left; text-align: left;
direction: rtl;
padding: 8px 0px; padding: 8px 0px;
background-color: rgb(0, 0, 0, 0); background-color: rgb(0, 0, 0, 0);
} }

View file

@ -15,18 +15,52 @@ limitations under the License.
*/ */
.mx_VideoFeed { .mx_VideoFeed {
border-radius: 4px; overflow: hidden;
position: relative;
&.mx_VideoFeed_voice { &.mx_VideoFeed_voice {
background-color: $inverted-bg-color; background-color: $inverted-bg-color;
} }
&.mx_VideoFeed_video { .mx_VideoFeed_video {
width: 100%;
background-color: transparent; background-color: transparent;
&.mx_VideoFeed_video_mirror {
transform: scale(-1, 1);
}
}
.mx_VideoFeed_mic {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
background-color: rgba(0, 0, 0, 0.5); // Same on both themes
border-radius: 100%;
&::before {
position: absolute;
content: "";
width: 16px;
height: 16px;
mask-repeat: no-repeat;
mask-size: contain;
mask-position: center;
background-color: white; // Same on both themes
border-radius: 7px;
}
&.mx_VideoFeed_mic_muted::before {
mask-image: url('$(res)/img/voip/mic-muted.svg');
}
&.mx_VideoFeed_mic_unmuted::before {
mask-image: url('$(res)/img/voip/mic-unmuted.svg');
}
} }
} }
.mx_VideoFeed_mirror {
transform: scale(-1, 1);
}

View file

@ -1,7 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18.5151 20.0831L15.6941 17.2621L17.2621 15.6941L20.0831 18.5151C21.5741 20.0061 22.1529 21.7793 21.9661 21.9661C21.7793 22.1529 20.0061 21.5741 18.5151 20.0831Z" fill="#737D8C"/> <path d="m11.068 2c-0.32021 4.772e-4 -0.66852 0.17244-0.96484 0.46875-2.5464 2.5435-5.0905 5.0892-7.6348 7.6348-0.79016 0.7902-0.69302 1.9462 1.1641 1.9707 1.855 0.02447 3.4407-0.56671 3.8281-0.69141l2.4355 3.1445c-0.83503 1.9462-0.86902 4.062-0.058594 5.7949 0.47213 1.0095 1.79 1.0049 2.5781 0.2168l3.2773-3.2773 2.8223 2.8223c1.491 1.491 3.2644 2.0696 3.4512 1.8828s-0.39181-1.9602-1.8828-3.4512l-2.8223-2.8223 3.2773-3.2773c0.788-0.788 0.79075-2.106-0.21875-2.5781-1.733-0.81044-3.8468-0.77643-5.793 0.058594l-3.1445-2.4355c0.1247-0.38742 0.71588-1.9731 0.69141-3.8281-0.015311-1.1607-0.47217-1.6336-1.0059-1.6328z" fill="#737d8c"/>
<path d="M7.46196 11.3821C7.07677 11.5059 5.49073 12.0989 3.63366 12.0744C1.77658 12.0499 1.67795 10.8941 2.46811 10.1039L6.28598 6.28602L9.42196 9.42203L7.46196 11.3821Z" fill="#737D8C"/>
<path d="M11.3821 7.46202C11.5059 7.07682 12.0989 5.49077 12.0744 3.63368C12.0499 1.77658 10.8941 1.67795 10.1039 2.46812L6.28598 6.28602L9.42196 9.42203L11.3821 7.46202Z" fill="#737D8C"/>
<path d="M7.40596 11.438L11.4379 7.40602L14.9099 10.206L10.2059 14.9101L7.40596 11.438Z" fill="#737D8C"/>
<path d="M11.774 11.774C9.31114 14.2369 8.61779 17.7115 9.83827 20.3213C10.3104 21.3308 11.6288 21.3273 12.4169 20.5392L20.5391 12.4169C21.3271 11.6289 21.3307 10.3104 20.3212 9.83829C17.7114 8.61779 14.2369 9.31115 11.774 11.774Z" fill="#737D8C"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 1 KiB

After

Width:  |  Height:  |  Size: 744 B

View file

@ -0,0 +1,5 @@
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1.9206 1.0544C1.68141 0.815201 1.29359 0.815201 1.0544 1.0544C0.815201 1.29359 0.815201 1.68141 1.0544 1.9206L4.55 5.41621V7C4.55 8.3531 5.6469 9.45 7 9.45C7.45436 9.45 7.87983 9.32632 8.24458 9.11079L9.12938 9.99558C8.52863 10.4234 7.7937 10.675 7 10.675C4.97035 10.675 3.325 9.02965 3.325 7C3.325 6.66173 3.05077 6.3875 2.7125 6.3875C2.37423 6.3875 2.1 6.66173 2.1 7C2.1 9.49877 3.97038 11.5607 6.3875 11.8621V12.5125C6.3875 12.8508 6.66173 13.125 7 13.125C7.33827 13.125 7.6125 12.8508 7.6125 12.5125V11.8621C8.50718 11.7505 9.32696 11.3978 10.0047 10.8709L12.0794 12.9456C12.3186 13.1848 12.7064 13.1848 12.9456 12.9456C13.1848 12.7064 13.1848 12.3186 12.9456 12.0794L1.9206 1.0544Z" fill="white"/>
<path d="M10.5474 7.96338L11.5073 8.92525C11.7601 8.33424 11.9 7.68346 11.9 7C11.9 6.66173 11.6258 6.3875 11.2875 6.3875C10.9492 6.3875 10.675 6.66173 10.675 7C10.675 7.33336 10.6306 7.65634 10.5474 7.96338Z" fill="white"/>
<path d="M4.81385 2.21784L9.45 6.86366V3.325C9.45 1.9719 8.3531 0.875 7 0.875C6.04532 0.875 5.21818 1.42104 4.81385 2.21784Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -0,0 +1,4 @@
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.4645 3.29384C4.4645 1.95795 5.59973 0.875 7.0001 0.875C8.40048 0.875 9.53571 1.95795 9.53571 3.29384V6.91127C9.53571 8.24716 8.40048 9.33011 7.0001 9.33011C5.59973 9.33011 4.4645 8.24716 4.4645 6.91127V3.29384Z" fill="white"/>
<path d="M2.56269 6.1391C3.01153 6.1391 3.37539 6.4862 3.37539 6.91437C3.37539 8.81701 4.99198 10.3617 6.99032 10.3666C6.99359 10.3666 6.99686 10.3666 7.00014 10.3666C7.0034 10.3666 7.00665 10.3666 7.0099 10.3666C9.00814 10.3616 10.6246 8.81694 10.6246 6.91437C10.6246 6.4862 10.9885 6.1391 11.4373 6.1391C11.8861 6.1391 12.25 6.4862 12.25 6.91437C12.25 9.41469 10.3257 11.4854 7.81283 11.8576V12.3497C7.81283 12.7779 7.44898 13.125 7.00014 13.125C6.5513 13.125 6.18744 12.7779 6.18744 12.3497V11.8576C3.67448 11.4855 1.75 9.41478 1.75 6.91437C1.75 6.4862 2.11386 6.1391 2.56269 6.1391Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 945 B

View file

@ -1,3 +1,6 @@
// Colors from Figma Compound https://www.figma.com/file/X4XTH9iS2KGJ2wFKDqkyed/Compound?node-id=557%3A0
$system-dark: #21262C;
// unified palette // unified palette
// try to use these colors when possible // try to use these colors when possible
$bg-color: #15191E; $bg-color: #15191E;
@ -47,7 +50,7 @@ $inverted-bg-color: $base-color;
$selected-color: $room-highlight-color; $selected-color: $room-highlight-color;
// selected for hoverover & selected event tiles // selected for hoverover & selected event tiles
$event-selected-color: #21262c; $event-selected-color: $system-dark;
// used for the hairline dividers in RoomView // used for the hairline dividers in RoomView
$primary-hairline-color: transparent; $primary-hairline-color: transparent;
@ -91,7 +94,7 @@ $lightbox-background-bg-color: #000;
$lightbox-background-bg-opacity: 0.85; $lightbox-background-bg-opacity: 0.85;
$settings-grey-fg-color: #a2a2a2; $settings-grey-fg-color: #a2a2a2;
$settings-profile-placeholder-bg-color: #21262c; $settings-profile-placeholder-bg-color: $system-dark;
$settings-profile-overlay-placeholder-fg-color: #454545; $settings-profile-overlay-placeholder-fg-color: #454545;
$settings-profile-button-bg-color: #e7e7e7; $settings-profile-button-bg-color: #e7e7e7;
$settings-profile-button-fg-color: $settings-profile-overlay-placeholder-fg-color; $settings-profile-button-fg-color: $settings-profile-overlay-placeholder-fg-color;
@ -112,8 +115,8 @@ $eventtile-meta-color: $roomtopic-color;
$header-divider-color: $header-panel-text-primary-color; $header-divider-color: $header-panel-text-primary-color;
$composer-e2e-icon-color: $header-panel-text-primary-color; $composer-e2e-icon-color: $header-panel-text-primary-color;
// this probably shouldn't have it's own colour $quinary-content-color: #394049;
$voipcall-plinth-color: #394049; $toast-bg-color: $quinary-content-color;
// ******************** // ********************
@ -175,7 +178,7 @@ $button-link-bg-color: transparent;
$togglesw-off-color: $room-highlight-color; $togglesw-off-color: $room-highlight-color;
$progressbar-fg-color: $accent-color; $progressbar-fg-color: $accent-color;
$progressbar-bg-color: #21262c; $progressbar-bg-color: $system-dark;
$visual-bell-bg-color: #800; $visual-bell-bg-color: #800;
@ -210,7 +213,7 @@ $user-tile-hover-bg-color: $header-panel-bg-color;
$message-body-panel-fg-color: $secondary-fg-color; $message-body-panel-fg-color: $secondary-fg-color;
$message-body-panel-bg-color: #394049; // "Dark Tile" $message-body-panel-bg-color: #394049; // "Dark Tile"
$message-body-panel-icon-fg-color: $secondary-fg-color; $message-body-panel-icon-fg-color: $secondary-fg-color;
$message-body-panel-icon-bg-color: #21262C; // "System Dark" $message-body-panel-icon-bg-color: $system-dark; // "System Dark"
$voice-record-stop-border-color: $quaternary-fg-color; $voice-record-stop-border-color: $quaternary-fg-color;
$voice-record-waveform-incomplete-fg-color: $quaternary-fg-color; $voice-record-waveform-incomplete-fg-color: $quaternary-fg-color;
@ -228,9 +231,9 @@ $groupFilterPanel-background-blur-amount: 30px;
$composer-shadow-color: rgba(0, 0, 0, 0.28); $composer-shadow-color: rgba(0, 0, 0, 0.28);
// Bubble tiles // Bubble tiles
$eventbubble-self-bg: #143A34; $eventbubble-self-bg: #14322E;
$eventbubble-others-bg: #394049; $eventbubble-others-bg: $event-selected-color;
$eventbubble-bg-hover: #433C23; $eventbubble-bg-hover: #1C2026;
$eventbubble-avatar-outline: $bg-color; $eventbubble-avatar-outline: $bg-color;
$eventbubble-reply-color: #C1C6CD; $eventbubble-reply-color: #C1C6CD;

View file

@ -111,8 +111,8 @@ $eventtile-meta-color: $roomtopic-color;
$header-divider-color: $header-panel-text-primary-color; $header-divider-color: $header-panel-text-primary-color;
$composer-e2e-icon-color: $header-panel-text-primary-color; $composer-e2e-icon-color: $header-panel-text-primary-color;
// this probably shouldn't have it's own colour $quinary-content-color: #394049;
$voipcall-plinth-color: #394049; $toast-bg-color: $quinary-content-color;
// ******************** // ********************

View file

@ -8,9 +8,12 @@
/* Noto Color Emoji contains digits, in fixed-width, therefore causing /* Noto Color Emoji contains digits, in fixed-width, therefore causing
digits in flowed text to stand out. digits in flowed text to stand out.
TODO: Consider putting all emoji fonts to the end rather than the front. */ TODO: Consider putting all emoji fonts to the end rather than the front. */
$font-family: Nunito, Twemoji, 'Apple Color Emoji', 'Segoe UI Emoji', Arial, Helvetica, Sans-Serif, 'Noto Color Emoji'; $font-family: 'Nunito', 'Twemoji', 'Apple Color Emoji', 'Segoe UI Emoji', 'Arial', 'Helvetica', sans-serif, 'Noto Color Emoji';
$monospace-font-family: Inconsolata, Twemoji, 'Apple Color Emoji', 'Segoe UI Emoji', 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-light: #F4F6FA;
// unified palette // unified palette
// try to use these colors when possible // try to use these colors when possible
@ -178,8 +181,8 @@ $eventtile-meta-color: $roomtopic-color;
$composer-e2e-icon-color: #91a1c0; $composer-e2e-icon-color: #91a1c0;
$header-divider-color: #91a1c0; $header-divider-color: #91a1c0;
// this probably shouldn't have it's own colour $toast-bg-color: $system-light;
$voipcall-plinth-color: #F4F6FA; $voipcall-plinth-color: $system-light;
// ******************** // ********************
@ -331,7 +334,7 @@ $user-tile-hover-bg-color: $header-panel-bg-color;
$message-body-panel-fg-color: $secondary-fg-color; $message-body-panel-fg-color: $secondary-fg-color;
$message-body-panel-bg-color: #E3E8F0; $message-body-panel-bg-color: #E3E8F0;
$message-body-panel-icon-fg-color: $secondary-fg-color; $message-body-panel-icon-fg-color: $secondary-fg-color;
$message-body-panel-icon-bg-color: #F4F6FA; $message-body-panel-icon-bg-color: $system-light;
// 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;
@ -348,9 +351,9 @@ $appearance-tab-border-color: $input-darker-bg-color;
$composer-shadow-color: tranparent; $composer-shadow-color: tranparent;
// Bubble tiles // Bubble tiles
$eventbubble-self-bg: #F8FDFC; $eventbubble-self-bg: #F0FBF8;
$eventbubble-others-bg: #F7F8F9; $eventbubble-others-bg: $system-light;
$eventbubble-bg-hover: rgb(242, 242, 242); $eventbubble-bg-hover: #FAFBFD;
$eventbubble-avatar-outline: #fff; $eventbubble-avatar-outline: #fff;
$eventbubble-reply-color: #C1C6CD; $eventbubble-reply-color: #C1C6CD;
@ -390,7 +393,7 @@ $eventbubble-reply-color: #C1C6CD;
@define-mixin mx_DialogButton_secondary { @define-mixin mx_DialogButton_secondary {
// flip colours for the secondary ones // flip colours for the secondary ones
font-weight: 600; font-weight: 600;
border: 1px solid $accent-color ! important; border: 1px solid $accent-color !important;
color: $accent-color; color: $accent-color;
background-color: $button-secondary-bg-color; background-color: $button-secondary-bg-color;
} }

View file

@ -8,9 +8,12 @@
/* Noto Color Emoji contains digits, in fixed-width, therefore causing /* Noto Color Emoji contains digits, in fixed-width, therefore causing
digits in flowed text to stand out. digits in flowed text to stand out.
TODO: Consider putting all emoji fonts to the end rather than the front. */ TODO: Consider putting all emoji fonts to the end rather than the front. */
$font-family: Inter, Twemoji, 'Apple Color Emoji', 'Segoe UI Emoji', Arial, Helvetica, Sans-Serif, 'Noto Color Emoji'; $font-family: 'Inter', 'Twemoji', 'Apple Color Emoji', 'Segoe UI Emoji', 'Arial', 'Helvetica', sans-serif, 'Noto Color Emoji';
$monospace-font-family: Inconsolata, Twemoji, 'Apple Color Emoji', 'Segoe UI Emoji', 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-light: #F4F6FA;
// unified palette // unified palette
// try to use these colors when possible // try to use these colors when possible
@ -138,7 +141,7 @@ $blockquote-bar-color: #ddd;
$blockquote-fg-color: #777; $blockquote-fg-color: #777;
$settings-grey-fg-color: #a2a2a2; $settings-grey-fg-color: #a2a2a2;
$settings-profile-placeholder-bg-color: #f4f6fa; $settings-profile-placeholder-bg-color: $system-light;
$settings-profile-overlay-placeholder-fg-color: #2e2f32; $settings-profile-overlay-placeholder-fg-color: #2e2f32;
$settings-profile-button-bg-color: #e7e7e7; $settings-profile-button-bg-color: #e7e7e7;
$settings-profile-button-fg-color: $settings-profile-overlay-placeholder-fg-color; $settings-profile-button-fg-color: $settings-profile-overlay-placeholder-fg-color;
@ -167,8 +170,8 @@ $eventtile-meta-color: $roomtopic-color;
$composer-e2e-icon-color: #91A1C0; $composer-e2e-icon-color: #91A1C0;
$header-divider-color: #91A1C0; $header-divider-color: #91A1C0;
// this probably shouldn't have it's own colour $toast-bg-color: $system-light;
$voipcall-plinth-color: #F4F6FA; $voipcall-plinth-color: $system-light;
// ******************** // ********************
@ -327,7 +330,7 @@ $user-tile-hover-bg-color: $header-panel-bg-color;
$message-body-panel-fg-color: $secondary-fg-color; $message-body-panel-fg-color: $secondary-fg-color;
$message-body-panel-bg-color: #E3E8F0; // "Separator" $message-body-panel-bg-color: #E3E8F0; // "Separator"
$message-body-panel-icon-fg-color: $secondary-fg-color; $message-body-panel-icon-fg-color: $secondary-fg-color;
$message-body-panel-icon-bg-color: #F4F6FA; $message-body-panel-icon-bg-color: $system-light;
// 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.
@ -350,9 +353,9 @@ $groupFilterPanel-background-blur-amount: 20px;
$composer-shadow-color: rgba(0, 0, 0, 0.04); $composer-shadow-color: rgba(0, 0, 0, 0.04);
// Bubble tiles // Bubble tiles
$eventbubble-self-bg: #F8FDFC; $eventbubble-self-bg: #F0FBF8;
$eventbubble-others-bg: #F7F8F9; $eventbubble-others-bg: $system-light;
$eventbubble-bg-hover: #FEFCF5; $eventbubble-bg-hover: #FAFBFD;
$eventbubble-avatar-outline: $primary-bg-color; $eventbubble-avatar-outline: $primary-bg-color;
$eventbubble-reply-color: #C1C6CD; $eventbubble-reply-color: #C1C6CD;
@ -392,7 +395,7 @@ $eventbubble-reply-color: #C1C6CD;
@define-mixin mx_DialogButton_secondary { @define-mixin mx_DialogButton_secondary {
// flip colours for the secondary ones // flip colours for the secondary ones
font-weight: 600; font-weight: 600;
border: 1px solid $accent-color ! important; border: 1px solid $accent-color !important;
color: $accent-color; color: $accent-color;
background-color: $button-secondary-bg-color; background-color: $button-secondary-bg-color;
} }

View file

@ -1,7 +1,8 @@
/* /*
Copyright 2015, 2016 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017, 2018 New Vector Ltd Copyright 2017, 2018 New Vector Ltd
Copyright 2019, 2020 The Matrix.org Foundation C.I.C. Copyright 2019 - 2021 The Matrix.org Foundation C.I.C.
Copyright 2021 Šimon Brandner <simon.bra.ag@gmail.com>
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.
@ -60,7 +61,6 @@ import Modal from './Modal';
import { _t } from './languageHandler'; import { _t } from './languageHandler';
import dis from './dispatcher/dispatcher'; import dis from './dispatcher/dispatcher';
import WidgetUtils from './utils/WidgetUtils'; import WidgetUtils from './utils/WidgetUtils';
import WidgetEchoStore from './stores/WidgetEchoStore';
import SettingsStore from './settings/SettingsStore'; import SettingsStore from './settings/SettingsStore';
import { Jitsi } from "./widgets/Jitsi"; import { Jitsi } from "./widgets/Jitsi";
import { WidgetType } from "./widgets/WidgetType"; import { WidgetType } from "./widgets/WidgetType";
@ -86,6 +86,12 @@ import { randomUppercaseString, randomLowercaseString } from "matrix-js-sdk/src/
import EventEmitter from 'events'; import EventEmitter from 'events';
import SdkConfig from './SdkConfig'; import SdkConfig from './SdkConfig';
import { ensureDMExists, findDMForUser } from './createRoom'; import { ensureDMExists, findDMForUser } from './createRoom';
import { IPushRule, RuleId, TweakName, Tweaks } from "matrix-js-sdk/src/@types/PushRules";
import { PushProcessor } from 'matrix-js-sdk/src/pushprocessor';
import { WidgetLayoutStore, Container } from './stores/widgets/WidgetLayoutStore';
import { getIncomingCallToastKey } from './toasts/IncomingCallToast';
import ToastStore from './stores/ToastStore';
import IncomingCallToast from "./toasts/IncomingCallToast";
export const PROTOCOL_PSTN = 'm.protocol.pstn'; export const PROTOCOL_PSTN = 'm.protocol.pstn';
export const PROTOCOL_PSTN_PREFIXED = 'im.vector.protocol.pstn'; export const PROTOCOL_PSTN_PREFIXED = 'im.vector.protocol.pstn';
@ -476,14 +482,28 @@ export default class CallHandler extends EventEmitter {
} }
switch (newState) { switch (newState) {
case CallState.Ringing: case CallState.Ringing: {
this.play(AudioID.Ring); const incomingCallPushRule = (
new PushProcessor(MatrixClientPeg.get()).getPushRuleById(RuleId.IncomingCall) as IPushRule
);
const pushRuleEnabled = incomingCallPushRule?.enabled;
const tweakSetToRing = incomingCallPushRule?.actions.some((action: Tweaks) => (
action.set_tweak === TweakName.Sound &&
action.value === "ring"
));
if (pushRuleEnabled && tweakSetToRing) {
this.play(AudioID.Ring);
} else {
this.silenceCall(call.callId);
}
break; break;
case CallState.InviteSent: }
case CallState.InviteSent: {
this.play(AudioID.Ringback); this.play(AudioID.Ringback);
break; break;
case CallState.Ended: }
{ case CallState.Ended: {
const hangupReason = call.hangupReason; const hangupReason = call.hangupReason;
Analytics.trackEvent('voip', 'callEnded', 'hangupReason', hangupReason); Analytics.trackEvent('voip', 'callEnded', 'hangupReason', hangupReason);
this.removeCallForRoom(mappedRoomId); this.removeCallForRoom(mappedRoomId);
@ -624,6 +644,19 @@ export default class CallHandler extends EventEmitter {
`Call state in ${mappedRoomId} changed to ${status}`, `Call state in ${mappedRoomId} changed to ${status}`,
); );
const toastKey = getIncomingCallToastKey(call.callId);
if (status === CallState.Ringing) {
ToastStore.sharedInstance().addOrReplaceToast({
key: toastKey,
priority: 100,
component: IncomingCallToast,
bodyClassName: "mx_IncomingCallToast",
props: { call },
});
} else {
ToastStore.sharedInstance().dismissToast(toastKey);
}
dis.dispatch({ dis.dispatch({
action: 'call_state', action: 'call_state',
room_id: mappedRoomId, room_id: mappedRoomId,
@ -914,6 +947,8 @@ export default class CallHandler extends EventEmitter {
action: 'view_room', action: 'view_room',
room_id: roomId, room_id: roomId,
}); });
await this.placeCall(roomId, PlaceCallType.Voice, null);
} }
private async startTransferToPhoneNumber(call: MatrixCall, destination: string, consultFirst: boolean) { private async startTransferToPhoneNumber(call: MatrixCall, destination: string, consultFirst: boolean) {
@ -993,14 +1028,10 @@ export default class CallHandler extends EventEmitter {
// prevent double clicking the call button // prevent double clicking the call button
const room = MatrixClientPeg.get().getRoom(roomId); const room = MatrixClientPeg.get().getRoom(roomId);
const currentJitsiWidgets = WidgetUtils.getRoomWidgetsOfType(room, WidgetType.JITSI); const jitsiWidget = WidgetStore.instance.getApps(roomId).find((app) => WidgetType.JITSI.matches(app.type));
const hasJitsi = currentJitsiWidgets.length > 0 if (jitsiWidget) {
|| WidgetEchoStore.roomHasPendingWidgetsOfType(roomId, currentJitsiWidgets, WidgetType.JITSI); // If there already is a Jitsi widget pin it
if (hasJitsi) { WidgetLayoutStore.instance.moveToContainer(room, jitsiWidget, Container.Top);
Modal.createTrackedDialog('Call already in progress', '', ErrorDialog, {
title: _t('Call in Progress'),
description: _t('A call is currently being placed!'),
});
return; return;
} }

View file

@ -209,6 +209,14 @@ async function loadImageElement(imageFile: File) {
return { width, height, img }; return { width, height, img };
} }
// Minimum size for image files before we generate a thumbnail for them.
const IMAGE_SIZE_THRESHOLD_THUMBNAIL = 1 << 15; // 32KB
// Minimum size improvement for image thumbnails, if both are not met then don't bother uploading thumbnail.
const IMAGE_THUMBNAIL_MIN_REDUCTION_SIZE = 1 << 16; // 1MB
const IMAGE_THUMBNAIL_MIN_REDUCTION_PERCENT = 0.1; // 10%
// We don't apply these thresholds to video thumbnails as a poster image is always useful
// and videos tend to be much larger.
/** /**
* Read the metadata for an image file and create and upload a thumbnail of the image. * Read the metadata for an image file and create and upload a thumbnail of the image.
* *
@ -217,23 +225,33 @@ async function loadImageElement(imageFile: File) {
* @param {File} imageFile The image to read and thumbnail. * @param {File} imageFile The image to read and thumbnail.
* @return {Promise} A promise that resolves with the attachment info. * @return {Promise} A promise that resolves with the attachment info.
*/ */
function infoForImageFile(matrixClient, roomId, imageFile) { async function infoForImageFile(matrixClient: MatrixClient, roomId: string, imageFile: File) {
let thumbnailType = "image/png"; let thumbnailType = "image/png";
if (imageFile.type === "image/jpeg") { if (imageFile.type === "image/jpeg") {
thumbnailType = "image/jpeg"; thumbnailType = "image/jpeg";
} }
let imageInfo; const imageElement = await loadImageElement(imageFile);
return loadImageElement(imageFile).then((r) => {
return createThumbnail(r.img, r.width, r.height, thumbnailType); const result = await createThumbnail(imageElement.img, imageElement.width, imageElement.height, thumbnailType);
}).then((result) => { const imageInfo = result.info;
imageInfo = result.info;
return uploadFile(matrixClient, roomId, result.thumbnail); // we do all sizing checks here because we still rely on thumbnail generation for making a blurhash from.
}).then((result) => { const sizeDifference = imageFile.size - imageInfo.thumbnail_info.size;
imageInfo.thumbnail_url = result.url; if (
imageInfo.thumbnail_file = result.file; imageFile.size <= IMAGE_SIZE_THRESHOLD_THUMBNAIL || // image is small enough already
(sizeDifference <= IMAGE_THUMBNAIL_MIN_REDUCTION_SIZE && // thumbnail is not sufficiently smaller than original
sizeDifference <= (imageFile.size * IMAGE_THUMBNAIL_MIN_REDUCTION_PERCENT))
) {
delete imageInfo["thumbnail_info"];
return imageInfo; return imageInfo;
}); }
const uploadResult = await uploadFile(matrixClient, roomId, result.thumbnail);
imageInfo["thumbnail_url"] = uploadResult.url;
imageInfo["thumbnail_file"] = uploadResult.file;
return imageInfo;
} }
/** /**

View file

@ -57,7 +57,33 @@ const BIGEMOJI_REGEX = new RegExp(`^(${EMOJIBASE_REGEX.source})+$`, 'i');
const COLOR_REGEX = /^#[0-9a-fA-F]{6}$/; const COLOR_REGEX = /^#[0-9a-fA-F]{6}$/;
export const PERMITTED_URL_SCHEMES = ['http', 'https', 'ftp', 'mailto', 'magnet', 'matrix']; export const PERMITTED_URL_SCHEMES = [
"bitcoin",
"ftp",
"geo",
"http",
"https",
"im",
"irc",
"ircs",
"magnet",
"mailto",
"matrix",
"mms",
"news",
"nntp",
"openpgp4fpr",
"sip",
"sftp",
"sms",
"smsto",
"ssh",
"tel",
"urn",
"webcal",
"wtai",
"xmpp",
];
const MEDIA_API_MXC_REGEX = /\/_matrix\/media\/r0\/(?:download|thumbnail)\/(.+?)\/(.+?)(?:[?/]|$)/; const MEDIA_API_MXC_REGEX = /\/_matrix\/media\/r0\/(?:download|thumbnail)\/(.+?)\/(.+?)(?:[?/]|$)/;

View file

@ -146,23 +146,23 @@ export default class IdentityAuthClient {
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
const { finished } = Modal.createTrackedDialog('Default identity server terms warning', '', const { finished } = Modal.createTrackedDialog('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: (
<div> <div>
<p>{ _t( <p>{ _t(
"This action requires accessing the default identity server " + "This action requires accessing the default identity server " +
"<server /> to validate an email address or phone number, " + "<server /> to validate an email address or phone number, " +
"but the server does not have any terms of service.", {}, "but the server does not have any terms of service.", {},
{ {
server: () => <b>{ abbreviateUrl(identityServerUrl) }</b>, server: () => <b>{ abbreviateUrl(identityServerUrl) }</b>,
}, },
) }</p> ) }</p>
<p>{ _t( <p>{ _t(
"Only continue if you trust the owner of the server.", "Only continue if you trust the owner of the server.",
) }</p> ) }</p>
</div> </div>
), ),
button: _t("Trust"), button: _t("Trust"),
}); });
const [confirmed] = await finished; const [confirmed] = await finished;
if (confirmed) { if (confirmed) {

View file

@ -48,6 +48,7 @@ import { Jitsi } from "./widgets/Jitsi";
import { SSO_HOMESERVER_URL_KEY, SSO_ID_SERVER_URL_KEY, SSO_IDP_ID_KEY } from "./BasePlatform"; import { SSO_HOMESERVER_URL_KEY, SSO_ID_SERVER_URL_KEY, SSO_IDP_ID_KEY } from "./BasePlatform";
import ThreepidInviteStore from "./stores/ThreepidInviteStore"; import ThreepidInviteStore from "./stores/ThreepidInviteStore";
import CountlyAnalytics from "./CountlyAnalytics"; import CountlyAnalytics from "./CountlyAnalytics";
import { PosthogAnalytics } from "./PosthogAnalytics";
import CallHandler from './CallHandler'; import CallHandler from './CallHandler';
import LifecycleCustomisations from "./customisations/Lifecycle"; import LifecycleCustomisations from "./customisations/Lifecycle";
import ErrorDialog from "./components/views/dialogs/ErrorDialog"; import ErrorDialog from "./components/views/dialogs/ErrorDialog";
@ -573,6 +574,8 @@ async function doSetLoggedIn(
await abortLogin(); await abortLogin();
} }
PosthogAnalytics.instance.updateAnonymityFromSettings(credentials.userId);
Analytics.setLoggedIn(credentials.guest, credentials.homeserverUrl); Analytics.setLoggedIn(credentials.guest, credentials.homeserverUrl);
MatrixClientPeg.replaceUsingCreds(credentials); MatrixClientPeg.replaceUsingCreds(credentials);
@ -700,6 +703,8 @@ export function logout(): void {
CountlyAnalytics.instance.enable(/* anonymous = */ true); CountlyAnalytics.instance.enable(/* anonymous = */ true);
} }
PosthogAnalytics.instance.logout();
if (MatrixClientPeg.get().isGuest()) { if (MatrixClientPeg.get().isGuest()) {
// logout doesn't work for guest sessions // logout doesn't work for guest sessions
// Also we sometimes want to re-log in a guest session if we abort the login. // Also we sometimes want to re-log in a guest session if we abort the login.

355
src/PosthogAnalytics.ts Normal file
View file

@ -0,0 +1,355 @@
/*
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 posthog, { PostHog } from 'posthog-js';
import PlatformPeg from './PlatformPeg';
import SdkConfig from './SdkConfig';
import SettingsStore from './settings/SettingsStore';
/* Posthog analytics tracking.
*
* Anonymity behaviour is as follows:
*
* - If Posthog isn't configured in `config.json`, events are not sent.
* - If [Do Not Track](https://developer.mozilla.org/en-US/docs/Web/API/Navigator/doNotTrack) is
* enabled, events are not sent (this detection is built into posthog and turned on via the
* `respect_dnt` flag being passed to `posthog.init`).
* - If the `feature_pseudonymous_analytics_opt_in` labs flag is `true`, track pseudonomously, i.e.
* hash all matrix identifiers in tracking events (user IDs, room IDs etc) using SHA-256.
* - Otherwise, if the existing `analyticsOptIn` flag is `true`, track anonymously, i.e.
* redact all matrix identifiers in tracking events.
* - If both flags are false or not set, events are not sent.
*/
interface IEvent {
// The event name that will be used by PostHog. Event names should use snake_case.
eventName: string;
// The properties of the event that will be stored in PostHog. This is just a placeholder,
// extending interfaces must override this with a concrete definition to do type validation.
properties: {};
}
export enum Anonymity {
Disabled,
Anonymous,
Pseudonymous
}
// If an event extends IPseudonymousEvent, the event contains pseudonymous data
// that won't be sent unless the user has explicitly consented to pseudonymous tracking.
// For example, it might contain hashed user IDs or room IDs.
// Such events will be automatically dropped if PosthogAnalytics.anonymity isn't set to Pseudonymous.
export interface IPseudonymousEvent extends IEvent {}
// If an event extends IAnonymousEvent, the event strictly contains *only* anonymous data;
// i.e. no identifiers that can be associated with the user.
export interface IAnonymousEvent extends IEvent {}
export interface IRoomEvent extends IPseudonymousEvent {
hashedRoomId: string;
}
interface IPageView extends IAnonymousEvent {
eventName: "$pageview";
properties: {
durationMs?: number;
screen?: string;
};
}
const hashHex = async (input: string): Promise<string> => {
const buf = new TextEncoder().encode(input);
const digestBuf = await window.crypto.subtle.digest("sha-256", buf);
return [...new Uint8Array(digestBuf)].map((b: number) => b.toString(16).padStart(2, "0")).join("");
};
const whitelistedScreens = new Set([
"register", "login", "forgot_password", "soft_logout", "new", "settings", "welcome", "home", "start", "directory",
"start_sso", "start_cas", "groups", "complete_security", "post_registration", "room", "user", "group",
]);
export async function getRedactedCurrentLocation(
origin: string,
hash: string,
pathname: string,
anonymity: Anonymity,
): Promise<string> {
// Redact PII from the current location.
// If anonymous is true, redact entirely, if false, substitute it with a hash.
// For known screens, assumes a URL structure of /<screen name>/might/be/pii
if (origin.startsWith('file://')) {
pathname = "/<redacted_file_scheme_url>/";
}
let hashStr;
if (hash == "") {
hashStr = "";
} else {
let [beforeFirstSlash, screen, ...parts] = hash.split("/");
if (!whitelistedScreens.has(screen)) {
screen = "<redacted_screen_name>";
}
for (let i = 0; i < parts.length; i++) {
parts[i] = anonymity === Anonymity.Anonymous ? `<redacted>` : await hashHex(parts[i]);
}
hashStr = `${beforeFirstSlash}/${screen}/${parts.join("/")}`;
}
return origin + pathname + hashStr;
}
interface PlatformProperties {
appVersion: string;
appPlatform: string;
}
export class PosthogAnalytics {
/* Wrapper for Posthog analytics.
* 3 modes of anonymity are supported, governed by this.anonymity
* - Anonymity.Disabled means *no data* is passed to posthog
* - Anonymity.Anonymous means all identifers will be redacted before being passed to posthog
* - Anonymity.Pseudonymous means all identifiers will be hashed via SHA-256 before being passed
* to Posthog
*
* To update anonymity, call updateAnonymityFromSettings() or you can set it directly via setAnonymity().
*
* To pass an event to Posthog:
*
* 1. Declare a type for the event, extending IAnonymousEvent, IPseudonymousEvent or IRoomEvent.
* 2. Call the appropriate track*() method. Pseudonymous events will be dropped when anonymity is
* Anonymous or Disabled; Anonymous events will be dropped when anonymity is Disabled.
*/
private anonymity = Anonymity.Disabled;
// set true during the constructor if posthog config is present, otherwise false
private enabled = false;
private static _instance = null;
private platformSuperProperties = {};
public static get instance(): PosthogAnalytics {
if (!this._instance) {
this._instance = new PosthogAnalytics(posthog);
}
return this._instance;
}
constructor(private readonly posthog: PostHog) {
const posthogConfig = SdkConfig.get()["posthog"];
if (posthogConfig) {
this.posthog.init(posthogConfig.projectApiKey, {
api_host: posthogConfig.apiHost,
autocapture: false,
mask_all_text: true,
mask_all_element_attributes: true,
// This only triggers on page load, which for our SPA isn't particularly useful.
// Plus, the .capture call originating from somewhere in posthog makes it hard
// to redact URLs, which requires async code.
//
// To raise this manually, just call .capture("$pageview") or posthog.capture_pageview.
capture_pageview: false,
sanitize_properties: this.sanitizeProperties,
respect_dnt: true,
});
this.enabled = true;
} else {
this.enabled = false;
}
}
private sanitizeProperties = (properties: posthog.Properties): posthog.Properties => {
// Callback from posthog to sanitize properties before sending them to the server.
//
// Here we sanitize posthog's built in properties which leak PII e.g. url reporting.
// See utils.js _.info.properties in posthog-js.
// Replace the $current_url with a redacted version.
// $redacted_current_url is injected by this class earlier in capture(), as its generation
// is async and can't be done in this non-async callback.
if (!properties['$redacted_current_url']) {
console.log("$redacted_current_url not set in sanitizeProperties, will drop $current_url entirely");
}
properties['$current_url'] = properties['$redacted_current_url'];
delete properties['$redacted_current_url'];
if (this.anonymity == Anonymity.Anonymous) {
// drop referrer information for anonymous users
properties['$referrer'] = null;
properties['$referring_domain'] = null;
properties['$initial_referrer'] = null;
properties['$initial_referring_domain'] = null;
// drop device ID, which is a UUID persisted in local storage
properties['$device_id'] = null;
}
return properties;
};
private static getAnonymityFromSettings(): Anonymity {
// determine the current anonymity level based on current user settings
// "Send anonymous usage data which helps us improve Element. This will use a cookie."
const analyticsOptIn = SettingsStore.getValue("analyticsOptIn", null, true);
// (proposed wording) "Send pseudonymous usage data which helps us improve Element. This will use a cookie."
//
// TODO: Currently, this is only a labs flag, for testing purposes.
const pseudonumousOptIn = SettingsStore.getValue("feature_pseudonymous_analytics_opt_in", null, true);
let anonymity;
if (pseudonumousOptIn) {
anonymity = Anonymity.Pseudonymous;
} else if (analyticsOptIn) {
anonymity = Anonymity.Anonymous;
} else {
anonymity = Anonymity.Disabled;
}
return anonymity;
}
private registerSuperProperties(properties: posthog.Properties) {
if (this.enabled) {
this.posthog.register(properties);
}
}
private static async getPlatformProperties(): Promise<PlatformProperties> {
const platform = PlatformPeg.get();
let appVersion;
try {
appVersion = await platform.getAppVersion();
} catch (e) {
// this happens if no version is set i.e. in dev
appVersion = "unknown";
}
return {
appVersion,
appPlatform: platform.getHumanReadableName(),
};
}
private async capture(eventName: string, properties: posthog.Properties) {
if (!this.enabled) {
return;
}
const { origin, hash, pathname } = window.location;
properties['$redacted_current_url'] = await getRedactedCurrentLocation(
origin, hash, pathname, this.anonymity);
this.posthog.capture(eventName, properties);
}
public isEnabled(): boolean {
return this.enabled;
}
public setAnonymity(anonymity: Anonymity): void {
// Update this.anonymity.
// This is public for testing purposes, typically you want to call updateAnonymityFromSettings
// to ensure this value is in step with the user's settings.
if (this.enabled && (anonymity == Anonymity.Disabled || anonymity == Anonymity.Anonymous)) {
// when transitioning to Disabled or Anonymous ensure we clear out any prior state
// set in posthog e.g. distinct ID
this.posthog.reset();
// Restore any previously set platform super properties
this.registerSuperProperties(this.platformSuperProperties);
}
this.anonymity = anonymity;
}
public async identifyUser(userId: string): Promise<void> {
if (this.anonymity == Anonymity.Pseudonymous) {
this.posthog.identify(await hashHex(userId));
}
}
public getAnonymity(): Anonymity {
return this.anonymity;
}
public logout(): void {
if (this.enabled) {
this.posthog.reset();
}
this.setAnonymity(Anonymity.Anonymous);
}
public async trackPseudonymousEvent<E extends IPseudonymousEvent>(
eventName: E["eventName"],
properties: E["properties"] = {},
) {
if (this.anonymity == Anonymity.Anonymous || this.anonymity == Anonymity.Disabled) return;
await this.capture(eventName, properties);
}
public async trackAnonymousEvent<E extends IAnonymousEvent>(
eventName: E["eventName"],
properties: E["properties"] = {},
): Promise<void> {
if (this.anonymity == Anonymity.Disabled) return;
await this.capture(eventName, properties);
}
public async trackRoomEvent<E extends IRoomEvent>(
eventName: E["eventName"],
roomId: string,
properties: Omit<E["properties"], "roomId">,
): Promise<void> {
const updatedProperties = {
...properties,
hashedRoomId: roomId ? await hashHex(roomId) : null,
};
await this.trackPseudonymousEvent(eventName, updatedProperties);
}
public async trackPageView(durationMs: number): Promise<void> {
const hash = window.location.hash;
let screen = null;
const split = hash.split("/");
if (split.length >= 2) {
screen = split[1];
}
await this.trackAnonymousEvent<IPageView>("$pageview", {
durationMs,
screen,
});
}
public async updatePlatformSuperProperties(): Promise<void> {
// Update super properties in posthog with our platform (app version, platform).
// These properties will be subsequently passed in every event.
//
// This only needs to be done once per page lifetime. Note that getPlatformProperties
// is async and can involve a network request if we are running in a browser.
this.platformSuperProperties = await PosthogAnalytics.getPlatformProperties();
this.registerSuperProperties(this.platformSuperProperties);
}
public async updateAnonymityFromSettings(userId?: string): Promise<void> {
// Update this.anonymity based on the user's analytics opt-in settings
// Identify the user (via hashed user ID) to posthog if anonymity is pseudonmyous
this.setAnonymity(PosthogAnalytics.getAnonymityFromSettings());
if (userId && this.getAnonymity() == Anonymity.Pseudonymous) {
await this.identifyUser(userId);
}
}
}

View file

@ -25,11 +25,44 @@ import { Action } from './dispatcher/actions';
import defaultDispatcher from './dispatcher/dispatcher'; import defaultDispatcher from './dispatcher/dispatcher';
import { SetRightPanelPhasePayload } from './dispatcher/payloads/SetRightPanelPhasePayload'; import { SetRightPanelPhasePayload } from './dispatcher/payloads/SetRightPanelPhasePayload';
import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { MatrixClientPeg } from "./MatrixClientPeg";
// These functions are frequently used just to check whether an event has // These functions are frequently used just to check whether an event has
// any text to display at all. For this reason they return deferred values // any text to display at all. For this reason they return deferred values
// to avoid the expense of looking up translations when they're not needed. // to avoid the expense of looking up translations when they're not needed.
function textForCallInviteEvent(event: MatrixEvent): () => string | null {
const getSenderName = () => event.sender ? event.sender.name : _t('Someone');
// FIXME: Find a better way to determine this from the event?
let isVoice = true;
if (event.getContent().offer && event.getContent().offer.sdp &&
event.getContent().offer.sdp.indexOf('m=video') !== -1) {
isVoice = false;
}
const isSupported = MatrixClientPeg.get().supportsVoip();
// This ladder could be reduced down to a couple string variables, however other languages
// can have a hard time translating those strings. In an effort to make translations easier
// and more accurate, we break out the string-based variables to a couple booleans.
if (isVoice && isSupported) {
return () => _t("%(senderName)s placed a voice call.", {
senderName: getSenderName(),
});
} else if (isVoice && !isSupported) {
return () => _t("%(senderName)s placed a voice call. (not supported by this browser)", {
senderName: getSenderName(),
});
} else if (!isVoice && isSupported) {
return () => _t("%(senderName)s placed a video call.", {
senderName: getSenderName(),
});
} else if (!isVoice && !isSupported) {
return () => _t("%(senderName)s placed a video call. (not supported by this browser)", {
senderName: getSenderName(),
});
}
}
function textForMemberEvent(ev: MatrixEvent, allowJSX: boolean, showHiddenEvents?: boolean): () => string | null { function textForMemberEvent(ev: MatrixEvent, allowJSX: boolean, showHiddenEvents?: boolean): () => string | null {
// XXX: SYJS-16 "sender is sometimes null for join messages" // XXX: SYJS-16 "sender is sometimes null for join messages"
const senderName = ev.sender ? ev.sender.name : ev.getSender(); const senderName = ev.sender ? ev.sender.name : ev.getSender();
@ -567,6 +600,7 @@ interface IHandlers {
const handlers: IHandlers = { const handlers: IHandlers = {
'm.room.message': textForMessageEvent, 'm.room.message': textForMessageEvent,
'm.call.invite': textForCallInviteEvent,
}; };
const stateHandlers: IHandlers = { const stateHandlers: IHandlers = {

View file

@ -163,7 +163,7 @@ const shortcuts: Record<Categories, IShortcut[]> = {
modifiers: [Modifiers.SHIFT], modifiers: [Modifiers.SHIFT],
key: Key.PAGE_UP, key: Key.PAGE_UP,
}], }],
description: _td("Jump to oldest unread message"), description: _td("Jump to oldest unread message"),
}, { }, {
keybinds: [{ keybinds: [{
modifiers: [CMD_OR_CTRL, Modifiers.SHIFT], modifiers: [CMD_OR_CTRL, Modifiers.SHIFT],

View file

@ -38,17 +38,9 @@ function makePlaybackWaveform(input: number[]): number[] {
// First, convert negative amplitudes to positive so we don't detect zero as "noisy". // First, convert negative amplitudes to positive so we don't detect zero as "noisy".
const noiseWaveform = input.map(v => Math.abs(v)); const noiseWaveform = input.map(v => Math.abs(v));
// Next, we'll resample the waveform using a smoothing approach so we can keep the same rough shape. // Then, we'll resample the waveform using a smoothing approach so we can keep the same rough shape.
// We also rescale the waveform to be 0-1 for the remaining function logic. // We also rescale the waveform to be 0-1 so we end up with a clamped waveform to rely upon.
const resampled = arrayRescale(arraySmoothingResample(noiseWaveform, PLAYBACK_WAVEFORM_SAMPLES), 0, 1); return arrayRescale(arraySmoothingResample(noiseWaveform, PLAYBACK_WAVEFORM_SAMPLES), 0, 1);
// Then, we'll do a high and low pass filter to isolate actual speaking volumes within the rescaled
// waveform. Most speech happens below the 0.5 mark.
const filtered = resampled.map(v => clamp(v, 0.1, 0.5));
// Finally, we'll rescale the filtered waveform (0.1-0.5 becomes 0-1 again) so the user sees something
// sensible. This is what we return to keep our contract of "values between zero and one".
return arrayRescale(filtered, 0, 1);
} }
export class Playback extends EventEmitter implements IDestroyable { export class Playback extends EventEmitter implements IDestroyable {

View file

@ -30,6 +30,7 @@ import { IEncryptedFile } from "matrix-js-sdk/src/@types/event";
import { uploadFile } from "../ContentMessages"; import { uploadFile } from "../ContentMessages";
import { FixedRollingArray } from "../utils/FixedRollingArray"; import { FixedRollingArray } from "../utils/FixedRollingArray";
import { clamp } from "../utils/numbers"; import { clamp } from "../utils/numbers";
import mxRecorderWorkletPath from "./RecorderWorklet";
const CHANNELS = 1; // stereo isn't important const CHANNELS = 1; // stereo isn't important
export const SAMPLE_RATE = 48000; // 48khz is what WebRTC uses. 12khz is where we lose quality. export const SAMPLE_RATE = 48000; // 48khz is what WebRTC uses. 12khz is where we lose quality.
@ -113,16 +114,10 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
}); });
this.recorderSource = this.recorderContext.createMediaStreamSource(this.recorderStream); this.recorderSource = this.recorderContext.createMediaStreamSource(this.recorderStream);
// Set up our worklet. We use this for timing information and waveform analysis: the
// web audio API prefers this be done async to avoid holding the main thread with math.
const mxRecorderWorkletPath = document.body.dataset.vectorRecorderWorkletScript;
if (!mxRecorderWorkletPath) {
// noinspection ExceptionCaughtLocallyJS
throw new Error("Unable to create recorder: no worklet script registered");
}
// Connect our inputs and outputs // Connect our inputs and outputs
if (this.recorderContext.audioWorklet) { if (this.recorderContext.audioWorklet) {
// Set up our worklet. We use this for timing information and waveform analysis: the
// web audio API prefers this be done async to avoid holding the main thread with math.
await this.recorderContext.audioWorklet.addModule(mxRecorderWorkletPath); await this.recorderContext.audioWorklet.addModule(mxRecorderWorkletPath);
this.recorderWorklet = new AudioWorkletNode(this.recorderContext, WORKLET_NAME); this.recorderWorklet = new AudioWorkletNode(this.recorderContext, WORKLET_NAME);
this.recorderSource.connect(this.recorderWorklet); this.recorderSource.connect(this.recorderWorklet);

View file

@ -16,7 +16,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React, { CSSProperties, RefObject, 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";
@ -80,6 +80,10 @@ export interface IProps extends IPosition {
managed?: boolean; managed?: boolean;
wrapperClassName?: string; wrapperClassName?: string;
// If true, this context menu will be mounted as a child to the parent container. Otherwise
// it will be mounted to a container at the root of the DOM.
mountAsChild?: boolean;
// Function to be called on menu close // Function to be called on menu close
onFinished(); onFinished();
// on resize callback // on resize callback
@ -390,7 +394,13 @@ export class ContextMenu extends React.PureComponent<IProps, IState> {
} }
render(): React.ReactChild { render(): React.ReactChild {
return ReactDOM.createPortal(this.renderMenu(), getOrCreateContainer()); if (this.props.mountAsChild) {
// Render as a child of the current parent
return this.renderMenu();
} else {
// Render as a child of a container at the root of the DOM
return ReactDOM.createPortal(this.renderMenu(), getOrCreateContainer());
}
} }
} }
@ -461,10 +471,14 @@ type ContextMenuTuple<T> = [boolean, RefObject<T>, () => void, () => void, (val:
export const useContextMenu = <T extends any = HTMLElement>(): ContextMenuTuple<T> => { export const useContextMenu = <T extends any = HTMLElement>(): ContextMenuTuple<T> => {
const button = useRef<T>(null); const button = useRef<T>(null);
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const open = () => { const open = (ev?: SyntheticEvent) => {
ev?.preventDefault();
ev?.stopPropagation();
setIsOpen(true); setIsOpen(true);
}; };
const close = () => { const close = (ev?: SyntheticEvent) => {
ev?.preventDefault();
ev?.stopPropagation();
setIsOpen(false); setIsOpen(false);
}; };

View file

@ -107,6 +107,7 @@ import UIStore, { UI_EVENTS } from "../../stores/UIStore";
import SoftLogout from './auth/SoftLogout'; import SoftLogout from './auth/SoftLogout';
import { makeRoomPermalink } from "../../utils/permalinks/Permalinks"; import { makeRoomPermalink } from "../../utils/permalinks/Permalinks";
import { copyPlaintext } from "../../utils/strings"; import { copyPlaintext } from "../../utils/strings";
import { PosthogAnalytics } from '../../PosthogAnalytics';
/** constants for MatrixChat.state.view */ /** constants for MatrixChat.state.view */
export enum Views { export enum Views {
@ -387,6 +388,10 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
if (SettingsStore.getValue("analyticsOptIn")) { if (SettingsStore.getValue("analyticsOptIn")) {
Analytics.enable(); Analytics.enable();
} }
PosthogAnalytics.instance.updateAnonymityFromSettings();
PosthogAnalytics.instance.updatePlatformSuperProperties();
CountlyAnalytics.instance.enable(/* anonymous = */ true); CountlyAnalytics.instance.enable(/* anonymous = */ true);
} }
@ -443,6 +448,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
const durationMs = this.stopPageChangeTimer(); const durationMs = this.stopPageChangeTimer();
Analytics.trackPageChange(durationMs); Analytics.trackPageChange(durationMs);
CountlyAnalytics.instance.trackPageChange(durationMs); CountlyAnalytics.instance.trackPageChange(durationMs);
PosthogAnalytics.instance.trackPageView(durationMs);
} }
if (this.focusComposer) { if (this.focusComposer) {
dis.fire(Action.FocusSendMessageComposer); dis.fire(Action.FocusSendMessageComposer);

View file

@ -16,7 +16,6 @@ limitations under the License.
import React, { ReactNode, useMemo, useState } from "react"; import React, { ReactNode, useMemo, useState } from "react";
import { Room } from "matrix-js-sdk/src/models/room"; import { Room } from "matrix-js-sdk/src/models/room";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { EventType, RoomType } from "matrix-js-sdk/src/@types/event"; import { EventType, RoomType } from "matrix-js-sdk/src/@types/event";
import { ISpaceSummaryRoom, ISpaceSummaryEvent } from "matrix-js-sdk/src/@types/spaces"; import { ISpaceSummaryRoom, ISpaceSummaryEvent } from "matrix-js-sdk/src/@types/spaces";
import classNames from "classnames"; import classNames from "classnames";
@ -44,11 +43,13 @@ import { getChildOrder } from "../../stores/SpaceStore";
import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton"; import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton";
import { linkifyElement } from "../../HtmlUtils"; import { linkifyElement } from "../../HtmlUtils";
import { getDisplayAliasForAliasSet } from "../../Rooms"; import { getDisplayAliasForAliasSet } from "../../Rooms";
import { useDispatcher } from "../../hooks/useDispatcher";
import defaultDispatcher from "../../dispatcher/dispatcher";
import { Action } from "../../dispatcher/actions";
interface IHierarchyProps { interface IHierarchyProps {
space: Room; space: Room;
initialText?: string; initialText?: string;
refreshToken?: any;
additionalButtons?: ReactNode; additionalButtons?: ReactNode;
showRoom(room: ISpaceSummaryRoom, viaServers?: string[], autoJoin?: boolean): void; showRoom(room: ISpaceSummaryRoom, viaServers?: string[], autoJoin?: boolean): void;
} }
@ -315,18 +316,25 @@ export const HierarchyLevel = ({
</React.Fragment>; </React.Fragment>;
}; };
// mutate argument refreshToken to force a reload export const useSpaceSummary = (space: Room): [
export const useSpaceSummary = (cli: MatrixClient, space: Room, refreshToken?: any): [
null, null,
ISpaceSummaryRoom[], ISpaceSummaryRoom[],
Map<string, Map<string, ISpaceSummaryEvent>>?, Map<string, Map<string, ISpaceSummaryEvent>>?,
Map<string, Set<string>>?, Map<string, Set<string>>?,
Map<string, Set<string>>?, Map<string, Set<string>>?,
] | [Error] => { ] | [Error] => {
// crude temporary refresh token approach until we have pagination and rework the data flow here
const [refreshToken, setRefreshToken] = useState(0);
useDispatcher(defaultDispatcher, (payload => {
if (payload.action === Action.UpdateSpaceHierarchy) {
setRefreshToken(t => t + 1);
}
}));
// TODO pagination // TODO pagination
return useAsyncMemo(async () => { return useAsyncMemo(async () => {
try { try {
const data = await cli.getSpaceSummary(space.roomId); const data = await space.client.getSpaceSummary(space.roomId);
const parentChildRelations = new EnhancedMap<string, Map<string, ISpaceSummaryEvent>>(); const parentChildRelations = new EnhancedMap<string, Map<string, ISpaceSummaryEvent>>();
const childParentRelations = new EnhancedMap<string, Set<string>>(); const childParentRelations = new EnhancedMap<string, Set<string>>();
@ -354,7 +362,6 @@ export const SpaceHierarchy: React.FC<IHierarchyProps> = ({
space, space,
initialText = "", initialText = "",
showRoom, showRoom,
refreshToken,
additionalButtons, additionalButtons,
children, children,
}) => { }) => {
@ -364,7 +371,7 @@ export const SpaceHierarchy: React.FC<IHierarchyProps> = ({
const [selected, setSelected] = useState(new Map<string, Set<string>>()); // Map<parentId, Set<childId>> const [selected, setSelected] = useState(new Map<string, Set<string>>()); // Map<parentId, Set<childId>>
const [summaryError, rooms, parentChildMap, viaMap, childParentMap] = useSpaceSummary(cli, space, refreshToken); const [summaryError, rooms, parentChildMap, viaMap, childParentMap] = useSpaceSummary(space);
const roomsMap = useMemo(() => { const roomsMap = useMemo(() => {
if (!rooms) return null; if (!rooms) return null;

View file

@ -47,13 +47,23 @@ import { RightPanelPhases } from "../../stores/RightPanelStorePhases";
import { SetRightPanelPhasePayload } from "../../dispatcher/payloads/SetRightPanelPhasePayload"; import { SetRightPanelPhasePayload } from "../../dispatcher/payloads/SetRightPanelPhasePayload";
import { useStateArray } from "../../hooks/useStateArray"; import { useStateArray } from "../../hooks/useStateArray";
import SpacePublicShare from "../views/spaces/SpacePublicShare"; import SpacePublicShare from "../views/spaces/SpacePublicShare";
import { shouldShowSpaceSettings, showAddExistingRooms, showCreateNewRoom, showSpaceSettings } from "../../utils/space"; import {
shouldShowSpaceSettings,
showAddExistingRooms,
showCreateNewRoom,
showCreateNewSubspace,
showSpaceSettings,
} from "../../utils/space";
import { showRoom, SpaceHierarchy } from "./SpaceRoomDirectory"; import { showRoom, SpaceHierarchy } from "./SpaceRoomDirectory";
import MemberAvatar from "../views/avatars/MemberAvatar"; import MemberAvatar from "../views/avatars/MemberAvatar";
import { useStateToggle } from "../../hooks/useStateToggle";
import SpaceStore from "../../stores/SpaceStore"; import SpaceStore from "../../stores/SpaceStore";
import FacePile from "../views/elements/FacePile"; import FacePile from "../views/elements/FacePile";
import { AddExistingToSpace } from "../views/dialogs/AddExistingToSpaceDialog"; import {
AddExistingToSpace,
defaultDmsRenderer,
defaultRoomsRenderer,
defaultSpacesRenderer,
} from "../views/dialogs/AddExistingToSpaceDialog";
import { ChevronFace, ContextMenuButton, useContextMenu } from "./ContextMenu"; import { ChevronFace, ContextMenuButton, useContextMenu } from "./ContextMenu";
import IconizedContextMenu, { import IconizedContextMenu, {
IconizedContextMenuOption, IconizedContextMenuOption,
@ -62,10 +72,8 @@ import IconizedContextMenu, {
import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton"; import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton";
import { BetaPill } from "../views/beta/BetaCard"; import { BetaPill } from "../views/beta/BetaCard";
import { UserTab } from "../views/dialogs/UserSettingsDialog"; import { UserTab } from "../views/dialogs/UserSettingsDialog";
import Modal from "../../Modal";
import BetaFeedbackDialog from "../views/dialogs/BetaFeedbackDialog";
import SdkConfig from "../../SdkConfig";
import { EffectiveMembership, getEffectiveMembership } from "../../utils/membership"; import { EffectiveMembership, getEffectiveMembership } from "../../utils/membership";
import { SpaceFeedbackPrompt } from "../views/spaces/SpaceCreateMenu";
interface IProps { interface IProps {
space: Room; space: Room;
@ -92,28 +100,6 @@ enum Phase {
PrivateExistingRooms, PrivateExistingRooms,
} }
// XXX: Temporary for the Spaces Beta only
export const SpaceFeedbackPrompt = ({ onClick }: { onClick?: () => void }) => {
if (!SdkConfig.get().bug_report_endpoint_url) return null;
return <div className="mx_SpaceFeedbackPrompt">
<hr />
<div>
<span className="mx_SpaceFeedbackPrompt_text">{ _t("Spaces are a beta feature.") }</span>
<AccessibleButton
kind="link"
onClick={() => {
if (onClick) onClick();
Modal.createTrackedDialog("Beta Feedback", "feature_spaces", BetaFeedbackDialog, {
featureId: "feature_spaces",
});
}}>
{ _t("Feedback") }
</AccessibleButton>
</div>
</div>;
};
const RoomMemberCount = ({ room, children }) => { const RoomMemberCount = ({ room, children }) => {
const members = useRoomMembers(room); const members = useRoomMembers(room);
const count = members.length; const count = members.length;
@ -206,11 +192,11 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) =>
if (inviteSender) { if (inviteSender) {
inviterSection = <div className="mx_SpaceRoomView_preview_inviter"> inviterSection = <div className="mx_SpaceRoomView_preview_inviter">
<MemberAvatar member={inviter} width={32} height={32} /> <MemberAvatar member={inviter} fallbackUserId={inviteSender} width={32} height={32} />
<div> <div>
<div className="mx_SpaceRoomView_preview_inviter_name"> <div className="mx_SpaceRoomView_preview_inviter_name">
{ _t("<inviter/> invites you", {}, { { _t("<inviter/> invites you", {}, {
inviter: () => <b>{ inviter.name || inviteSender }</b>, inviter: () => <b>{ inviter?.name || inviteSender }</b>,
}) } }) }
</div> </div>
{ inviter ? <div className="mx_SpaceRoomView_preview_inviter_mxid"> { inviter ? <div className="mx_SpaceRoomView_preview_inviter_mxid">
@ -307,7 +293,7 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) =>
</div>; </div>;
}; };
const SpaceLandingAddButton = ({ space, onNewRoomAdded }) => { const SpaceLandingAddButton = ({ space }) => {
const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu(); const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu();
let contextMenu; let contextMenu;
@ -331,24 +317,32 @@ const SpaceLandingAddButton = ({ space, onNewRoomAdded }) => {
closeMenu(); closeMenu();
if (await showCreateNewRoom(space)) { if (await showCreateNewRoom(space)) {
onNewRoomAdded(); defaultDispatcher.fire(Action.UpdateSpaceHierarchy);
} }
}} }}
/> />
<IconizedContextMenuOption <IconizedContextMenuOption
label={_t("Add existing room")} label={_t("Add existing room")}
iconClassName="mx_RoomList_iconHash" iconClassName="mx_RoomList_iconHash"
onClick={async (e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
closeMenu(); closeMenu();
showAddExistingRooms(space);
const [added] = await showAddExistingRooms(space);
if (added) {
onNewRoomAdded();
}
}} }}
/> />
<IconizedContextMenuOption
label={_t("Add space")}
iconClassName="mx_RoomList_iconPlus"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
closeMenu();
showCreateNewSubspace(space);
}}
>
<BetaPill />
</IconizedContextMenuOption>
</IconizedContextMenuOptionList> </IconizedContextMenuOptionList>
</IconizedContextMenu>; </IconizedContextMenu>;
} }
@ -389,11 +383,9 @@ const SpaceLanding = ({ space }) => {
const canAddRooms = myMembership === "join" && space.currentState.maySendStateEvent(EventType.SpaceChild, userId); const canAddRooms = myMembership === "join" && space.currentState.maySendStateEvent(EventType.SpaceChild, userId);
const [refreshToken, forceUpdate] = useStateToggle(false);
let addRoomButton; let addRoomButton;
if (canAddRooms) { if (canAddRooms) {
addRoomButton = <SpaceLandingAddButton space={space} onNewRoomAdded={forceUpdate} />; addRoomButton = <SpaceLandingAddButton space={space} />;
} }
let settingsButton; let settingsButton;
@ -416,6 +408,7 @@ const SpaceLanding = ({ space }) => {
}; };
return <div className="mx_SpaceRoomView_landing"> return <div className="mx_SpaceRoomView_landing">
<SpaceFeedbackPrompt />
<RoomAvatar room={space} height={80} width={80} viewAvatarOnClick={true} /> <RoomAvatar room={space} height={80} width={80} viewAvatarOnClick={true} />
<div className="mx_SpaceRoomView_landing_name"> <div className="mx_SpaceRoomView_landing_name">
<RoomName room={space}> <RoomName room={space}>
@ -440,15 +433,8 @@ const SpaceLanding = ({ space }) => {
</div> </div>
) } ) }
</RoomTopic> </RoomTopic>
<SpaceFeedbackPrompt />
<hr />
<SpaceHierarchy <SpaceHierarchy space={space} showRoom={showRoom} additionalButtons={addRoomButton} />
space={space}
showRoom={showRoom}
refreshToken={refreshToken}
additionalButtons={addRoomButton}
/>
</div>; </div>;
}; };
@ -531,7 +517,6 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => {
value={buttonLabel} value={buttonLabel}
/> />
</div> </div>
<SpaceFeedbackPrompt />
</div>; </div>;
}; };
@ -550,11 +535,12 @@ const SpaceAddExistingRooms = ({ space, onFinished }) => {
{ _t("Skip for now") } { _t("Skip for now") }
</AccessibleButton> </AccessibleButton>
} }
filterPlaceholder={_t("Search for rooms or spaces")}
onFinished={onFinished} onFinished={onFinished}
roomsRenderer={defaultRoomsRenderer}
spacesRenderer={defaultSpacesRenderer}
dmsRenderer={defaultDmsRenderer}
/> />
<div className="mx_SpaceRoomView_buttons" />
<SpaceFeedbackPrompt />
</div>; </div>;
}; };
@ -574,7 +560,6 @@ const SpaceSetupPublicShare = ({ justCreatedOpts, space, onFinished, createdRoom
{ createdRooms ? _t("Go to my first room") : _t("Go to my space") } { createdRooms ? _t("Go to my first room") : _t("Go to my space") }
</AccessibleButton> </AccessibleButton>
</div> </div>
<SpaceFeedbackPrompt />
</div>; </div>;
}; };
@ -603,9 +588,8 @@ const SpaceSetupPrivateScope = ({ space, justCreatedOpts, onFinished }) => {
</AccessibleButton> </AccessibleButton>
<div className="mx_SpaceRoomView_betaWarning"> <div className="mx_SpaceRoomView_betaWarning">
<h3>{ _t("Teammates might not be able to view or join any private rooms you make.") }</h3> <h3>{ _t("Teammates might not be able to view or join any private rooms you make.") }</h3>
<p>{ _t("We're working on this as part of the beta, but just want to let you know.") }</p> <p>{ _t("We're working on this, but just want to let you know.") }</p>
</div> </div>
<SpaceFeedbackPrompt />
</div>; </div>;
}; };
@ -728,7 +712,6 @@ const SpaceSetupPrivateInvite = ({ space, onFinished }) => {
value={buttonLabel} value={buttonLabel}
/> />
</div> </div>
<SpaceFeedbackPrompt />
</div>; </div>;
}; };

View file

@ -58,28 +58,39 @@ export default class ToastContainer extends React.Component<{}, IState> {
let containerClasses; let containerClasses;
if (totalCount !== 0) { if (totalCount !== 0) {
const topToast = this.state.toasts[0]; const topToast = this.state.toasts[0];
const { title, icon, key, component, className, props } = topToast; const { title, icon, key, component, className, bodyClassName, props } = topToast;
const toastClasses = classNames("mx_Toast_toast", { const bodyClasses = classNames("mx_Toast_body", bodyClassName);
const toastClasses = classNames("mx_Toast_toast", className, {
"mx_Toast_hasIcon": icon, "mx_Toast_hasIcon": icon,
[`mx_Toast_icon_${icon}`]: icon, [`mx_Toast_icon_${icon}`]: icon,
}, className); });
let countIndicator;
if (isStacked || this.state.countSeen > 0) {
countIndicator = ` (${this.state.countSeen + 1}/${this.state.countSeen + totalCount})`;
}
const toastProps = Object.assign({}, props, { const toastProps = Object.assign({}, props, {
key, key,
toastKey: key, toastKey: key,
}); });
toast = (<div className={toastClasses}> const content = React.createElement(component, toastProps);
<div className="mx_Toast_title">
<h2>{ title }</h2> let countIndicator;
<span>{ countIndicator }</span> if (title && isStacked || this.state.countSeen > 0) {
countIndicator = ` (${this.state.countSeen + 1}/${this.state.countSeen + totalCount})`;
}
let titleElement;
if (title) {
titleElement = (
<div className="mx_Toast_title">
<h2>{ title }</h2>
<span>{ countIndicator }</span>
</div>
);
}
toast = (
<div className={toastClasses}>
{ titleElement }
<div className={bodyClasses}>{ content }</div>
</div> </div>
<div className="mx_Toast_body">{ React.createElement(component, toastProps) }</div> );
</div>);
containerClasses = classNames("mx_ToastContainer", { containerClasses = classNames("mx_ToastContainer", {
"mx_ToastContainer_stacked": isStacked, "mx_ToastContainer_stacked": isStacked,

View file

@ -17,8 +17,7 @@ limitations under the License.
import React from "react"; import React from "react";
import { IRecordingUpdate, RECORDING_PLAYBACK_SAMPLES, VoiceRecording } from "../../../audio/VoiceRecording"; import { IRecordingUpdate, RECORDING_PLAYBACK_SAMPLES, VoiceRecording } from "../../../audio/VoiceRecording";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import { arrayFastResample } from "../../../utils/arrays"; import { arrayFastResample, arraySeed } from "../../../utils/arrays";
import { percentageOf } from "../../../utils/numbers";
import Waveform from "./Waveform"; import Waveform from "./Waveform";
import { MarkedExecution } from "../../../utils/MarkedExecution"; import { MarkedExecution } from "../../../utils/MarkedExecution";
@ -48,18 +47,14 @@ export default class LiveRecordingWaveform extends React.PureComponent<IProps, I
constructor(props) { constructor(props) {
super(props); super(props);
this.state = { this.state = {
waveform: [], waveform: arraySeed(0, RECORDING_PLAYBACK_SAMPLES),
}; };
} }
componentDidMount() { componentDidMount() {
this.props.recorder.liveData.onUpdate((update: IRecordingUpdate) => { this.props.recorder.liveData.onUpdate((update: IRecordingUpdate) => {
const bars = arrayFastResample(Array.from(update.waveform), RECORDING_PLAYBACK_SAMPLES); // The incoming data is between zero and one, so we don't need to clamp/rescale it.
// The incoming data is between zero and one, but typically even screaming into a this.waveform = arrayFastResample(Array.from(update.waveform), RECORDING_PLAYBACK_SAMPLES);
// microphone won't send you over 0.6, so we artificially adjust the gain for the
// waveform. This results in a slightly more cinematic/animated waveform for the
// user.
this.waveform = bars.map(b => percentageOf(b, 0, 0.50));
this.scheduledUpdate.mark(); this.scheduledUpdate.mark();
}); });
} }

View file

@ -103,8 +103,8 @@ export default class CaptchaForm extends React.Component<ICaptchaFormProps, ICap
} }
private resetRecaptcha() { private resetRecaptcha() {
if (this.captchaWidgetId !== null) { if (this.captchaWidgetId) {
global.grecaptcha.reset(this.captchaWidgetId); global?.grecaptcha?.reset(this.captchaWidgetId);
} }
} }

View file

@ -27,6 +27,8 @@ import BetaFeedbackDialog from "../dialogs/BetaFeedbackDialog";
import SdkConfig from "../../../SdkConfig"; import SdkConfig from "../../../SdkConfig";
import SettingsFlag from "../elements/SettingsFlag"; import SettingsFlag from "../elements/SettingsFlag";
// XXX: Keep this around for re-use in future Betas
interface IProps { interface IProps {
title?: string; title?: string;
featureId: string; featureId: string;

View file

@ -49,6 +49,13 @@ export default class DialpadContextMenu extends React.Component<IProps, IState>
this.props.onFinished(); this.props.onFinished();
}; };
onKeyDown = (ev) => {
// Prevent Backspace and Delete keys from functioning in the entry field
if (ev.code === "Backspace" || ev.code === "Delete") {
ev.preventDefault();
}
};
onChange = (ev) => { onChange = (ev) => {
this.setState({ value: ev.target.value }); this.setState({ value: ev.target.value });
}; };
@ -64,6 +71,7 @@ export default class DialpadContextMenu extends React.Component<IProps, IState>
className="mx_DialPadContextMenu_dialled" className="mx_DialPadContextMenu_dialled"
value={this.state.value} value={this.state.value}
autoFocus={true} autoFocus={true}
onKeyDown={this.onKeyDown}
onChange={this.onChange} onChange={this.onChange}
/> />
</div> </div>

View file

@ -86,14 +86,18 @@ export const IconizedContextMenuCheckbox: React.FC<ICheckboxProps> = ({
> >
<span className={classNames("mx_IconizedContextMenu_icon", iconClassName)} /> <span className={classNames("mx_IconizedContextMenu_icon", iconClassName)} />
<span className="mx_IconizedContextMenu_label">{ label }</span> <span className="mx_IconizedContextMenu_label">{ label }</span>
{ active && <span className="mx_IconizedContextMenu_icon mx_IconizedContextMenu_checked" /> } <span className={classNames("mx_IconizedContextMenu_icon", {
mx_IconizedContextMenu_checked: active,
mx_IconizedContextMenu_unchecked: !active,
})} />
</MenuItemCheckbox>; </MenuItemCheckbox>;
}; };
export const IconizedContextMenuOption: React.FC<IOptionProps> = ({ label, iconClassName, ...props }) => { export const IconizedContextMenuOption: React.FC<IOptionProps> = ({ label, iconClassName, children, ...props }) => {
return <MenuItem {...props} label={label}> return <MenuItem {...props} label={label}>
{ iconClassName && <span className={classNames("mx_IconizedContextMenu_icon", iconClassName)} /> } { iconClassName && <span className={classNames("mx_IconizedContextMenu_icon", iconClassName)} /> }
<span className="mx_IconizedContextMenu_label">{ label }</span> <span className="mx_IconizedContextMenu_label">{ label }</span>
{ children }
</MenuItem>; </MenuItem>;
}; };

View file

@ -0,0 +1,216 @@
/*
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, { useContext } from "react";
import { Room } from "matrix-js-sdk/src/models/room";
import { EventType } from "matrix-js-sdk/src/@types/event";
import {
IProps as IContextMenuProps,
} from "../../structures/ContextMenu";
import IconizedContextMenu, { IconizedContextMenuOption, IconizedContextMenuOptionList } from "./IconizedContextMenu";
import { _t } from "../../../languageHandler";
import {
leaveSpace,
shouldShowSpaceSettings,
showAddExistingRooms,
showCreateNewRoom,
showCreateNewSubspace,
showSpaceInvite,
showSpaceSettings,
} from "../../../utils/space";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { ButtonEvent } from "../elements/AccessibleButton";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import RoomViewStore from "../../../stores/RoomViewStore";
import { SetRightPanelPhasePayload } from "../../../dispatcher/payloads/SetRightPanelPhasePayload";
import { Action } from "../../../dispatcher/actions";
import { RightPanelPhases } from "../../../stores/RightPanelStorePhases";
import { BetaPill } from "../beta/BetaCard";
interface IProps extends IContextMenuProps {
space: Room;
}
const SpaceContextMenu = ({ space, onFinished, ...props }: IProps) => {
const cli = useContext(MatrixClientContext);
const userId = cli.getUserId();
let inviteOption;
if (space.getJoinRule() === "public" || space.canInvite(userId)) {
const onInviteClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
showSpaceInvite(space);
onFinished();
};
inviteOption = (
<IconizedContextMenuOption
className="mx_SpacePanel_contextMenu_inviteButton"
iconClassName="mx_SpacePanel_iconInvite"
label={_t("Invite people")}
onClick={onInviteClick}
/>
);
}
let settingsOption;
let leaveSection;
if (shouldShowSpaceSettings(space)) {
const onSettingsClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
showSpaceSettings(space);
onFinished();
};
settingsOption = (
<IconizedContextMenuOption
iconClassName="mx_SpacePanel_iconSettings"
label={_t("Settings")}
onClick={onSettingsClick}
/>
);
} else {
const onLeaveClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
leaveSpace(space);
onFinished();
};
leaveSection = <IconizedContextMenuOptionList red first>
<IconizedContextMenuOption
iconClassName="mx_SpacePanel_iconLeave"
label={_t("Leave space")}
onClick={onLeaveClick}
/>
</IconizedContextMenuOptionList>;
}
const canAddRooms = space.currentState.maySendStateEvent(EventType.SpaceChild, userId);
let newRoomSection;
if (space.currentState.maySendStateEvent(EventType.SpaceChild, userId)) {
const onNewRoomClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
showCreateNewRoom(space);
onFinished();
};
const onAddExistingRoomClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
showAddExistingRooms(space);
onFinished();
};
const onNewSubspaceClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
showCreateNewSubspace(space);
onFinished();
};
newRoomSection = <IconizedContextMenuOptionList first>
<IconizedContextMenuOption
iconClassName="mx_SpacePanel_iconPlus"
label={_t("Create new room")}
onClick={onNewRoomClick}
/>
<IconizedContextMenuOption
iconClassName="mx_SpacePanel_iconHash"
label={_t("Add existing room")}
onClick={onAddExistingRoomClick}
/>
<IconizedContextMenuOption
iconClassName="mx_SpacePanel_iconPlus"
label={_t("Add space")}
onClick={onNewSubspaceClick}
>
<BetaPill />
</IconizedContextMenuOption>
</IconizedContextMenuOptionList>;
}
const onMembersClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
if (!RoomViewStore.getRoomId()) {
defaultDispatcher.dispatch({
action: "view_room",
room_id: space.roomId,
}, true);
}
defaultDispatcher.dispatch<SetRightPanelPhasePayload>({
action: Action.SetRightPanelPhase,
phase: RightPanelPhases.SpaceMemberList,
refireParams: { space: space },
});
onFinished();
};
const onExploreRoomsClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
defaultDispatcher.dispatch({
action: "view_room",
room_id: space.roomId,
});
onFinished();
};
return <IconizedContextMenu
{...props}
onFinished={onFinished}
className="mx_SpacePanel_contextMenu"
compact
>
<div className="mx_SpacePanel_contextMenu_header">
{ space.name }
</div>
<IconizedContextMenuOptionList first>
{ inviteOption }
<IconizedContextMenuOption
iconClassName="mx_SpacePanel_iconMembers"
label={_t("Members")}
onClick={onMembersClick}
/>
{ settingsOption }
<IconizedContextMenuOption
iconClassName="mx_SpacePanel_iconExplore"
label={canAddRooms ? _t("Manage & explore rooms") : _t("Explore rooms")}
onClick={onExploreRoomsClick}
/>
</IconizedContextMenuOptionList>
{ newRoomSection }
{ leaveSection }
</IconizedContextMenu>;
};
export default SpaceContextMenu;

View file

@ -0,0 +1,67 @@
/*
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, { useState } from "react";
import { Room } from "matrix-js-sdk/src/models/room";
import { _t } from '../../../languageHandler';
import BaseDialog from "./BaseDialog";
import AccessibleButton from "../elements/AccessibleButton";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { AddExistingToSpace, defaultSpacesRenderer, SubspaceSelector } from "./AddExistingToSpaceDialog";
interface IProps {
space: Room;
onCreateSubspaceClick(): void;
onFinished(added?: boolean): void;
}
const AddExistingSubspaceDialog: React.FC<IProps> = ({ space, onCreateSubspaceClick, onFinished }) => {
const [selectedSpace, setSelectedSpace] = useState(space);
return <BaseDialog
title={(
<SubspaceSelector
title={_t("Add existing space")}
space={space}
value={selectedSpace}
onChange={setSelectedSpace}
/>
)}
className="mx_AddExistingToSpaceDialog"
contentId="mx_AddExistingToSpace"
onFinished={onFinished}
fixedWidth={false}
>
<MatrixClientContext.Provider value={space.client}>
<AddExistingToSpace
space={space}
onFinished={onFinished}
footerPrompt={<>
<div>{ _t("Want to add a new space instead?") }</div>
<AccessibleButton onClick={onCreateSubspaceClick} kind="link">
{ _t("Create a new space") }
</AccessibleButton>
</>}
filterPlaceholder={_t("Search for spaces")}
spacesRenderer={defaultSpacesRenderer}
/>
</MatrixClientContext.Provider>
</BaseDialog>;
};
export default AddExistingSubspaceDialog;

View file

@ -18,9 +18,9 @@ import React, { ReactNode, useContext, useMemo, useState } from "react";
import classNames from "classnames"; import classNames from "classnames";
import { Room } from "matrix-js-sdk/src/models/room"; import { Room } from "matrix-js-sdk/src/models/room";
import { sleep } from "matrix-js-sdk/src/utils"; import { sleep } from "matrix-js-sdk/src/utils";
import { EventType } from "matrix-js-sdk/src/@types/event";
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import { IDialogProps } from "./IDialogProps";
import BaseDialog from "./BaseDialog"; import BaseDialog from "./BaseDialog";
import Dropdown from "../elements/Dropdown"; import Dropdown from "../elements/Dropdown";
import SearchBox from "../../structures/SearchBox"; import SearchBox from "../../structures/SearchBox";
@ -35,19 +35,20 @@ import StyledCheckbox from "../elements/StyledCheckbox";
import MatrixClientContext from "../../../contexts/MatrixClientContext"; import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { sortRooms } from "../../../stores/room-list/algorithms/tag-sorting/RecentAlgorithm"; import { sortRooms } from "../../../stores/room-list/algorithms/tag-sorting/RecentAlgorithm";
import ProgressBar from "../elements/ProgressBar"; import ProgressBar from "../elements/ProgressBar";
import { SpaceFeedbackPrompt } from "../../structures/SpaceRoomView";
import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar"; import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar";
import QueryMatcher from "../../../autocomplete/QueryMatcher"; import QueryMatcher from "../../../autocomplete/QueryMatcher";
import TruncatedList from "../elements/TruncatedList"; import TruncatedList from "../elements/TruncatedList";
import EntityTile from "../rooms/EntityTile"; import EntityTile from "../rooms/EntityTile";
import BaseAvatar from "../avatars/BaseAvatar"; import BaseAvatar from "../avatars/BaseAvatar";
interface IProps extends IDialogProps { interface IProps {
space: Room; space: Room;
onCreateRoomClick(space: Room): void; onCreateRoomClick(): void;
onAddSubspaceClick(): void;
onFinished(added?: boolean): void;
} }
const Entry = ({ room, checked, onChange }) => { export const Entry = ({ room, checked, onChange }) => {
return <label className="mx_AddExistingToSpace_entry"> return <label className="mx_AddExistingToSpace_entry">
{ room?.isSpaceRoom() { room?.isSpaceRoom()
? <RoomAvatar room={room} height={32} width={32} /> ? <RoomAvatar room={room} height={32} width={32} />
@ -65,14 +66,36 @@ const Entry = ({ room, checked, onChange }) => {
interface IAddExistingToSpaceProps { interface IAddExistingToSpaceProps {
space: Room; space: Room;
footerPrompt?: ReactNode; footerPrompt?: ReactNode;
filterPlaceholder: string;
emptySelectionButton?: ReactNode; emptySelectionButton?: ReactNode;
onFinished(added: boolean): void; onFinished(added: boolean): void;
roomsRenderer?(
rooms: Room[],
selectedToAdd: Set<Room>,
onChange: undefined | ((checked: boolean, room: Room) => void),
truncateAt: number,
overflowTile: (overflowCount: number, totalCount: number) => JSX.Element,
): ReactNode;
spacesRenderer?(
spaces: Room[],
selectedToAdd: Set<Room>,
onChange?: (checked: boolean, room: Room) => void,
): ReactNode;
dmsRenderer?(
dms: Room[],
selectedToAdd: Set<Room>,
onChange?: (checked: boolean, room: Room) => void,
): ReactNode;
} }
export const AddExistingToSpace: React.FC<IAddExistingToSpaceProps> = ({ export const AddExistingToSpace: React.FC<IAddExistingToSpaceProps> = ({
space, space,
footerPrompt, footerPrompt,
emptySelectionButton, emptySelectionButton,
filterPlaceholder,
roomsRenderer,
dmsRenderer,
spacesRenderer,
onFinished, onFinished,
}) => { }) => {
const cli = useContext(MatrixClientContext); const cli = useContext(MatrixClientContext);
@ -196,7 +219,7 @@ export const AddExistingToSpace: React.FC<IAddExistingToSpaceProps> = ({
</>; </>;
} }
const onChange = !busy && !error ? (checked, room) => { const onChange = !busy && !error ? (checked: boolean, room: Room) => {
if (checked) { if (checked) {
selectedToAdd.add(room); selectedToAdd.add(room);
} else { } else {
@ -206,7 +229,7 @@ export const AddExistingToSpace: React.FC<IAddExistingToSpaceProps> = ({
} : null; } : null;
const [truncateAt, setTruncateAt] = useState(20); const [truncateAt, setTruncateAt] = useState(20);
function overflowTile(overflowCount, totalCount) { function overflowTile(overflowCount: number, totalCount: number): JSX.Element {
const text = _t("and %(count)s others...", { count: overflowCount }); const text = _t("and %(count)s others...", { count: overflowCount });
return ( return (
<EntityTile <EntityTile
@ -222,73 +245,36 @@ export const AddExistingToSpace: React.FC<IAddExistingToSpaceProps> = ({
); );
} }
let noResults = true;
if ((roomsRenderer && rooms.length > 0) ||
(dmsRenderer && dms.length > 0) ||
(!roomsRenderer && !dmsRenderer && spacesRenderer && spaces.length > 0) // only count spaces when alone
) {
noResults = false;
}
return <div className="mx_AddExistingToSpace"> return <div className="mx_AddExistingToSpace">
<SearchBox <SearchBox
className="mx_textinput_icon mx_textinput_search" className="mx_textinput_icon mx_textinput_search"
placeholder={_t("Filter your rooms and spaces")} placeholder={filterPlaceholder}
onSearch={setQuery} onSearch={setQuery}
autoComplete={true} autoComplete={true}
autoFocus={true} autoFocus={true}
/> />
<AutoHideScrollbar className="mx_AddExistingToSpace_content"> <AutoHideScrollbar className="mx_AddExistingToSpace_content">
{ rooms.length > 0 ? ( { rooms.length > 0 && roomsRenderer ? (
<div className="mx_AddExistingToSpace_section"> roomsRenderer(rooms, selectedToAdd, onChange, truncateAt, overflowTile)
<h3>{ _t("Rooms") }</h3>
<TruncatedList
truncateAt={truncateAt}
createOverflowElement={overflowTile}
getChildren={(start, end) => rooms.slice(start, end).map(room =>
<Entry
key={room.roomId}
room={room}
checked={selectedToAdd.has(room)}
onChange={onChange ? (checked) => {
onChange(checked, room);
} : null}
/>,
)}
getChildCount={() => rooms.length}
/>
</div>
) : undefined } ) : undefined }
{ spaces.length > 0 ? ( { spaces.length > 0 && spacesRenderer ? (
<div className="mx_AddExistingToSpace_section mx_AddExistingToSpace_section_spaces"> spacesRenderer(spaces, selectedToAdd, onChange)
<h3>{ _t("Spaces") }</h3>
<div className="mx_AddExistingToSpace_section_experimental">
<div>{ _t("Feeling experimental?") }</div>
<div>{ _t("You can add existing spaces to a space.") }</div>
</div>
{ spaces.map(space => {
return <Entry
key={space.roomId}
room={space}
checked={selectedToAdd.has(space)}
onChange={onChange ? (checked) => {
onChange(checked, space);
} : null}
/>;
}) }
</div>
) : null } ) : null }
{ dms.length > 0 ? ( { dms.length > 0 && dmsRenderer ? (
<div className="mx_AddExistingToSpace_section"> dmsRenderer(dms, selectedToAdd, onChange)
<h3>{ _t("Direct Messages") }</h3>
{ dms.map(room => {
return <Entry
key={room.roomId}
room={room}
checked={selectedToAdd.has(room)}
onChange={onChange ? (checked) => {
onChange(checked, room);
} : null}
/>;
}) }
</div>
) : null } ) : null }
{ spaces.length + rooms.length + dms.length < 1 ? <span className="mx_AddExistingToSpace_noResults"> { noResults ? <span className="mx_AddExistingToSpace_noResults">
{ _t("No results") } { _t("No results") }
</span> : undefined } </span> : undefined }
</AutoHideScrollbar> </AutoHideScrollbar>
@ -299,50 +285,126 @@ export const AddExistingToSpace: React.FC<IAddExistingToSpaceProps> = ({
</div>; </div>;
}; };
const AddExistingToSpaceDialog: React.FC<IProps> = ({ space, onCreateRoomClick, onFinished }) => { export const defaultRoomsRenderer: IAddExistingToSpaceProps["roomsRenderer"] = (
const [selectedSpace, setSelectedSpace] = useState(space); rooms, selectedToAdd, onChange, truncateAt, overflowTile,
const existingSubspaces = SpaceStore.instance.getChildSpaces(space.roomId); ) => (
<div className="mx_AddExistingToSpace_section">
<h3>{ _t("Rooms") }</h3>
<TruncatedList
truncateAt={truncateAt}
createOverflowElement={overflowTile}
getChildren={(start, end) => rooms.slice(start, end).map(room =>
<Entry
key={room.roomId}
room={room}
checked={selectedToAdd.has(room)}
onChange={onChange ? (checked: boolean) => {
onChange(checked, room);
} : null}
/>,
)}
getChildCount={() => rooms.length}
/>
</div>
);
let spaceOptionSection; export const defaultSpacesRenderer: IAddExistingToSpaceProps["spacesRenderer"] = (spaces, selectedToAdd, onChange) => (
if (existingSubspaces.length > 0) { <div className="mx_AddExistingToSpace_section">
const options = [space, ...existingSubspaces].map((space) => { { spaces.map(space => {
const classes = classNames("mx_AddExistingToSpaceDialog_dropdownOption", { return <Entry
mx_AddExistingToSpaceDialog_dropdownOptionActive: space === selectedSpace, key={space.roomId}
}); room={space}
return <div key={space.roomId} className={classes}> checked={selectedToAdd.has(space)}
<RoomAvatar room={space} width={24} height={24} /> onChange={onChange ? (checked) => {
{ space.name || getDisplayAliasForRoom(space) || space.roomId } onChange(checked, space);
</div>; } : null}
}); />;
}) }
</div>
);
spaceOptionSection = ( export const defaultDmsRenderer: IAddExistingToSpaceProps["dmsRenderer"] = (dms, selectedToAdd, onChange) => (
<div className="mx_AddExistingToSpace_section">
<h3>{ _t("Direct Messages") }</h3>
{ dms.map(room => {
return <Entry
key={room.roomId}
room={room}
checked={selectedToAdd.has(room)}
onChange={onChange ? (checked: boolean) => {
onChange(checked, room);
} : null}
/>;
}) }
</div>
);
interface ISubspaceSelectorProps {
title: string;
space: Room;
value: Room;
onChange(space: Room): void;
}
export const SubspaceSelector = ({ title, space, value, onChange }: ISubspaceSelectorProps) => {
const options = useMemo(() => {
return [space, ...SpaceStore.instance.getChildSpaces(space.roomId).filter(space => {
return space.currentState.maySendStateEvent(EventType.SpaceChild, space.client.credentials.userId);
})];
}, [space]);
let body;
if (options.length > 1) {
body = (
<Dropdown <Dropdown
id="mx_SpaceSelectDropdown" id="mx_SpaceSelectDropdown"
className="mx_SpaceSelectDropdown"
onOptionChange={(key: string) => { onOptionChange={(key: string) => {
setSelectedSpace(existingSubspaces.find(space => space.roomId === key) || space); onChange(options.find(space => space.roomId === key) || space);
}} }}
value={selectedSpace.roomId} value={value.roomId}
label={_t("Space selection")} label={_t("Space selection")}
> >
{ options } { options.map((space) => {
const classes = classNames({
mx_SubspaceSelector_dropdownOptionActive: space === value,
});
return <div key={space.roomId} className={classes}>
<RoomAvatar room={space} width={24} height={24} />
{ space.name || getDisplayAliasForRoom(space) || space.roomId }
</div>;
}) }
</Dropdown> </Dropdown>
); );
} else { } else {
spaceOptionSection = <div className="mx_AddExistingToSpaceDialog_onlySpace"> body = (
{ space.name || getDisplayAliasForRoom(space) || space.roomId } <div className="mx_SubspaceSelector_onlySpace">
</div>; { space.name || getDisplayAliasForRoom(space) || space.roomId }
</div>
);
} }
const title = <React.Fragment> return <div className="mx_SubspaceSelector">
<RoomAvatar room={selectedSpace} height={40} width={40} /> <RoomAvatar room={value} height={40} width={40} />
<div> <div>
<h1>{ _t("Add existing rooms") }</h1> <h1>{ title }</h1>
{ spaceOptionSection } { body }
</div> </div>
</React.Fragment>; </div>;
};
const AddExistingToSpaceDialog: React.FC<IProps> = ({ space, onCreateRoomClick, onAddSubspaceClick, onFinished }) => {
const [selectedSpace, setSelectedSpace] = useState(space);
return <BaseDialog return <BaseDialog
title={title} title={(
<SubspaceSelector
title={_t("Add existing rooms")}
space={space}
value={selectedSpace}
onChange={setSelectedSpace}
/>
)}
className="mx_AddExistingToSpaceDialog" className="mx_AddExistingToSpaceDialog"
contentId="mx_AddExistingToSpace" contentId="mx_AddExistingToSpace"
onFinished={onFinished} onFinished={onFinished}
@ -354,14 +416,35 @@ const AddExistingToSpaceDialog: React.FC<IProps> = ({ space, onCreateRoomClick,
onFinished={onFinished} onFinished={onFinished}
footerPrompt={<> footerPrompt={<>
<div>{ _t("Want to add a new room instead?") }</div> <div>{ _t("Want to add a new room instead?") }</div>
<AccessibleButton onClick={() => onCreateRoomClick(space)} kind="link"> <AccessibleButton
kind="link"
onClick={() => {
onCreateRoomClick();
onFinished();
}}
>
{ _t("Create a new room") } { _t("Create a new room") }
</AccessibleButton> </AccessibleButton>
</>} </>}
filterPlaceholder={_t("Search for rooms")}
roomsRenderer={defaultRoomsRenderer}
spacesRenderer={() => (
<div className="mx_AddExistingToSpace_section">
<h3>{ _t("Spaces") }</h3>
<AccessibleButton
kind="link"
onClick={() => {
onAddSubspaceClick();
onFinished();
}}
>
{ _t("Adding spaces has moved.") }
</AccessibleButton>
</div>
)}
dmsRenderer={defaultDmsRenderer}
/> />
</MatrixClientContext.Provider> </MatrixClientContext.Provider>
<SpaceFeedbackPrompt onClick={() => onFinished(false)} />
</BaseDialog>; </BaseDialog>;
}; };

View file

@ -14,22 +14,18 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React, { useState } from "react"; import React from "react";
import QuestionDialog from './QuestionDialog';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import Field from "../elements/Field";
import SdkConfig from "../../../SdkConfig";
import { IDialogProps } from "./IDialogProps"; import { IDialogProps } from "./IDialogProps";
import SettingsStore from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
import { submitFeedback } from "../../../rageshake/submit-rageshake";
import StyledCheckbox from "../elements/StyledCheckbox";
import Modal from "../../../Modal";
import InfoDialog from "./InfoDialog";
import AccessibleButton from "../elements/AccessibleButton"; import AccessibleButton from "../elements/AccessibleButton";
import defaultDispatcher from "../../../dispatcher/dispatcher"; import defaultDispatcher from "../../../dispatcher/dispatcher";
import { Action } from "../../../dispatcher/actions"; import { Action } from "../../../dispatcher/actions";
import { UserTab } from "./UserSettingsDialog"; import { UserTab } from "./UserSettingsDialog";
import GenericFeatureFeedbackDialog from "./GenericFeatureFeedbackDialog";
// XXX: Keep this around for re-use in future Betas
interface IProps extends IDialogProps { interface IProps extends IDialogProps {
featureId: string; featureId: string;
@ -38,77 +34,28 @@ interface IProps extends IDialogProps {
const BetaFeedbackDialog: React.FC<IProps> = ({ featureId, onFinished }) => { const BetaFeedbackDialog: React.FC<IProps> = ({ featureId, onFinished }) => {
const info = SettingsStore.getBetaInfo(featureId); const info = SettingsStore.getBetaInfo(featureId);
const [comment, setComment] = useState(""); return <GenericFeatureFeedbackDialog
const [canContact, setCanContact] = useState(false);
const sendFeedback = async (ok: boolean) => {
if (!ok) return onFinished(false);
const extraData = SettingsStore.getBetaInfo(featureId)?.extraSettings.reduce((o, k) => {
o[k] = SettingsStore.getValue(k);
return o;
}, {});
submitFeedback(SdkConfig.get().bug_report_endpoint_url, info.feedbackLabel, comment, canContact, extraData);
onFinished(true);
Modal.createTrackedDialog("Beta Dialog Sent", featureId, InfoDialog, {
title: _t("Beta feedback"),
description: _t("Thank you for your feedback, we really appreciate it."),
button: _t("Done"),
hasCloseButton: false,
fixedWidth: false,
});
};
return (<QuestionDialog
className="mx_BetaFeedbackDialog"
hasCancelButton={true}
title={_t("%(featureName)s beta feedback", { featureName: info.title })} title={_t("%(featureName)s beta feedback", { featureName: info.title })}
description={<React.Fragment> subheading={_t(info.feedbackSubheading)}
<div className="mx_BetaFeedbackDialog_subheading"> onFinished={onFinished}
{ _t(info.feedbackSubheading) } rageshakeLabel={info.feedbackLabel}
&nbsp; rageshakeData={Object.fromEntries((SettingsStore.getBetaInfo(featureId)?.extraSettings || []).map(k => {
{ _t("Your platform and username will be noted to help us use your feedback as much as we can.") } return SettingsStore.getValue(k);
}))}
<AccessibleButton >
kind="link" <AccessibleButton
onClick={() => { kind="link"
onFinished(false); onClick={() => {
defaultDispatcher.dispatch({ onFinished(false);
action: Action.ViewUserSettings, defaultDispatcher.dispatch({
initialTabId: UserTab.Labs, action: Action.ViewUserSettings,
}); initialTabId: UserTab.Labs,
}} });
> }}
{ _t("To leave the beta, visit your settings.") } >
</AccessibleButton> { _t("To leave the beta, visit your settings.") }
</div> </AccessibleButton>
</GenericFeatureFeedbackDialog>;
<Field
id="feedbackComment"
label={_t("Feedback")}
type="text"
autoComplete="off"
value={comment}
element="textarea"
onChange={(ev) => {
setComment(ev.target.value);
}}
autoFocus={true}
/>
<StyledCheckbox
checked={canContact}
onClick={e => setCanContact((e.target as HTMLInputElement).checked)}
>
{ _t("You may contact me if you have any follow up questions") }
</StyledCheckbox>
</React.Fragment>}
button={_t("Send feedback")}
buttonDisabled={!comment}
onFinished={sendFeedback}
/>);
}; };
export default BetaFeedbackDialog; export default BetaFeedbackDialog;

View file

@ -32,8 +32,8 @@ import RoomAliasField from "../elements/RoomAliasField";
import LabelledToggleSwitch from "../elements/LabelledToggleSwitch"; import LabelledToggleSwitch from "../elements/LabelledToggleSwitch";
import DialogButtons from "../elements/DialogButtons"; import DialogButtons from "../elements/DialogButtons";
import BaseDialog from "../dialogs/BaseDialog"; import BaseDialog from "../dialogs/BaseDialog";
import Dropdown from "../elements/Dropdown";
import SpaceStore from "../../../stores/SpaceStore"; import SpaceStore from "../../../stores/SpaceStore";
import JoinRuleDropdown from "../elements/JoinRuleDropdown";
interface IProps { interface IProps {
defaultPublic?: boolean; defaultPublic?: boolean;
@ -250,7 +250,7 @@ export default class CreateRoomDialog extends React.Component<IProps, IState> {
&nbsp; &nbsp;
{ _t("You can change this at any time from room settings.") } { _t("You can change this at any time from room settings.") }
</p>; </p>;
} else if (this.state.joinRule === JoinRule.Public) { } else if (this.state.joinRule === JoinRule.Public && this.props.parentSpace) {
publicPrivateLabel = <p> publicPrivateLabel = <p>
{ _t( { _t(
"Anyone will be able to find and join this room, not just members of <SpaceName/>.", {}, { "Anyone will be able to find and join this room, not just members of <SpaceName/>.", {}, {
@ -260,6 +260,12 @@ export default class CreateRoomDialog extends React.Component<IProps, IState> {
&nbsp; &nbsp;
{ _t("You can change this at any time from room settings.") } { _t("You can change this at any time from room settings.") }
</p>; </p>;
} else if (this.state.joinRule === JoinRule.Public) {
publicPrivateLabel = <p>
{ _t("Anyone will be able to find and join this room.") }
&nbsp;
{ _t("You can change this at any time from room settings.") }
</p>;
} else if (this.state.joinRule === JoinRule.Invite) { } else if (this.state.joinRule === JoinRule.Invite) {
publicPrivateLabel = <p> publicPrivateLabel = <p>
{ _t( { _t(
@ -316,21 +322,6 @@ export default class CreateRoomDialog extends React.Component<IProps, IState> {
title = this.state.joinRule === JoinRule.Public ? _t('Create a public room') : _t('Create a private room'); title = this.state.joinRule === JoinRule.Public ? _t('Create a public room') : _t('Create a private room');
} }
const options = [
<div key={JoinRule.Invite} className="mx_CreateRoomDialog_dropdown_invite">
{ _t("Private room (invite only)") }
</div>,
<div key={JoinRule.Public} className="mx_CreateRoomDialog_dropdown_public">
{ _t("Public room") }
</div>,
];
if (this.supportsRestricted) {
options.unshift(<div key={JoinRule.Restricted} className="mx_CreateRoomDialog_dropdown_restricted">
{ _t("Visible to space members") }
</div>);
}
return ( return (
<BaseDialog className="mx_CreateRoomDialog" onFinished={this.props.onFinished} title={title}> <BaseDialog className="mx_CreateRoomDialog" onFinished={this.props.onFinished} title={title}>
<form onSubmit={this.onOk} onKeyDown={this.onKeyDown}> <form onSubmit={this.onOk} onKeyDown={this.onKeyDown}>
@ -350,16 +341,14 @@ export default class CreateRoomDialog extends React.Component<IProps, IState> {
className="mx_CreateRoomDialog_topic" className="mx_CreateRoomDialog_topic"
/> />
<Dropdown <JoinRuleDropdown
id="mx_CreateRoomDialog_typeDropdown"
className="mx_CreateRoomDialog_typeDropdown"
onOptionChange={this.onJoinRuleChange}
menuWidth={448}
value={this.state.joinRule}
label={_t("Room visibility")} label={_t("Room visibility")}
> labelInvite={_t("Private room (invite only)")}
{ options } labelPublic={_t("Public room")}
</Dropdown> labelRestricted={this.supportsRestricted ? _t("Visible to space members") : undefined}
value={this.state.joinRule}
onChange={this.onJoinRuleChange}
/>
{ publicPrivateLabel } { publicPrivateLabel }
{ e2eeSection } { e2eeSection }

View file

@ -0,0 +1,210 @@
/*
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/models/room";
import { JoinRule, Preset } from "matrix-js-sdk/src/@types/partials";
import { RoomType } from "matrix-js-sdk/src/@types/event";
import { _t } from '../../../languageHandler';
import BaseDialog from "./BaseDialog";
import AccessibleButton from "../elements/AccessibleButton";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { BetaPill } from "../beta/BetaCard";
import Field from "../elements/Field";
import RoomAliasField from "../elements/RoomAliasField";
import SpaceStore from "../../../stores/SpaceStore";
import { SpaceCreateForm } from "../spaces/SpaceCreateMenu";
import createRoom from "../../../createRoom";
import { SubspaceSelector } from "./AddExistingToSpaceDialog";
import JoinRuleDropdown from "../elements/JoinRuleDropdown";
interface IProps {
space: Room;
onAddExistingSpaceClick(): void;
onFinished(added?: boolean): void;
}
const CreateSubspaceDialog: React.FC<IProps> = ({ space, onAddExistingSpaceClick, onFinished }) => {
const [parentSpace, setParentSpace] = useState(space);
const [busy, setBusy] = useState<boolean>(false);
const [name, setName] = useState("");
const spaceNameField = useRef<Field>();
const [alias, setAlias] = useState("");
const spaceAliasField = useRef<RoomAliasField>();
const [avatar, setAvatar] = useState<File>(null);
const [topic, setTopic] = useState<string>("");
const supportsRestricted = !!SpaceStore.instance.restrictedJoinRuleSupport?.preferred;
const spaceJoinRule = space.getJoinRule();
let defaultJoinRule = JoinRule.Invite;
if (spaceJoinRule === JoinRule.Public) {
defaultJoinRule = JoinRule.Public;
} else if (supportsRestricted) {
defaultJoinRule = JoinRule.Restricted;
}
const [joinRule, setJoinRule] = useState<JoinRule>(defaultJoinRule);
const onCreateSubspaceClick = async (e) => {
e.preventDefault();
if (busy) return;
setBusy(true);
// require & validate the space name field
if (!await spaceNameField.current.validate({ allowEmpty: false })) {
spaceNameField.current.focus();
spaceNameField.current.validate({ allowEmpty: false, focused: true });
setBusy(false);
return;
}
// validate the space name alias field but do not require it
if (joinRule === JoinRule.Public && !await spaceAliasField.current.validate({ allowEmpty: true })) {
spaceAliasField.current.focus();
spaceAliasField.current.validate({ allowEmpty: true, focused: true });
setBusy(false);
return;
}
try {
await createRoom({
createOpts: {
preset: joinRule === JoinRule.Public ? Preset.PublicChat : Preset.PrivateChat,
name,
power_level_content_override: {
// Only allow Admins to write to the timeline to prevent hidden sync spam
events_default: 100,
...joinRule === JoinRule.Public ? { invite: 0 } : {},
},
room_alias_name: joinRule === JoinRule.Public && alias
? alias.substr(1, alias.indexOf(":") - 1)
: undefined,
topic,
},
avatar,
roomType: RoomType.Space,
parentSpace,
spinner: false,
encryption: false,
andView: true,
inlineErrors: true,
});
onFinished(true);
} catch (e) {
console.error(e);
}
};
let joinRuleMicrocopy: JSX.Element;
if (joinRule === JoinRule.Restricted) {
joinRuleMicrocopy = <p>
{ _t(
"Anyone in <SpaceName/> will be able to find and join.", {}, {
SpaceName: () => <b>{ parentSpace.name }</b>,
},
) }
</p>;
} else if (joinRule === JoinRule.Public) {
joinRuleMicrocopy = <p>
{ _t(
"Anyone will be able to find and join this space, not just members of <SpaceName/>.", {}, {
SpaceName: () => <b>{ parentSpace.name }</b>,
},
) }
</p>;
} else if (joinRule === JoinRule.Invite) {
joinRuleMicrocopy = <p>
{ _t("Only people invited will be able to find and join this space.") }
</p>;
}
return <BaseDialog
title={(
<SubspaceSelector
title={_t("Create a space")}
space={space}
value={parentSpace}
onChange={setParentSpace}
/>
)}
className="mx_CreateSubspaceDialog"
contentId="mx_CreateSubspaceDialog"
onFinished={onFinished}
fixedWidth={false}
>
<MatrixClientContext.Provider value={space.client}>
<div className="mx_CreateSubspaceDialog_content">
<div className="mx_CreateSubspaceDialog_betaNotice">
<BetaPill />
{ _t("Add a space to a space you manage.") }
</div>
<SpaceCreateForm
busy={busy}
onSubmit={onCreateSubspaceClick}
setAvatar={setAvatar}
name={name}
setName={setName}
nameFieldRef={spaceNameField}
topic={topic}
setTopic={setTopic}
alias={alias}
setAlias={setAlias}
showAliasField={joinRule === JoinRule.Public}
aliasFieldRef={spaceAliasField}
>
<JoinRuleDropdown
label={_t("Space visibility")}
labelInvite={_t("Private space (invite only)")}
labelPublic={_t("Public space")}
labelRestricted={supportsRestricted ? _t("Visible to space members") : undefined}
width={478}
value={joinRule}
onChange={setJoinRule}
/>
{ joinRuleMicrocopy }
</SpaceCreateForm>
</div>
<div className="mx_CreateSubspaceDialog_footer">
<div className="mx_CreateSubspaceDialog_footer_prompt">
<div>{ _t("Want to add an existing space instead?") }</div>
<AccessibleButton
kind="link"
onClick={() => {
onAddExistingSpaceClick();
onFinished();
}}
>
{ _t("Add existing space") }
</AccessibleButton>
</div>
<AccessibleButton kind="primary_outline" disabled={busy} onClick={() => onFinished(false)}>
{ _t("Cancel") }
</AccessibleButton>
<AccessibleButton kind="primary" disabled={busy} onClick={onCreateSubspaceClick}>
{ busy ? _t("Adding...") : _t("Add") }
</AccessibleButton>
</div>
</MatrixClientContext.Provider>
</BaseDialog>;
};
export default CreateSubspaceDialog;

View file

@ -490,9 +490,9 @@ class RoomStateExplorer extends React.PureComponent<IExplorerProps, IRoomStateEx
forceStateEvent={true} forceStateEvent={true}
onBack={this.onBack} onBack={this.onBack}
inputs={{ inputs={{
eventType: this.state.event.getType(), eventType: this.state.event.getType(),
evContent: JSON.stringify(this.state.event.getContent(), null, '\t'), evContent: JSON.stringify(this.state.event.getContent(), null, '\t'),
stateKey: this.state.event.getStateKey(), stateKey: this.state.event.getStateKey(),
}} }}
/>; />;
} }

View file

@ -0,0 +1,101 @@
/*
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, { useState } from "react";
import QuestionDialog from './QuestionDialog';
import { _t } from '../../../languageHandler';
import Field from "../elements/Field";
import SdkConfig from "../../../SdkConfig";
import { IDialogProps } from "./IDialogProps";
import { submitFeedback } from "../../../rageshake/submit-rageshake";
import StyledCheckbox from "../elements/StyledCheckbox";
import Modal from "../../../Modal";
import InfoDialog from "./InfoDialog";
interface IProps extends IDialogProps {
title: string;
subheading: string;
rageshakeLabel: string;
rageshakeData?: Record<string, string>;
}
const GenericFeatureFeedbackDialog: React.FC<IProps> = ({
title,
subheading,
children,
rageshakeLabel,
rageshakeData = {},
onFinished,
}) => {
const [comment, setComment] = useState("");
const [canContact, setCanContact] = useState(false);
const sendFeedback = async (ok: boolean) => {
if (!ok) return onFinished(false);
submitFeedback(SdkConfig.get().bug_report_endpoint_url, rageshakeLabel, comment, canContact, rageshakeData);
onFinished(true);
Modal.createTrackedDialog("Feedback Sent", rageshakeLabel, InfoDialog, {
title,
description: _t("Thank you for your feedback, we really appreciate it."),
button: _t("Done"),
hasCloseButton: false,
fixedWidth: false,
});
};
return (<QuestionDialog
className="mx_GenericFeatureFeedbackDialog"
hasCancelButton={true}
title={title}
description={<React.Fragment>
<div className="mx_GenericFeatureFeedbackDialog_subheading">
{ subheading }
&nbsp;
{ _t("Your platform and username will be noted to help us use your feedback as much as we can.") }
{ children }
</div>
<Field
id="feedbackComment"
label={_t("Feedback")}
type="text"
autoComplete="off"
value={comment}
element="textarea"
onChange={(ev) => {
setComment(ev.target.value);
}}
autoFocus={true}
/>
<StyledCheckbox
checked={canContact}
onChange={e => setCanContact((e.target as HTMLInputElement).checked)}
>
{ _t("You may contact me if you have any follow up questions") }
</StyledCheckbox>
</React.Fragment>}
button={_t("Send feedback")}
buttonDisabled={!comment}
onFinished={sendFeedback}
/>);
};
export default GenericFeatureFeedbackDialog;

View file

@ -20,10 +20,10 @@ import { _t } from '../../../languageHandler';
import SdkConfig from '../../../SdkConfig'; import SdkConfig from '../../../SdkConfig';
export default function KeySignatureUploadFailedDialog({ export default function KeySignatureUploadFailedDialog({
failures, failures,
source, source,
continuation, continuation,
onFinished, onFinished,
}) { }) {
const RETRIES = 2; const RETRIES = 2;
const BaseDialog = sdk.getComponent('dialogs.BaseDialog'); const BaseDialog = sdk.getComponent('dialogs.BaseDialog');

View file

@ -0,0 +1,197 @@
/*
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, { useEffect, useMemo, useState } from "react";
import { Room } from "matrix-js-sdk/src/models/room";
import { JoinRule } from "matrix-js-sdk/src/@types/partials";
import { _t } from '../../../languageHandler';
import DialogButtons from "../elements/DialogButtons";
import BaseDialog from "../dialogs/BaseDialog";
import SpaceStore from "../../../stores/SpaceStore";
import AutoHideScrollbar from "../../structures/AutoHideScrollbar";
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}
autoComplete={true}
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.All);
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.All,
label: _t("Leave all rooms and spaces"),
}, {
value: RoomsToLeave.None,
label: _t("Don't leave any"),
}, {
value: RoomsToLeave.Specific,
label: _t("Leave specific rooms and spaces"),
},
]}
/>
{ 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 {
space: Room;
onFinished(leave: boolean, rooms?: Room[]): void;
}
const isOnlyAdmin = (room: Room): boolean => {
return !room.getJoinedMembers().some(member => {
return member.userId !== room.client.credentials.userId && member.powerLevelNorm === 100;
});
};
const LeaveSpaceDialog: React.FC<IProps> = ({ space, onFinished }) => {
const spaceChildren = useMemo(() => SpaceStore.instance.getChildren(space.roomId), [space.roomId]);
const [roomsToLeave, setRoomsToLeave] = useState<Room[]>([]);
let rejoinWarning;
if (space.getJoinRule() !== JoinRule.Public) {
rejoinWarning = _t("You won't be able to rejoin unless you are re-invited.");
}
let onlyAdminWarning;
if (isOnlyAdmin(space)) {
onlyAdminWarning = _t("You're the only admin of this space. " +
"Leaving it will mean no one has control over it.");
} else {
const numChildrenOnlyAdminIn = roomsToLeave.filter(isOnlyAdmin).length;
if (numChildrenOnlyAdminIn > 0) {
onlyAdminWarning = _t("You're the only admin of some of the rooms or spaces you wish to leave. " +
"Leaving them will leave them without any admins.");
}
}
return <BaseDialog
title={_t("Leave %(spaceName)s", { spaceName: space.name })}
className="mx_LeaveSpaceDialog"
contentId="mx_LeaveSpaceDialog"
onFinished={() => onFinished(false)}
fixedWidth={false}
>
<div className="mx_Dialog_content" id="mx_LeaveSpaceDialog">
<p>
{ _t("Are you sure you want to leave <spaceName/>?", {}, {
spaceName: () => <b>{ space.name }</b>,
}) }
&nbsp;
{ rejoinWarning }
</p>
{ spaceChildren.length > 0 && <LeaveRoomsPicker
space={space}
spaceChildren={spaceChildren}
roomsToLeave={roomsToLeave}
setRoomsToLeave={setRoomsToLeave}
/> }
{ onlyAdminWarning && <div className="mx_LeaveSpaceDialog_section_warning">
{ onlyAdminWarning }
</div> }
</div>
<DialogButtons
primaryButton={_t("Leave space")}
onPrimaryButtonClick={() => onFinished(true, roomsToLeave)}
hasCancel={true}
onCancel={onFinished}
/>
</BaseDialog>;
};
export default LeaveSpaceDialog;

View file

@ -16,6 +16,7 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import { IKeyBackupInfo } from "matrix-js-sdk/src/crypto/keybackup";
import Modal from '../../../Modal'; import Modal from '../../../Modal';
import * as sdk from '../../../index'; import * as sdk from '../../../index';
import dis from '../../../dispatcher/dispatcher'; import dis from '../../../dispatcher/dispatcher';
@ -24,19 +25,25 @@ import { MatrixClientPeg } from '../../../MatrixClientPeg';
import RestoreKeyBackupDialog from './security/RestoreKeyBackupDialog'; import RestoreKeyBackupDialog from './security/RestoreKeyBackupDialog';
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
interface IProps {
onFinished: (success: boolean) => void;
}
interface IState {
shouldLoadBackupStatus: boolean;
loading: boolean;
backupInfo: IKeyBackupInfo;
error?: string;
}
@replaceableComponent("views.dialogs.LogoutDialog") @replaceableComponent("views.dialogs.LogoutDialog")
export default class LogoutDialog extends React.Component { export default class LogoutDialog extends React.Component<IProps, IState> {
defaultProps = { static defaultProps = {
onFinished: function() {}, onFinished: function() {},
}; };
constructor() { constructor(props) {
super(); super(props);
this._onSettingsLinkClick = this._onSettingsLinkClick.bind(this);
this._onExportE2eKeysClicked = this._onExportE2eKeysClicked.bind(this);
this._onFinished = this._onFinished.bind(this);
this._onSetRecoveryMethodClick = this._onSetRecoveryMethodClick.bind(this);
this._onLogoutConfirm = this._onLogoutConfirm.bind(this);
const cli = MatrixClientPeg.get(); const cli = MatrixClientPeg.get();
const shouldLoadBackupStatus = cli.isCryptoEnabled() && !cli.getKeyBackupEnabled(); const shouldLoadBackupStatus = cli.isCryptoEnabled() && !cli.getKeyBackupEnabled();
@ -49,11 +56,11 @@ export default class LogoutDialog extends React.Component {
}; };
if (shouldLoadBackupStatus) { if (shouldLoadBackupStatus) {
this._loadBackupStatus(); this.loadBackupStatus();
} }
} }
async _loadBackupStatus() { private async loadBackupStatus() {
try { try {
const backupInfo = await MatrixClientPeg.get().getKeyBackupVersion(); const backupInfo = await MatrixClientPeg.get().getKeyBackupVersion();
this.setState({ this.setState({
@ -69,29 +76,29 @@ export default class LogoutDialog extends React.Component {
} }
} }
_onSettingsLinkClick() { private onSettingsLinkClick = (): void => {
// close dialog // close dialog
this.props.onFinished(); this.props.onFinished(true);
} };
_onExportE2eKeysClicked() { private onExportE2eKeysClicked = (): void => {
Modal.createTrackedDialogAsync('Export E2E Keys', '', Modal.createTrackedDialogAsync('Export E2E Keys', '',
import('../../../async-components/views/dialogs/security/ExportE2eKeysDialog'), import('../../../async-components/views/dialogs/security/ExportE2eKeysDialog'),
{ {
matrixClient: MatrixClientPeg.get(), matrixClient: MatrixClientPeg.get(),
}, },
); );
} };
_onFinished(confirmed) { private onFinished = (confirmed: boolean): void => {
if (confirmed) { if (confirmed) {
dis.dispatch({ action: 'logout' }); dis.dispatch({ action: 'logout' });
} }
// close dialog // close dialog
this.props.onFinished(); this.props.onFinished(confirmed);
} };
_onSetRecoveryMethodClick() { private onSetRecoveryMethodClick = (): void => {
if (this.state.backupInfo) { if (this.state.backupInfo) {
// A key backup exists for this account, but the creating device is not // A key backup exists for this account, but the creating device is not
// verified, so restore the backup which will give us the keys from it and // verified, so restore the backup which will give us the keys from it and
@ -108,15 +115,15 @@ export default class LogoutDialog extends React.Component {
} }
// close dialog // close dialog
this.props.onFinished(); this.props.onFinished(true);
} };
_onLogoutConfirm() { private onLogoutConfirm = (): void => {
dis.dispatch({ action: 'logout' }); dis.dispatch({ action: 'logout' });
// close dialog // close dialog
this.props.onFinished(); this.props.onFinished(true);
} };
render() { render() {
if (this.state.shouldLoadBackupStatus) { if (this.state.shouldLoadBackupStatus) {
@ -152,16 +159,16 @@ export default class LogoutDialog extends React.Component {
</div> </div>
<DialogButtons primaryButton={setupButtonCaption} <DialogButtons primaryButton={setupButtonCaption}
hasCancel={false} hasCancel={false}
onPrimaryButtonClick={this._onSetRecoveryMethodClick} onPrimaryButtonClick={this.onSetRecoveryMethodClick}
focus={true} focus={true}
> >
<button onClick={this._onLogoutConfirm}> <button onClick={this.onLogoutConfirm}>
{ _t("I don't want my encrypted messages") } { _t("I don't want my encrypted messages") }
</button> </button>
</DialogButtons> </DialogButtons>
<details> <details>
<summary>{ _t("Advanced") }</summary> <summary>{ _t("Advanced") }</summary>
<p><button onClick={this._onExportE2eKeysClicked}> <p><button onClick={this.onExportE2eKeysClicked}>
{ _t("Manually export keys") } { _t("Manually export keys") }
</button></p> </button></p>
</details> </details>
@ -174,7 +181,7 @@ export default class LogoutDialog extends React.Component {
title={_t("You'll lose access to your encrypted messages")} title={_t("You'll lose access to your encrypted messages")}
contentId='mx_Dialog_content' contentId='mx_Dialog_content'
hasCancel={true} hasCancel={true}
onFinished={this._onFinished} onFinished={this.onFinished}
> >
{ dialogContent } { dialogContent }
</BaseDialog>); </BaseDialog>);
@ -187,7 +194,7 @@ export default class LogoutDialog extends React.Component {
"Are you sure you want to sign out?", "Are you sure you want to sign out?",
)} )}
button={_t("Sign out")} button={_t("Sign out")}
onFinished={this._onFinished} onFinished={this.onFinished}
/>); />);
} }
} }

View file

@ -0,0 +1,68 @@
/*
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 from 'react';
import { JoinRule } from 'matrix-js-sdk/src/@types/partials';
import Dropdown from "./Dropdown";
interface IProps {
value: JoinRule;
label: string;
width?: number;
labelInvite: string;
labelPublic: string;
labelRestricted?: string; // if omitted then this option will be hidden, e.g if unsupported
onChange(value: JoinRule): void;
}
const JoinRuleDropdown = ({
label,
labelInvite,
labelPublic,
labelRestricted,
value,
width = 448,
onChange,
}: IProps) => {
const options = [
<div key={JoinRule.Invite} className="mx_JoinRuleDropdown_invite">
{ labelInvite }
</div>,
<div key={JoinRule.Public} className="mx_JoinRuleDropdown_public">
{ labelPublic }
</div>,
];
if (labelRestricted) {
options.unshift(<div key={JoinRule.Restricted} className="mx_JoinRuleDropdown_restricted">
{ labelRestricted }
</div>);
}
return <Dropdown
id="mx_JoinRuleDropdown"
className="mx_JoinRuleDropdown"
onOptionChange={onChange}
menuWidth={width}
value={value}
label={label}
>
{ options }
</Dropdown>;
};
export default JoinRuleDropdown;

View file

@ -192,7 +192,8 @@ class Pill extends React.Component {
}); });
} }
onUserPillClicked = () => { onUserPillClicked = (e) => {
e.preventDefault();
dis.dispatch({ dis.dispatch({
action: Action.ViewUser, action: Action.ViewUser,
member: this.state.member, member: this.state.member,

View file

@ -25,7 +25,6 @@ import { CallErrorCode, CallState } from 'matrix-js-sdk/src/webrtc/call';
import InfoTooltip, { InfoTooltipKind } from '../elements/InfoTooltip'; import InfoTooltip, { InfoTooltipKind } from '../elements/InfoTooltip';
import classNames from 'classnames'; import classNames from 'classnames';
import AccessibleTooltipButton from '../elements/AccessibleTooltipButton'; import AccessibleTooltipButton from '../elements/AccessibleTooltipButton';
import { MatrixClientPeg } from '../../../MatrixClientPeg';
interface IProps { interface IProps {
mxEvent: MatrixEvent; mxEvent: MatrixEvent;
@ -117,14 +116,12 @@ export default class CallEvent extends React.Component<IProps, IState> {
if (state === CallState.Ended) { if (state === CallState.Ended) {
const hangupReason = this.props.callEventGrouper.hangupReason; const hangupReason = this.props.callEventGrouper.hangupReason;
const gotRejected = this.props.callEventGrouper.gotRejected; const gotRejected = this.props.callEventGrouper.gotRejected;
const rejectParty = this.props.callEventGrouper.rejectParty;
if (gotRejected) { if (gotRejected) {
const weDeclinedCall = MatrixClientPeg.get().getUserId() === rejectParty;
return ( return (
<div className="mx_CallEvent_content"> <div className="mx_CallEvent_content">
{ weDeclinedCall ? _t("You declined this call") : _t("They declined this call") } { _t("Call declined") }
{ this.renderCallBackButton(weDeclinedCall ? _t("Call back") : _t("Call again")) } { this.renderCallBackButton(_t("Call back")) }
</div> </div>
); );
} else if (([CallErrorCode.UserHangup, "user hangup"].includes(hangupReason) || !hangupReason)) { } else if (([CallErrorCode.UserHangup, "user hangup"].includes(hangupReason) || !hangupReason)) {
@ -136,14 +133,14 @@ export default class CallEvent extends React.Component<IProps, IState> {
// Also, if we don't have a reason // Also, if we don't have a reason
return ( return (
<div className="mx_CallEvent_content"> <div className="mx_CallEvent_content">
{ _t("This call has ended") } { _t("Call ended") }
</div> </div>
); );
} else if (hangupReason === CallErrorCode.InviteTimeout) { } else if (hangupReason === CallErrorCode.InviteTimeout) {
return ( return (
<div className="mx_CallEvent_content"> <div className="mx_CallEvent_content">
{ _t("They didn't pick up") } { _t("Missed call") }
{ this.renderCallBackButton(_t("Call again")) } { this.renderCallBackButton(_t("Call back")) }
</div> </div>
); );
} }
@ -176,7 +173,8 @@ export default class CallEvent extends React.Component<IProps, IState> {
className="mx_CallEvent_content_tooltip" className="mx_CallEvent_content_tooltip"
kind={InfoTooltipKind.Warning} kind={InfoTooltipKind.Warning}
/> />
{ _t("This call has failed") } { _t("Connection failed") }
{ this.renderCallBackButton(_t("Retry")) }
</div> </div>
); );
} }
@ -190,7 +188,7 @@ export default class CallEvent extends React.Component<IProps, IState> {
if (state === CustomCallState.Missed) { if (state === CustomCallState.Missed) {
return ( return (
<div className="mx_CallEvent_content"> <div className="mx_CallEvent_content">
{ _t("You missed this call") } { _t("Missed call") }
{ this.renderCallBackButton(_t("Call back")) } { this.renderCallBackButton(_t("Call back")) }
</div> </div>
); );

View file

@ -16,12 +16,13 @@ limitations under the License.
import { MatrixEvent } from "matrix-js-sdk/src"; import { MatrixEvent } from "matrix-js-sdk/src";
import { MediaEventHelper } from "../../../utils/MediaEventHelper"; import { MediaEventHelper } from "../../../utils/MediaEventHelper";
import React, { createRef } from "react"; import React from "react";
import { RovingAccessibleTooltipButton } from "../../../accessibility/RovingTabIndex"; import { RovingAccessibleTooltipButton } from "../../../accessibility/RovingTabIndex";
import Spinner from "../elements/Spinner"; import Spinner from "../elements/Spinner";
import classNames from "classnames"; import classNames from "classnames";
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import { FileDownloader } from "../../../utils/FileDownloader";
interface IProps { interface IProps {
mxEvent: MatrixEvent; mxEvent: MatrixEvent;
@ -39,7 +40,7 @@ interface IState {
@replaceableComponent("views.messages.DownloadActionButton") @replaceableComponent("views.messages.DownloadActionButton")
export default class DownloadActionButton extends React.PureComponent<IProps, IState> { export default class DownloadActionButton extends React.PureComponent<IProps, IState> {
private iframe: React.RefObject<HTMLIFrameElement> = createRef(); private downloader = new FileDownloader();
public constructor(props: IProps) { public constructor(props: IProps) {
super(props); super(props);
@ -56,27 +57,21 @@ export default class DownloadActionButton extends React.PureComponent<IProps, IS
if (this.state.blob) { if (this.state.blob) {
// Cheat and trigger a download, again. // Cheat and trigger a download, again.
return this.onFrameLoad(); return this.doDownload();
} }
const blob = await this.props.mediaEventHelperGet().sourceBlob.value; const blob = await this.props.mediaEventHelperGet().sourceBlob.value;
this.setState({ blob }); this.setState({ blob });
await this.doDownload();
}; };
private onFrameLoad = () => { private async doDownload() {
this.setState({ loading: false }); await this.downloader.download({
// we aren't showing the iframe, so we can send over the bare minimum styles and such.
this.iframe.current.contentWindow.postMessage({
imgSrc: "", // no image
imgStyle: null,
style: "",
blob: this.state.blob, blob: this.state.blob,
download: this.props.mediaEventHelperGet().fileName, name: this.props.mediaEventHelperGet().fileName,
textContent: "", });
auto: true, // autodownload this.setState({ loading: false });
}, '*'); }
};
public render() { public render() {
let spinner: JSX.Element; let spinner: JSX.Element;
@ -92,18 +87,11 @@ export default class DownloadActionButton extends React.PureComponent<IProps, IS
return <RovingAccessibleTooltipButton return <RovingAccessibleTooltipButton
className={classes} className={classes}
title={spinner ? _t("Downloading") : _t("Download")} title={spinner ? _t("Decrypting") : _t("Download")}
onClick={this.onDownloadClick} onClick={this.onDownloadClick}
disabled={!!spinner} disabled={!!spinner}
> >
{ spinner } { spinner }
{ this.state.blob && <iframe
src="usercontent/" // XXX: Like MFileBody, this should come from the skin
ref={this.iframe}
onLoad={this.onFrameLoad}
sandbox="allow-scripts allow-downloads allow-downloads-without-user-activation"
style={{ display: "none" }}
/> }
</RovingAccessibleTooltipButton>; </RovingAccessibleTooltipButton>;
} }
} }

View file

@ -26,6 +26,8 @@ import { TileShape } from "../rooms/EventTile";
import { presentableTextForFile } from "../../../utils/FileUtils"; import { presentableTextForFile } from "../../../utils/FileUtils";
import { IMediaEventContent } from "../../../customisations/models/IMediaEventContent"; import { IMediaEventContent } from "../../../customisations/models/IMediaEventContent";
import { IBodyProps } from "./IBodyProps"; import { IBodyProps } from "./IBodyProps";
import { FileDownloader } from "../../../utils/FileDownloader";
import TextWithTooltip from "../elements/TextWithTooltip";
export let DOWNLOAD_ICON_URL; // cached copy of the download.svg asset for the sandboxed iframe later on export let DOWNLOAD_ICON_URL; // cached copy of the download.svg asset for the sandboxed iframe later on
@ -111,6 +113,7 @@ export default class MFileBody extends React.Component<IProps, IState> {
private iframe: React.RefObject<HTMLIFrameElement> = createRef(); private iframe: React.RefObject<HTMLIFrameElement> = createRef();
private dummyLink: React.RefObject<HTMLAnchorElement> = createRef(); private dummyLink: React.RefObject<HTMLAnchorElement> = createRef();
private userDidClick = false; private userDidClick = false;
private fileDownloader: FileDownloader = new FileDownloader(() => this.iframe.current);
public constructor(props: IProps) { public constructor(props: IProps) {
super(props); super(props);
@ -118,6 +121,32 @@ export default class MFileBody extends React.Component<IProps, IState> {
this.state = {}; this.state = {};
} }
private get content(): IMediaEventContent {
return this.props.mxEvent.getContent<IMediaEventContent>();
}
private get fileName(): string {
return this.content.body && this.content.body.length > 0 ? this.content.body : _t("Attachment");
}
private get linkText(): string {
return presentableTextForFile(this.content);
}
private downloadFile(fileName: string, text: string) {
this.fileDownloader.download({
blob: this.state.decryptedBlob,
name: fileName,
autoDownload: this.userDidClick,
opts: {
imgSrc: DOWNLOAD_ICON_URL,
imgStyle: null,
style: computedStyle(this.dummyLink.current),
textContent: _t("Download %(text)s", { text }),
},
});
}
private getContentUrl(): string { private getContentUrl(): string {
const media = mediaFromContent(this.props.mxEvent.getContent()); const media = mediaFromContent(this.props.mxEvent.getContent());
return media.srcHttp; return media.srcHttp;
@ -129,24 +158,56 @@ export default class MFileBody extends React.Component<IProps, IState> {
} }
} }
public render() { private decryptFile = async (): Promise<void> => {
const content = this.props.mxEvent.getContent<IMediaEventContent>(); if (this.state.decryptedBlob) {
const text = presentableTextForFile(content); return;
const isEncrypted = this.props.mediaEventHelper.media.isEncrypted; }
const fileName = content.body && content.body.length > 0 ? content.body : _t("Attachment"); try {
const contentUrl = this.getContentUrl(); this.userDidClick = true;
const fileSize = content.info ? content.info.size : null; this.setState({
const fileType = content.info ? content.info.mimetype : "application/octet-stream"; decryptedBlob: await this.props.mediaEventHelper.sourceBlob.value,
});
} catch (err) {
console.warn("Unable to decrypt attachment: ", err);
Modal.createTrackedDialog('Error decrypting attachment', '', ErrorDialog, {
title: _t("Error"),
description: _t("Error decrypting attachment"),
});
}
};
let placeholder = null; private onPlaceholderClick = async () => {
const mediaHelper = this.props.mediaEventHelper;
if (mediaHelper.media.isEncrypted) {
await this.decryptFile();
this.downloadFile(this.fileName, this.linkText);
} else {
// As a button we're missing the `download` attribute for styling reasons, so
// download with the file downloader.
this.fileDownloader.download({
blob: await mediaHelper.sourceBlob.value,
name: this.fileName,
});
}
};
public render() {
const isEncrypted = this.props.mediaEventHelper.media.isEncrypted;
const contentUrl = this.getContentUrl();
const fileSize = this.content.info ? this.content.info.size : null;
const fileType = this.content.info ? this.content.info.mimetype : "application/octet-stream";
let placeholder: React.ReactNode = null;
if (this.props.showGenericPlaceholder) { if (this.props.showGenericPlaceholder) {
placeholder = ( placeholder = (
<div className="mx_MediaBody mx_MFileBody_info"> <AccessibleButton className="mx_MediaBody mx_MFileBody_info" onClick={this.onPlaceholderClick}>
<span className="mx_MFileBody_info_icon" /> <span className="mx_MFileBody_info_icon" />
<span className="mx_MFileBody_info_filename"> <TextWithTooltip tooltip={presentableTextForFile(this.content, _t("Attachment"), true)}>
{ presentableTextForFile(content, _t("Attachment"), false) } <span className="mx_MFileBody_info_filename">
</span> { presentableTextForFile(this.content, _t("Attachment"), true, true) }
</div> </span>
</TextWithTooltip>
</AccessibleButton>
); );
} }
@ -157,20 +218,6 @@ export default class MFileBody extends React.Component<IProps, IState> {
// Need to decrypt the attachment // Need to decrypt the attachment
// Wait for the user to click on the link before downloading // Wait for the user to click on the link before downloading
// and decrypting the attachment. // and decrypting the attachment.
const decrypt = async () => {
try {
this.userDidClick = true;
this.setState({
decryptedBlob: await this.props.mediaEventHelper.sourceBlob.value,
});
} catch (err) {
console.warn("Unable to decrypt attachment: ", err);
Modal.createTrackedDialog('Error decrypting attachment', '', ErrorDialog, {
title: _t("Error"),
description: _t("Error decrypting attachment"),
});
}
};
// This button should actually Download because usercontent/ will try to click itself // This button should actually Download because usercontent/ will try to click itself
// but it is not guaranteed between various browsers' settings. // but it is not guaranteed between various browsers' settings.
@ -178,31 +225,14 @@ export default class MFileBody extends React.Component<IProps, IState> {
<span className="mx_MFileBody"> <span className="mx_MFileBody">
{ placeholder } { placeholder }
{ showDownloadLink && <div className="mx_MFileBody_download"> { showDownloadLink && <div className="mx_MFileBody_download">
<AccessibleButton onClick={decrypt}> <AccessibleButton onClick={this.decryptFile}>
{ _t("Decrypt %(text)s", { text: text }) } { _t("Decrypt %(text)s", { text: this.linkText }) }
</AccessibleButton> </AccessibleButton>
</div> } </div> }
</span> </span>
); );
} }
// When the iframe loads we tell it to render a download link
const onIframeLoad = (ev) => {
ev.target.contentWindow.postMessage({
imgSrc: DOWNLOAD_ICON_URL,
imgStyle: null, // it handles this internally for us. Useful if a downstream changes the icon.
style: computedStyle(this.dummyLink.current),
blob: this.state.decryptedBlob,
// Set a download attribute for encrypted files so that the file
// will have the correct name when the user tries to download it.
// We can't provide a Content-Disposition header like we would for HTTP.
download: fileName,
textContent: _t("Download %(text)s", { text: text }),
// only auto-download if a user triggered this iframe explicitly
auto: this.userDidClick,
}, "*");
};
const url = "usercontent/"; // XXX: this path should probably be passed from the skin const url = "usercontent/"; // XXX: this path should probably be passed from the skin
// If the attachment is encrypted then put the link inside an iframe. // If the attachment is encrypted then put the link inside an iframe.
@ -218,9 +248,16 @@ export default class MFileBody extends React.Component<IProps, IState> {
*/ } */ }
<a ref={this.dummyLink} /> <a ref={this.dummyLink} />
</div> </div>
{ /*
TODO: Move iframe (and dummy link) into FileDownloader.
We currently have it set up this way because of styles applied to the iframe
itself which cannot be easily handled/overridden by the FileDownloader. In
future, the download link may disappear entirely at which point it could also
be suitable to just remove this bit of code.
*/ }
<iframe <iframe
src={url} src={url}
onLoad={onIframeLoad} onLoad={() => this.downloadFile(this.fileName, this.linkText)}
ref={this.iframe} ref={this.iframe}
sandbox="allow-scripts allow-downloads allow-downloads-without-user-activation" /> sandbox="allow-scripts allow-downloads allow-downloads-without-user-activation" />
</div> } </div> }
@ -259,7 +296,7 @@ export default class MFileBody extends React.Component<IProps, IState> {
// We have to create an anchor to download the file // We have to create an anchor to download the file
const tempAnchor = document.createElement('a'); const tempAnchor = document.createElement('a');
tempAnchor.download = fileName; tempAnchor.download = this.fileName;
tempAnchor.href = blobUrl; tempAnchor.href = blobUrl;
document.body.appendChild(tempAnchor); // for firefox: https://stackoverflow.com/a/32226068 document.body.appendChild(tempAnchor); // for firefox: https://stackoverflow.com/a/32226068
tempAnchor.click(); tempAnchor.click();
@ -268,7 +305,7 @@ export default class MFileBody extends React.Component<IProps, IState> {
}; };
} else { } else {
// Else we are hoping the browser will do the right thing // Else we are hoping the browser will do the right thing
downloadProps["download"] = fileName; downloadProps["download"] = this.fileName;
} }
return ( return (
@ -277,16 +314,16 @@ export default class MFileBody extends React.Component<IProps, IState> {
{ showDownloadLink && <div className="mx_MFileBody_download"> { showDownloadLink && <div className="mx_MFileBody_download">
<a {...downloadProps}> <a {...downloadProps}>
<span className="mx_MFileBody_download_icon" /> <span className="mx_MFileBody_download_icon" />
{ _t("Download %(text)s", { text: text }) } { _t("Download %(text)s", { text: this.linkText }) }
</a> </a>
{ this.props.tileShape === TileShape.FileGrid && <div className="mx_MImageBody_size"> { this.props.tileShape === TileShape.FileGrid && <div className="mx_MImageBody_size">
{ content.info && content.info.size ? filesize(content.info.size) : "" } { this.content.info && this.content.info.size ? filesize(this.content.info.size) : "" }
</div> } </div> }
</div> } </div> }
</span> </span>
); );
} else { } else {
const extra = text ? (': ' + text) : ''; const extra = this.linkText ? (': ' + this.linkText) : '';
return <span className="mx_MFileBody"> return <span className="mx_MFileBody">
{ placeholder } { placeholder }
{ _t("Invalid file%(extra)s", { extra: extra }) } { _t("Invalid file%(extra)s", { extra: extra }) }

View file

@ -366,7 +366,7 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
} }
const thumbnail = ( const thumbnail = (
<div className="mx_MImageBody_thumbnail_container" style={{ maxHeight: maxHeight + "px", maxWidth: maxWidth + "px" }}> <div className="mx_MImageBody_thumbnail_container" style={{ maxHeight: maxHeight, maxWidth: maxWidth, aspectRatio: `${infoWidth}/${infoHeight}` }}>
{ showPlaceholder && { showPlaceholder &&
<div <div
className="mx_MImageBody_thumbnail" className="mx_MImageBody_thumbnail"

View file

@ -17,7 +17,6 @@ limitations under the License.
import React from "react"; import React from "react";
import MAudioBody from "./MAudioBody"; import MAudioBody from "./MAudioBody";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import SettingsStore from "../../../settings/SettingsStore";
import MVoiceMessageBody from "./MVoiceMessageBody"; import MVoiceMessageBody from "./MVoiceMessageBody";
import { IBodyProps } from "./IBodyProps"; import { IBodyProps } from "./IBodyProps";
@ -27,8 +26,7 @@ export default class MVoiceOrAudioBody extends React.PureComponent<IBodyProps> {
// MSC2516 is a legacy identifier. See https://github.com/matrix-org/matrix-doc/pull/3245 // MSC2516 is a legacy identifier. See https://github.com/matrix-org/matrix-doc/pull/3245
const isVoiceMessage = !!this.props.mxEvent.getContent()['org.matrix.msc2516.voice'] const isVoiceMessage = !!this.props.mxEvent.getContent()['org.matrix.msc2516.voice']
|| !!this.props.mxEvent.getContent()['org.matrix.msc3245.voice']; || !!this.props.mxEvent.getContent()['org.matrix.msc3245.voice'];
const voiceMessagesEnabled = SettingsStore.getValue("feature_voice_messages"); if (isVoiceMessage) {
if (isVoiceMessage && voiceMessagesEnabled) {
return <MVoiceMessageBody {...this.props} />; return <MVoiceMessageBody {...this.props} />;
} else { } else {
return <MAudioBody {...this.props} />; return <MAudioBody {...this.props} />;

View file

@ -15,18 +15,21 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import { MatrixEvent } from 'matrix-js-sdk/src';
import classNames from 'classnames'; import classNames from 'classnames';
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { MatrixClientPeg } from "../../../MatrixClientPeg";
@replaceableComponent("views.messages.ViewSourceEvent") interface IProps {
export default class ViewSourceEvent extends React.PureComponent { mxEvent: MatrixEvent;
static propTypes = { }
/* the MatrixEvent to show */
mxEvent: PropTypes.object.isRequired,
};
interface IState {
expanded: boolean;
}
@replaceableComponent("views.messages.ViewSourceEvent")
export default class ViewSourceEvent extends React.PureComponent<IProps, IState> {
constructor(props) { constructor(props) {
super(props); super(props);
@ -35,7 +38,7 @@ export default class ViewSourceEvent extends React.PureComponent {
}; };
} }
componentDidMount() { public componentDidMount(): void {
const { mxEvent } = this.props; const { mxEvent } = this.props;
const client = MatrixClientPeg.get(); const client = MatrixClientPeg.get();
@ -46,15 +49,15 @@ export default class ViewSourceEvent extends React.PureComponent {
} }
} }
onToggle = (ev) => { private onToggle = (ev: React.MouseEvent) => {
ev.preventDefault(); ev.preventDefault();
const { expanded } = this.state; const { expanded } = this.state;
this.setState({ this.setState({
expanded: !expanded, expanded: !expanded,
}); });
} };
render() { public render(): React.ReactNode {
const { mxEvent } = this.props; const { mxEvent } = this.props;
const { expanded } = this.state; const { expanded } = this.state;

View file

@ -152,7 +152,7 @@ const PinnedMessagesCard = ({ room, onClose }: IProps) => {
<h2>{ _t("Nothing pinned, yet") }</h2> <h2>{ _t("Nothing pinned, yet") }</h2>
{ _t("If you have permissions, open the menu on any message and select " + { _t("If you have permissions, open the menu on any message and select " +
"<b>Pin</b> to stick them here.", {}, { "<b>Pin</b> to stick them here.", {}, {
b: sub => <b>{ sub }</b>, b: sub => <b>{ sub }</b>,
}) } }) }
</div> </div>
</div>; </div>;

View file

@ -1381,8 +1381,8 @@ const BasicUserInfo: React.FC<{
className="mx_UserInfo_field" className="mx_UserInfo_field"
onClick={() => { onClick={() => {
dis.dispatch({ dis.dispatch({
action: Action.ViewUserSettings, action: Action.ViewUserSettings,
initialTabId: UserTab.Security, initialTabId: UserTab.Security,
}); });
}} }}
> >

View file

@ -55,6 +55,14 @@ const REGEX_EMOTICON_WHITESPACE = new RegExp('(?:^|\\s)(' + EMOTICON_REGEX.sourc
const IS_MAC = navigator.platform.indexOf("Mac") !== -1; const IS_MAC = navigator.platform.indexOf("Mac") !== -1;
const SURROUND_WITH_CHARACTERS = ["\"", "_", "`", "'", "*", "~", "$"];
const SURROUND_WITH_DOUBLE_CHARACTERS = new Map([
["(", ")"],
["[", "]"],
["{", "}"],
["<", ">"],
]);
function ctrlShortcutLabel(key: string): string { function ctrlShortcutLabel(key: string): string {
return (IS_MAC ? "⌘" : "Ctrl") + "+" + key; return (IS_MAC ? "⌘" : "Ctrl") + "+" + key;
} }
@ -99,6 +107,7 @@ interface IState {
showVisualBell?: boolean; showVisualBell?: boolean;
autoComplete?: AutocompleteWrapperModel; autoComplete?: AutocompleteWrapperModel;
completionIndex?: number; completionIndex?: number;
surroundWith: boolean;
} }
@replaceableComponent("views.rooms.BasicMessageEditor") @replaceableComponent("views.rooms.BasicMessageEditor")
@ -117,12 +126,14 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
private readonly emoticonSettingHandle: string; private readonly emoticonSettingHandle: string;
private readonly shouldShowPillAvatarSettingHandle: string; private readonly shouldShowPillAvatarSettingHandle: string;
private readonly surroundWithHandle: string;
private readonly historyManager = new HistoryManager(); private readonly historyManager = new HistoryManager();
constructor(props) { constructor(props) {
super(props); super(props);
this.state = { this.state = {
showPillAvatar: SettingsStore.getValue("Pill.shouldShowPillAvatar"), showPillAvatar: SettingsStore.getValue("Pill.shouldShowPillAvatar"),
surroundWith: SettingsStore.getValue("MessageComposerInput.surroundWith"),
}; };
this.emoticonSettingHandle = SettingsStore.watchSetting('MessageComposerInput.autoReplaceEmoji', null, this.emoticonSettingHandle = SettingsStore.watchSetting('MessageComposerInput.autoReplaceEmoji', null,
@ -130,6 +141,8 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
this.configureEmoticonAutoReplace(); this.configureEmoticonAutoReplace();
this.shouldShowPillAvatarSettingHandle = SettingsStore.watchSetting("Pill.shouldShowPillAvatar", null, this.shouldShowPillAvatarSettingHandle = SettingsStore.watchSetting("Pill.shouldShowPillAvatar", null,
this.configureShouldShowPillAvatar); this.configureShouldShowPillAvatar);
this.surroundWithHandle = SettingsStore.watchSetting("MessageComposerInput.surroundWith", null,
this.surroundWithSettingChanged);
} }
public componentDidUpdate(prevProps: IProps) { public componentDidUpdate(prevProps: IProps) {
@ -422,6 +435,28 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
private onKeyDown = (event: React.KeyboardEvent): void => { private onKeyDown = (event: React.KeyboardEvent): void => {
const model = this.props.model; const model = this.props.model;
let handled = false; let handled = false;
if (this.state.surroundWith && document.getSelection().type != "Caret") {
// This surrounds the selected text with a character. This is
// intentionally left out of the keybinding manager as the keybinds
// here shouldn't be changeable
const selectionRange = getRangeForSelection(
this.editorRef.current,
this.props.model,
document.getSelection(),
);
// trim the range as we want it to exclude leading/trailing spaces
selectionRange.trim();
if ([...SURROUND_WITH_DOUBLE_CHARACTERS.keys(), ...SURROUND_WITH_CHARACTERS].includes(event.key)) {
this.historyManager.ensureLastChangesPushed(this.props.model);
this.modifiedFlag = true;
toggleInlineFormat(selectionRange, event.key, SURROUND_WITH_DOUBLE_CHARACTERS.get(event.key));
handled = true;
}
}
const action = getKeyBindingsManager().getMessageComposerAction(event); const action = getKeyBindingsManager().getMessageComposerAction(event);
switch (action) { switch (action) {
case MessageComposerAction.FormatBold: case MessageComposerAction.FormatBold:
@ -574,6 +609,11 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
this.setState({ showPillAvatar }); this.setState({ showPillAvatar });
}; };
private surroundWithSettingChanged = () => {
const surroundWith = SettingsStore.getValue("MessageComposerInput.surroundWith");
this.setState({ surroundWith });
};
componentWillUnmount() { componentWillUnmount() {
document.removeEventListener("selectionchange", this.onSelectionChange); document.removeEventListener("selectionchange", this.onSelectionChange);
this.editorRef.current.removeEventListener("input", this.onInput, true); this.editorRef.current.removeEventListener("input", this.onInput, true);
@ -581,6 +621,7 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
this.editorRef.current.removeEventListener("compositionend", this.onCompositionEnd, true); this.editorRef.current.removeEventListener("compositionend", this.onCompositionEnd, true);
SettingsStore.unwatchSetting(this.emoticonSettingHandle); SettingsStore.unwatchSetting(this.emoticonSettingHandle);
SettingsStore.unwatchSetting(this.shouldShowPillAvatarSettingHandle); SettingsStore.unwatchSetting(this.shouldShowPillAvatarSettingHandle);
SettingsStore.unwatchSetting(this.surroundWithHandle);
} }
componentDidMount() { componentDidMount() {

View file

@ -394,12 +394,10 @@ export default class MessageComposer extends React.Component<IProps, IState> {
controls.push(<Stickerpicker key="stickerpicker_controls_button" room={this.props.room} />); controls.push(<Stickerpicker key="stickerpicker_controls_button" room={this.props.room} />);
} }
if (SettingsStore.getValue("feature_voice_messages")) { controls.push(<VoiceRecordComposerTile
controls.push(<VoiceRecordComposerTile key="controls_voice_record"
key="controls_voice_record" ref={c => this.voiceRecordingButton = c}
ref={c => this.voiceRecordingButton = c} room={this.props.room} />);
room={this.props.room} />);
}
if (!this.state.isComposerEmpty || this.state.haveRecording) { if (!this.state.isComposerEmpty || this.state.haveRecording) {
controls.push( controls.push(

View file

@ -64,9 +64,9 @@ const NewRoomIntro = () => {
height={AVATAR_SIZE} height={AVATAR_SIZE}
onClick={() => { onClick={() => {
defaultDispatcher.dispatch<ViewUserPayload>({ defaultDispatcher.dispatch<ViewUserPayload>({
action: Action.ViewUser, action: Action.ViewUser,
// XXX: We should be using a real member object and not assuming what the receiver wants. // XXX: We should be using a real member object and not assuming what the receiver wants.
member: member || { userId: dmPartner } as User, member: member || { userId: dmPartner } as User,
}); });
}} }}
/> />

View file

@ -145,7 +145,7 @@ export default class ReadReceiptMarker extends React.PureComponent {
if (oldInfo && oldInfo.left) { if (oldInfo && oldInfo.left) {
// start at the old height and in the old h pos // start at the old height and in the old h pos
startStyles.push({ top: startTopOffset+"px", startStyles.push({ top: startTopOffset+"px",
left: toPx(oldInfo.left) }); left: toPx(oldInfo.left) });
} }
startStyles.push({ top: startTopOffset+'px', left: '0' }); startStyles.push({ top: startTopOffset+'px', left: '0' });
@ -174,14 +174,14 @@ export default class ReadReceiptMarker extends React.PureComponent {
title = _t( title = _t(
"Seen by %(userName)s at %(dateTime)s", "Seen by %(userName)s at %(dateTime)s",
{ userName: this.props.fallbackUserId, { userName: this.props.fallbackUserId,
dateTime: dateString }, dateTime: dateString },
); );
} else { } else {
title = _t( title = _t(
"Seen by %(displayName)s (%(userName)s) at %(dateTime)s", "Seen by %(displayName)s (%(userName)s) at %(dateTime)s",
{ displayName: this.props.member.rawDisplayName, { displayName: this.props.member.rawDisplayName,
userName: this.props.fallbackUserId, userName: this.props.fallbackUserId,
dateTime: dateString }, dateTime: dateString },
); );
} }
} }

View file

@ -17,10 +17,7 @@ limitations under the License.
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import React, { ReactNode } from "react"; import React, { ReactNode } from "react";
import { import { IUpload, RecordingState, VoiceRecording } from "../../../audio/VoiceRecording";
RecordingState,
VoiceRecording,
} from "../../../audio/VoiceRecording";
import { Room } from "matrix-js-sdk/src/models/room"; import { Room } from "matrix-js-sdk/src/models/room";
import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { MatrixClientPeg } from "../../../MatrixClientPeg";
import classNames from "classnames"; import classNames from "classnames";
@ -34,6 +31,11 @@ import { MsgType } from "matrix-js-sdk/src/@types/event";
import Modal from "../../../Modal"; import Modal from "../../../Modal";
import ErrorDialog from "../dialogs/ErrorDialog"; import ErrorDialog from "../dialogs/ErrorDialog";
import MediaDeviceHandler, { MediaDeviceKindEnum } from "../../../MediaDeviceHandler"; import MediaDeviceHandler, { MediaDeviceKindEnum } from "../../../MediaDeviceHandler";
import NotificationBadge from "./NotificationBadge";
import { StaticNotificationState } from "../../../stores/notifications/StaticNotificationState";
import { NotificationColor } from "../../../stores/notifications/NotificationColor";
import InlineSpinner from "../elements/InlineSpinner";
import { PlaybackManager } from "../../../audio/PlaybackManager";
interface IProps { interface IProps {
room: Room; room: Room;
@ -42,6 +44,7 @@ interface IProps {
interface IState { interface IState {
recorder?: VoiceRecording; recorder?: VoiceRecording;
recordingPhase?: RecordingState; recordingPhase?: RecordingState;
didUploadFail?: boolean;
} }
/** /**
@ -69,9 +72,19 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
await this.state.recorder.stop(); await this.state.recorder.stop();
let upload: IUpload;
try { try {
const upload = await this.state.recorder.upload(this.props.room.roomId); upload = await this.state.recorder.upload(this.props.room.roomId);
} catch (e) {
console.error("Error uploading voice message:", e);
// Flag error and move on. The recording phase will be reset by the upload function.
this.setState({ didUploadFail: true });
return; // don't dispose the recording: the user has a chance to re-upload
}
try {
// noinspection ES6MissingAwait - we don't care if it fails, it'll get queued. // noinspection ES6MissingAwait - we don't care if it fails, it'll get queued.
MatrixClientPeg.get().sendMessage(this.props.room.roomId, { MatrixClientPeg.get().sendMessage(this.props.room.roomId, {
"body": "Voice message", "body": "Voice message",
@ -104,12 +117,11 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
"org.matrix.msc3245.voice": {}, // No content, this is a rendering hint "org.matrix.msc3245.voice": {}, // No content, this is a rendering hint
}); });
} catch (e) { } catch (e) {
console.error("Error sending/uploading voice message:", e); console.error("Error sending voice message:", e);
Modal.createTrackedDialog('Upload failed', '', ErrorDialog, {
title: _t('Upload Failed'), // Voice message should be in the timeline at this point, so let other things take care
description: _t("The voice message failed to upload."), // of error handling. We also shouldn't need the recording anymore, so fall through to
}); // disposal.
return; // don't dispose the recording so the user can retry, maybe
} }
await this.disposeRecording(); await this.disposeRecording();
} }
@ -118,7 +130,7 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
await VoiceRecordingStore.instance.disposeRecording(); await VoiceRecordingStore.instance.disposeRecording();
// Reset back to no recording, which means no phase (ie: restart component entirely) // Reset back to no recording, which means no phase (ie: restart component entirely)
this.setState({ recorder: null, recordingPhase: null }); this.setState({ recorder: null, recordingPhase: null, didUploadFail: false });
} }
private onCancel = async () => { private onCancel = async () => {
@ -166,6 +178,9 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
} }
try { try {
// stop any noises which might be happening
await PlaybackManager.instance.playOnly(null);
const recorder = VoiceRecordingStore.instance.startRecording(); const recorder = VoiceRecordingStore.instance.startRecording();
await recorder.start(); await recorder.start();
@ -209,9 +224,9 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
'mx_VoiceRecordComposerTile_stop': this.state.recorder?.isRecording, 'mx_VoiceRecordComposerTile_stop': this.state.recorder?.isRecording,
}); });
let tooltip = _t("Record a voice message"); let tooltip = _t("Send voice message");
if (!!this.state.recorder) { if (!!this.state.recorder) {
tooltip = _t("Stop the recording"); tooltip = _t("Stop recording");
} }
let stopOrRecordBtn = <AccessibleTooltipButton let stopOrRecordBtn = <AccessibleTooltipButton
@ -229,12 +244,30 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
if (this.state.recorder && this.state.recordingPhase !== RecordingState.Uploading) { if (this.state.recorder && this.state.recordingPhase !== RecordingState.Uploading) {
deleteButton = <AccessibleTooltipButton deleteButton = <AccessibleTooltipButton
className='mx_VoiceRecordComposerTile_delete' className='mx_VoiceRecordComposerTile_delete'
title={_t("Delete recording")} title={_t("Delete")}
onClick={this.onCancel} onClick={this.onCancel}
/>; />;
} }
let uploadIndicator;
if (this.state.recordingPhase === RecordingState.Uploading) {
uploadIndicator = <span className='mx_VoiceRecordComposerTile_uploadingState'>
<InlineSpinner w={16} h={16} />
</span>;
} else if (this.state.didUploadFail && this.state.recordingPhase === RecordingState.Ended) {
uploadIndicator = <span className='mx_VoiceRecordComposerTile_failedState'>
<span className='mx_VoiceRecordComposerTile_uploadState_badge'>
{ /* Need to stick the badge in a span to ensure it doesn't create a block component */ }
<NotificationBadge
notification={StaticNotificationState.forSymbol("!", NotificationColor.Red)}
/>
</span>
<span className='text-warning'>{ _t("Failed to send") }</span>
</span>;
}
return (<> return (<>
{ uploadIndicator }
{ deleteButton } { deleteButton }
{ this.renderWaveformArea() } { this.renderWaveformArea() }
{ recordingInfo } { recordingInfo }

View file

@ -26,6 +26,7 @@ import { replaceableComponent } from "../../../../../utils/replaceableComponent"
import SettingsFlag from '../../../elements/SettingsFlag'; import SettingsFlag from '../../../elements/SettingsFlag';
import * as KeyboardShortcuts from "../../../../../accessibility/KeyboardShortcuts"; import * as KeyboardShortcuts from "../../../../../accessibility/KeyboardShortcuts";
import AccessibleButton from "../../../elements/AccessibleButton"; import AccessibleButton from "../../../elements/AccessibleButton";
import SpaceStore from "../../../../../stores/SpaceStore";
interface IState { interface IState {
autoLaunch: boolean; autoLaunch: boolean;
@ -47,6 +48,10 @@ export default class PreferencesUserSettingsTab extends React.Component<{}, ISta
'breadcrumbs', 'breadcrumbs',
]; ];
static SPACES_SETTINGS = [
"Spaces.allRoomsInHome",
];
static KEYBINDINGS_SETTINGS = [ static KEYBINDINGS_SETTINGS = [
'ctrlFForSearch', 'ctrlFForSearch',
]; ];
@ -56,6 +61,7 @@ export default class PreferencesUserSettingsTab extends React.Component<{}, ISta
'MessageComposerInput.suggestEmoji', 'MessageComposerInput.suggestEmoji',
'sendTypingNotifications', 'sendTypingNotifications',
'MessageComposerInput.ctrlEnterToSend', 'MessageComposerInput.ctrlEnterToSend',
'MessageComposerInput.surroundWith',
'MessageComposerInput.showStickersButton', 'MessageComposerInput.showStickersButton',
]; ];
@ -231,6 +237,11 @@ export default class PreferencesUserSettingsTab extends React.Component<{}, ISta
{ this.renderGroup(PreferencesUserSettingsTab.ROOM_LIST_SETTINGS) } { this.renderGroup(PreferencesUserSettingsTab.ROOM_LIST_SETTINGS) }
</div> </div>
{ SpaceStore.spacesEnabled && <div className="mx_SettingsTab_section">
<span className="mx_SettingsTab_subheading">{ _t("Spaces") }</span>
{ this.renderGroup(PreferencesUserSettingsTab.SPACES_SETTINGS) }
</div> }
<div className="mx_SettingsTab_section"> <div className="mx_SettingsTab_section">
<span className="mx_SettingsTab_subheading">{ _t("Keyboard shortcuts") }</span> <span className="mx_SettingsTab_subheading">{ _t("Keyboard shortcuts") }</span>
<AccessibleButton className="mx_SettingsFlag" onClick={KeyboardShortcuts.toggleDialog}> <AccessibleButton className="mx_SettingsFlag" onClick={KeyboardShortcuts.toggleDialog}>

View file

@ -36,6 +36,7 @@ import { UIFeature } from "../../../../../settings/UIFeature";
import { isE2eAdvancedPanelPossible } from "../../E2eAdvancedPanel"; import { isE2eAdvancedPanelPossible } from "../../E2eAdvancedPanel";
import CountlyAnalytics from "../../../../../CountlyAnalytics"; import CountlyAnalytics from "../../../../../CountlyAnalytics";
import { replaceableComponent } from "../../../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../../../utils/replaceableComponent";
import { PosthogAnalytics } from "../../../../../PosthogAnalytics";
export class IgnoredUser extends React.Component { export class IgnoredUser extends React.Component {
static propTypes = { static propTypes = {
@ -106,6 +107,7 @@ export default class SecurityUserSettingsTab extends React.Component {
_updateAnalytics = (checked) => { _updateAnalytics = (checked) => {
checked ? Analytics.enable() : Analytics.disable(); checked ? Analytics.enable() : Analytics.disable();
CountlyAnalytics.instance.enable(/* anonymous = */ !checked); CountlyAnalytics.instance.enable(/* anonymous = */ !checked);
PosthogAnalytics.instance.updateAnonymityFromSettings(MatrixClientPeg.get().getUserId());
}; };
_onExportE2eKeysClicked = () => { _onExportE2eKeysClicked = () => {

View file

@ -14,9 +14,9 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React, { useContext, useRef, useState } from "react"; import React, { ComponentProps, RefObject, SyntheticEvent, useContext, useRef, useState } from "react";
import classNames from "classnames"; import classNames from "classnames";
import { EventType, RoomType, RoomCreateTypeField } from "matrix-js-sdk/src/@types/event"; import { RoomType } from "matrix-js-sdk/src/@types/event";
import FocusLock from "react-focus-lock"; import FocusLock from "react-focus-lock";
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
@ -24,18 +24,16 @@ import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import { ChevronFace, ContextMenu } from "../../structures/ContextMenu"; import { ChevronFace, ContextMenu } from "../../structures/ContextMenu";
import createRoom from "../../../createRoom"; import createRoom from "../../../createRoom";
import MatrixClientContext from "../../../contexts/MatrixClientContext"; import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { SpaceAvatar } from "./SpaceBasicSettings"; import SpaceBasicSettings, { SpaceAvatar } from "./SpaceBasicSettings";
import AccessibleButton from "../elements/AccessibleButton"; import AccessibleButton from "../elements/AccessibleButton";
import { BetaPill } from "../beta/BetaCard";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import { Action } from "../../../dispatcher/actions";
import { UserTab } from "../dialogs/UserSettingsDialog";
import Field from "../elements/Field"; import Field from "../elements/Field";
import withValidation from "../elements/Validation"; import withValidation from "../elements/Validation";
import { SpaceFeedbackPrompt } from "../../structures/SpaceRoomView"; import { HistoryVisibility, Preset } from "matrix-js-sdk/src/@types/partials";
import { Preset } from "matrix-js-sdk/src/@types/partials";
import { ICreateRoomStateEvent } from "matrix-js-sdk/src/@types/requests";
import RoomAliasField from "../elements/RoomAliasField"; import RoomAliasField from "../elements/RoomAliasField";
import SdkConfig from "../../../SdkConfig";
import Modal from "../../../Modal";
import GenericFeatureFeedbackDialog from "../dialogs/GenericFeatureFeedbackDialog";
import SettingsStore from "../../../settings/SettingsStore";
const SpaceCreateMenuType = ({ title, description, className, onClick }) => { const SpaceCreateMenuType = ({ title, description, className, onClick }) => {
return ( return (
@ -66,8 +64,111 @@ const nameToAlias = (name: string, domain: string): string => {
return `#${localpart}:${domain}`; return `#${localpart}:${domain}`;
}; };
const SpaceCreateMenu = ({ onFinished }) => { // XXX: Temporary for the Spaces release only
export const SpaceFeedbackPrompt = ({ onClick }: { onClick?: () => void }) => {
if (!SdkConfig.get().bug_report_endpoint_url) return null;
return <div className="mx_SpaceFeedbackPrompt">
<span className="mx_SpaceFeedbackPrompt_text">{ _t("Spaces are a new feature.") }</span>
<AccessibleButton
kind="link"
onClick={() => {
if (onClick) onClick();
Modal.createTrackedDialog("Spaces Feedback", "", GenericFeatureFeedbackDialog, {
title: _t("Spaces feedback"),
subheading: _t("Thank you for trying Spaces. " +
"Your feedback will help inform the next versions."),
rageshakeLabel: "spaces-feedback",
rageshakeData: Object.fromEntries([
"feature_spaces.all_rooms",
"feature_spaces.space_member_dms",
"feature_spaces.space_dm_badges",
].map(k => [k, SettingsStore.getValue(k)])),
});
}}
>
{ _t("Give feedback.") }
</AccessibleButton>
</div>;
};
type BProps = Pick<ComponentProps<typeof SpaceBasicSettings>, "setAvatar" | "name" | "setName" | "topic" | "setTopic">;
interface ISpaceCreateFormProps extends BProps {
busy: boolean;
alias: string;
nameFieldRef: RefObject<Field>;
aliasFieldRef: RefObject<RoomAliasField>;
showAliasField?: boolean;
onSubmit(e: SyntheticEvent): void;
setAlias(alias: string): void;
}
export const SpaceCreateForm: React.FC<ISpaceCreateFormProps> = ({
busy,
onSubmit,
setAvatar,
name,
setName,
nameFieldRef,
alias,
aliasFieldRef,
setAlias,
showAliasField,
topic,
setTopic,
children,
}) => {
const cli = useContext(MatrixClientContext); const cli = useContext(MatrixClientContext);
const domain = cli.getDomain();
return <form className="mx_SpaceBasicSettings" onSubmit={onSubmit}>
<SpaceAvatar setAvatar={setAvatar} avatarDisabled={busy} />
<Field
name="spaceName"
label={_t("Name")}
autoFocus={true}
value={name}
onChange={ev => {
const newName = ev.target.value;
if (!alias || alias === nameToAlias(name, domain)) {
setAlias(nameToAlias(newName, domain));
}
setName(newName);
}}
ref={nameFieldRef}
onValidate={spaceNameValidator}
disabled={busy}
/>
{ showAliasField
? <RoomAliasField
ref={aliasFieldRef}
onChange={setAlias}
domain={domain}
value={alias}
placeholder={name ? nameToAlias(name, domain) : _t("e.g. my-space")}
label={_t("Address")}
disabled={busy}
/>
: null
}
<Field
name="spaceTopic"
element="textarea"
label={_t("Description")}
value={topic}
onChange={ev => setTopic(ev.target.value)}
rows={3}
disabled={busy}
/>
{ children }
</form>;
};
const SpaceCreateMenu = ({ onFinished }) => {
const [visibility, setVisibility] = useState<Visibility>(null); const [visibility, setVisibility] = useState<Visibility>(null);
const [busy, setBusy] = useState<boolean>(false); const [busy, setBusy] = useState<boolean>(false);
@ -98,42 +199,26 @@ const SpaceCreateMenu = ({ onFinished }) => {
return; return;
} }
const initialState: ICreateRoomStateEvent[] = [
{
type: EventType.RoomHistoryVisibility,
content: {
"history_visibility": visibility === Visibility.Public ? "world_readable" : "invited",
},
},
];
if (avatar) {
const url = await cli.uploadContent(avatar);
initialState.push({
type: EventType.RoomAvatar,
content: { url },
});
}
try { try {
await createRoom({ await createRoom({
createOpts: { createOpts: {
preset: visibility === Visibility.Public ? Preset.PublicChat : Preset.PrivateChat, preset: visibility === Visibility.Public ? Preset.PublicChat : Preset.PrivateChat,
name, name,
creation_content: {
[RoomCreateTypeField]: RoomType.Space,
},
initial_state: initialState,
power_level_content_override: { power_level_content_override: {
// Only allow Admins to write to the timeline to prevent hidden sync spam // Only allow Admins to write to the timeline to prevent hidden sync spam
events_default: 100, events_default: 100,
...Visibility.Public ? { invite: 0 } : {}, ...visibility === Visibility.Public ? { invite: 0 } : {},
}, },
room_alias_name: visibility === Visibility.Public && alias room_alias_name: visibility === Visibility.Public && alias
? alias.substr(1, alias.indexOf(":") - 1) ? alias.substr(1, alias.indexOf(":") - 1)
: undefined, : undefined,
topic, topic,
}, },
avatar,
roomType: RoomType.Space,
historyVisibility: visibility === Visibility.Public
? HistoryVisibility.WorldReadable
: HistoryVisibility.Invited,
spinner: false, spinner: false,
encryption: false, encryption: false,
andView: true, andView: true,
@ -171,7 +256,6 @@ const SpaceCreateMenu = ({ onFinished }) => {
<SpaceFeedbackPrompt onClick={onFinished} /> <SpaceFeedbackPrompt onClick={onFinished} />
</React.Fragment>; </React.Fragment>;
} else { } else {
const domain = cli.getDomain();
body = <React.Fragment> body = <React.Fragment>
<AccessibleTooltipButton <AccessibleTooltipButton
className="mx_SpaceCreateMenu_back" className="mx_SpaceCreateMenu_back"
@ -192,49 +276,20 @@ const SpaceCreateMenu = ({ onFinished }) => {
} }
</p> </p>
<form className="mx_SpaceBasicSettings" onSubmit={onSpaceCreateClick}> <SpaceCreateForm
<SpaceAvatar setAvatar={setAvatar} avatarDisabled={busy} /> busy={busy}
onSubmit={onSpaceCreateClick}
<Field setAvatar={setAvatar}
name="spaceName" name={name}
label={_t("Name")} setName={setName}
autoFocus={true} nameFieldRef={spaceNameField}
value={name} topic={topic}
onChange={ev => { setTopic={setTopic}
const newName = ev.target.value; alias={alias}
if (!alias || alias === nameToAlias(name, domain)) { setAlias={setAlias}
setAlias(nameToAlias(newName, domain)); showAliasField={visibility === Visibility.Public}
} aliasFieldRef={spaceAliasField}
setName(newName); />
}}
ref={spaceNameField}
onValidate={spaceNameValidator}
disabled={busy}
/>
{ visibility === Visibility.Public
? <RoomAliasField
ref={spaceAliasField}
onChange={setAlias}
domain={domain}
value={alias}
placeholder={name ? nameToAlias(name, domain) : _t("e.g. my-space")}
label={_t("Address")}
disabled={busy}
/>
: null
}
<Field
name="spaceTopic"
element="textarea"
label={_t("Description")}
value={topic}
onChange={ev => setTopic(ev.target.value)}
rows={3}
disabled={busy}
/>
</form>
<AccessibleButton kind="primary" onClick={onSpaceCreateClick} disabled={busy}> <AccessibleButton kind="primary" onClick={onSpaceCreateClick} disabled={busy}>
{ busy ? _t("Creating...") : _t("Create") } { busy ? _t("Creating...") : _t("Create") }
@ -252,13 +307,6 @@ const SpaceCreateMenu = ({ onFinished }) => {
managed={false} managed={false}
> >
<FocusLock returnFocus={true}> <FocusLock returnFocus={true}>
<BetaPill onClick={() => {
onFinished();
defaultDispatcher.dispatch({
action: Action.ViewUserSettings,
initialTabId: UserTab.Labs,
});
}} />
{ body } { body }
</FocusLock> </FocusLock>
</ContextMenu>; </ContextMenu>;

View file

@ -14,115 +14,46 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React, { Dispatch, ReactNode, SetStateAction, useEffect, useState } from "react"; import React, { ComponentProps, Dispatch, ReactNode, SetStateAction, useEffect, useState } from "react";
import { DragDropContext, Droppable, Draggable } from "react-beautiful-dnd"; import { DragDropContext, Draggable, Droppable } from "react-beautiful-dnd";
import classNames from "classnames"; import classNames from "classnames";
import { Room } from "matrix-js-sdk/src/models/room"; import { Room } from "matrix-js-sdk/src/models/room";
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import RoomAvatar from "../avatars/RoomAvatar";
import { useContextMenu } from "../../structures/ContextMenu"; import { useContextMenu } from "../../structures/ContextMenu";
import SpaceCreateMenu from "./SpaceCreateMenu"; import SpaceCreateMenu from "./SpaceCreateMenu";
import { SpaceItem } from "./SpaceTreeLevel"; import { SpaceButton, SpaceItem } from "./SpaceTreeLevel";
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import { useEventEmitter } from "../../../hooks/useEventEmitter"; import { useEventEmitterState } from "../../../hooks/useEventEmitter";
import SpaceStore, { import SpaceStore, {
HOME_SPACE, HOME_SPACE,
UPDATE_HOME_BEHAVIOUR,
UPDATE_INVITED_SPACES, UPDATE_INVITED_SPACES,
UPDATE_SELECTED_SPACE, UPDATE_SELECTED_SPACE,
UPDATE_TOP_LEVEL_SPACES, UPDATE_TOP_LEVEL_SPACES,
} from "../../../stores/SpaceStore"; } from "../../../stores/SpaceStore";
import AutoHideScrollbar from "../../structures/AutoHideScrollbar"; import AutoHideScrollbar from "../../structures/AutoHideScrollbar";
import NotificationBadge from "../rooms/NotificationBadge"; import { RovingTabIndexProvider } from "../../../accessibility/RovingTabIndex";
import {
RovingAccessibleButton,
RovingAccessibleTooltipButton,
RovingTabIndexProvider,
} from "../../../accessibility/RovingTabIndex";
import { Key } from "../../../Keyboard"; import { Key } from "../../../Keyboard";
import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore"; import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore";
import { NotificationState } from "../../../stores/notifications/NotificationState"; import SpaceContextMenu from "../context_menus/SpaceContextMenu";
import IconizedContextMenu, {
interface IButtonProps { IconizedContextMenuCheckbox,
space?: Room; IconizedContextMenuOptionList,
className?: string; } from "../context_menus/IconizedContextMenu";
selected?: boolean; import SettingsStore from "../../../settings/SettingsStore";
tooltip?: string; import { SettingLevel } from "../../../settings/SettingLevel";
notificationState?: NotificationState;
isNarrow?: boolean;
onClick(): void;
}
const SpaceButton: React.FC<IButtonProps> = ({
space,
className,
selected,
onClick,
tooltip,
notificationState,
isNarrow,
children,
}) => {
const classes = classNames("mx_SpaceButton", className, {
mx_SpaceButton_active: selected,
mx_SpaceButton_narrow: isNarrow,
});
let avatar = <div className="mx_SpaceButton_avatarPlaceholder"><div className="mx_SpaceButton_icon" /></div>;
if (space) {
avatar = <RoomAvatar width={32} height={32} room={space} />;
}
let notifBadge;
if (notificationState) {
notifBadge = <div className="mx_SpacePanel_badgeContainer">
<NotificationBadge
onClick={() => SpaceStore.instance.setActiveRoomInSpace(space)}
forceCount={false}
notification={notificationState}
/>
</div>;
}
let button;
if (isNarrow) {
button = (
<RovingAccessibleTooltipButton className={classes} title={tooltip} onClick={onClick} role="treeitem">
<div className="mx_SpaceButton_selectionWrapper">
{ avatar }
{ notifBadge }
{ children }
</div>
</RovingAccessibleTooltipButton>
);
} else {
button = (
<RovingAccessibleButton className={classes} onClick={onClick} role="treeitem">
<div className="mx_SpaceButton_selectionWrapper">
{ avatar }
<span className="mx_SpaceButton_name">{ tooltip }</span>
{ notifBadge }
{ children }
</div>
</RovingAccessibleButton>
);
}
return <li className={classNames({
"mx_SpaceItem": true,
"collapsed": isNarrow,
})}>
{ button }
</li>;
};
const useSpaces = (): [Room[], Room[], Room | null] => { const useSpaces = (): [Room[], Room[], Room | null] => {
const [invites, setInvites] = useState<Room[]>(SpaceStore.instance.invitedSpaces); const invites = useEventEmitterState<Room[]>(SpaceStore.instance, UPDATE_INVITED_SPACES, () => {
useEventEmitter(SpaceStore.instance, UPDATE_INVITED_SPACES, setInvites); return SpaceStore.instance.invitedSpaces;
const [spaces, setSpaces] = useState<Room[]>(SpaceStore.instance.spacePanelSpaces); });
useEventEmitter(SpaceStore.instance, UPDATE_TOP_LEVEL_SPACES, setSpaces); const spaces = useEventEmitterState<Room[]>(SpaceStore.instance, UPDATE_TOP_LEVEL_SPACES, () => {
const [activeSpace, setActiveSpace] = useState<Room>(SpaceStore.instance.activeSpace); return SpaceStore.instance.spacePanelSpaces;
useEventEmitter(SpaceStore.instance, UPDATE_SELECTED_SPACE, setActiveSpace); });
const activeSpace = useEventEmitterState<Room>(SpaceStore.instance, UPDATE_SELECTED_SPACE, () => {
return SpaceStore.instance.activeSpace;
});
return [invites, spaces, activeSpace]; return [invites, spaces, activeSpace];
}; };
@ -132,23 +63,108 @@ interface IInnerSpacePanelProps {
setPanelCollapsed: Dispatch<SetStateAction<boolean>>; setPanelCollapsed: Dispatch<SetStateAction<boolean>>;
} }
const HomeButtonContextMenu = ({ onFinished, ...props }: ComponentProps<typeof SpaceContextMenu>) => {
const allRoomsInHome = useEventEmitterState(SpaceStore.instance, UPDATE_HOME_BEHAVIOUR, () => {
return SpaceStore.instance.allRoomsInHome;
});
return <IconizedContextMenu
{...props}
onFinished={onFinished}
className="mx_SpacePanel_contextMenu"
compact
>
<div className="mx_SpacePanel_contextMenu_header">
{ _t("Home") }
</div>
<IconizedContextMenuOptionList first>
<IconizedContextMenuCheckbox
iconClassName="mx_SpacePanel_noIcon"
label={_t("Show all rooms")}
active={allRoomsInHome}
onClick={() => {
SettingsStore.setValue("Spaces.allRoomsInHome", null, SettingLevel.ACCOUNT, !allRoomsInHome);
}}
/>
</IconizedContextMenuOptionList>
</IconizedContextMenu>;
};
interface IHomeButtonProps {
selected: boolean;
isPanelCollapsed: boolean;
}
const HomeButton = ({ selected, isPanelCollapsed }: IHomeButtonProps) => {
const allRoomsInHome = useEventEmitterState(SpaceStore.instance, UPDATE_HOME_BEHAVIOUR, () => {
return SpaceStore.instance.allRoomsInHome;
});
return <li className={classNames("mx_SpaceItem", {
"collapsed": isPanelCollapsed,
})}>
<SpaceButton
className="mx_SpaceButton_home"
onClick={() => SpaceStore.instance.setActiveSpace(null)}
selected={selected}
label={allRoomsInHome ? _t("All rooms") : _t("Home")}
notificationState={allRoomsInHome
? RoomNotificationStateStore.instance.globalState
: SpaceStore.instance.getNotificationState(HOME_SPACE)}
isNarrow={isPanelCollapsed}
ContextMenuComponent={HomeButtonContextMenu}
contextMenuTooltip={_t("Options")}
/>
</li>;
};
const CreateSpaceButton = ({
isPanelCollapsed,
setPanelCollapsed,
}: Pick<IInnerSpacePanelProps, "isPanelCollapsed" | "setPanelCollapsed">) => {
// We don't need the handle as we position the menu in a constant location
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu<void>();
useEffect(() => {
if (!isPanelCollapsed && menuDisplayed) {
closeMenu();
}
}, [isPanelCollapsed]); // eslint-disable-line react-hooks/exhaustive-deps
let contextMenu = null;
if (menuDisplayed) {
contextMenu = <SpaceCreateMenu onFinished={closeMenu} />;
}
const onNewClick = menuDisplayed ? closeMenu : () => {
if (!isPanelCollapsed) setPanelCollapsed(true);
openMenu();
};
return <li className={classNames("mx_SpaceItem", {
"collapsed": isPanelCollapsed,
})}>
<SpaceButton
className={classNames("mx_SpaceButton_new", {
mx_SpaceButton_newCancel: menuDisplayed,
})}
label={menuDisplayed ? _t("Cancel") : _t("Create a space")}
onClick={onNewClick}
isNarrow={isPanelCollapsed}
/>
{ contextMenu }
</li>;
};
// Optimisation based on https://github.com/atlassian/react-beautiful-dnd/blob/master/docs/api/droppable.md#recommended-droppable--performance-optimisation // Optimisation based on https://github.com/atlassian/react-beautiful-dnd/blob/master/docs/api/droppable.md#recommended-droppable--performance-optimisation
const InnerSpacePanel = React.memo<IInnerSpacePanelProps>(({ children, isPanelCollapsed, setPanelCollapsed }) => { const InnerSpacePanel = React.memo<IInnerSpacePanelProps>(({ children, isPanelCollapsed, setPanelCollapsed }) => {
const [invites, spaces, activeSpace] = useSpaces(); const [invites, spaces, activeSpace] = useSpaces();
const activeSpaces = activeSpace ? [activeSpace] : []; const activeSpaces = activeSpace ? [activeSpace] : [];
const homeNotificationState = SpaceStore.spacesTweakAllRoomsEnabled
? RoomNotificationStateStore.instance.globalState : SpaceStore.instance.getNotificationState(HOME_SPACE);
return <div className="mx_SpaceTreeLevel"> return <div className="mx_SpaceTreeLevel">
<SpaceButton <HomeButton selected={!activeSpace} isPanelCollapsed={isPanelCollapsed} />
className="mx_SpaceButton_home"
onClick={() => SpaceStore.instance.setActiveSpace(null)}
selected={!activeSpace}
tooltip={SpaceStore.spacesTweakAllRoomsEnabled ? _t("All rooms") : _t("Home")}
notificationState={homeNotificationState}
isNarrow={isPanelCollapsed}
/>
{ invites.map(s => ( { invites.map(s => (
<SpaceItem <SpaceItem
key={s.roomId} key={s.roomId}
@ -178,26 +194,13 @@ const InnerSpacePanel = React.memo<IInnerSpacePanelProps>(({ children, isPanelCo
</Draggable> </Draggable>
)) } )) }
{ children } { children }
<CreateSpaceButton isPanelCollapsed={isPanelCollapsed} setPanelCollapsed={setPanelCollapsed} />
</div>; </div>;
}); });
const SpacePanel = () => { const SpacePanel = () => {
// We don't need the handle as we position the menu in a constant location
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu<void>();
const [isPanelCollapsed, setPanelCollapsed] = useState(true); const [isPanelCollapsed, setPanelCollapsed] = useState(true);
useEffect(() => {
if (!isPanelCollapsed && menuDisplayed) {
closeMenu();
}
}, [isPanelCollapsed]); // eslint-disable-line react-hooks/exhaustive-deps
let contextMenu = null;
if (menuDisplayed) {
contextMenu = <SpaceCreateMenu onFinished={closeMenu} />;
}
const onKeyDown = (ev: React.KeyboardEvent) => { const onKeyDown = (ev: React.KeyboardEvent) => {
let handled = true; let handled = true;
@ -259,11 +262,6 @@ const SpacePanel = () => {
} }
}; };
const onNewClick = menuDisplayed ? closeMenu : () => {
if (!isPanelCollapsed) setPanelCollapsed(true);
openMenu();
};
return ( return (
<DragDropContext onDragEnd={result => { <DragDropContext onDragEnd={result => {
if (!result.destination) return; // dropped outside the list if (!result.destination) return; // dropped outside the list
@ -291,15 +289,6 @@ const SpacePanel = () => {
> >
{ provided.placeholder } { provided.placeholder }
</InnerSpacePanel> </InnerSpacePanel>
<SpaceButton
className={classNames("mx_SpaceButton_new", {
mx_SpaceButton_newCancel: menuDisplayed,
})}
tooltip={menuDisplayed ? _t("Cancel") : _t("Create a space")}
onClick={onNewClick}
isNarrow={isPanelCollapsed}
/>
</AutoHideScrollbar> </AutoHideScrollbar>
) } ) }
</Droppable> </Droppable>
@ -308,7 +297,6 @@ const SpacePanel = () => {
onClick={() => setPanelCollapsed(!isPanelCollapsed)} onClick={() => setPanelCollapsed(!isPanelCollapsed)}
title={isPanelCollapsed ? _t("Expand space panel") : _t("Collapse space panel")} title={isPanelCollapsed ? _t("Expand space panel") : _t("Collapse space panel")}
/> />
{ contextMenu }
</ul> </ul>
) } ) }
</RovingTabIndexProvider> </RovingTabIndexProvider>

View file

@ -21,12 +21,11 @@ import { EventType } from "matrix-js-sdk/src/@types/event";
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import AccessibleButton from "../elements/AccessibleButton"; import AccessibleButton from "../elements/AccessibleButton";
import { SpaceFeedbackPrompt } from "../../structures/SpaceRoomView";
import SpaceBasicSettings from "./SpaceBasicSettings"; import SpaceBasicSettings from "./SpaceBasicSettings";
import { avatarUrlForRoom } from "../../../Avatar"; import { avatarUrlForRoom } from "../../../Avatar";
import { IDialogProps } from "../dialogs/IDialogProps"; import { IDialogProps } from "../dialogs/IDialogProps";
import { getTopic } from "../elements/RoomTopic"; import { getTopic } from "../elements/RoomTopic";
import { defaultDispatcher } from "../../../dispatcher/dispatcher"; import { leaveSpace } from "../../../utils/space";
interface IProps extends IDialogProps { interface IProps extends IDialogProps {
matrixClient: MatrixClient; matrixClient: MatrixClient;
@ -96,8 +95,6 @@ const SpaceSettingsGeneralTab = ({ matrixClient: cli, space, onFinished }: IProp
{ error && <div className="mx_SpaceRoomView_errorText">{ error }</div> } { error && <div className="mx_SpaceRoomView_errorText">{ error }</div> }
<SpaceFeedbackPrompt />
<div className="mx_SettingsTab_section"> <div className="mx_SettingsTab_section">
<SpaceBasicSettings <SpaceBasicSettings
avatarUrl={avatarUrlForRoom(space, 80, 80, "crop")} avatarUrl={avatarUrlForRoom(space, 80, 80, "crop")}
@ -128,10 +125,7 @@ const SpaceSettingsGeneralTab = ({ matrixClient: cli, space, onFinished }: IProp
<AccessibleButton <AccessibleButton
kind="danger" kind="danger"
onClick={() => { onClick={() => {
defaultDispatcher.dispatch({ leaveSpace(space);
action: "leave_room",
room_id: space.roomId,
});
}} }}
> >
{ _t("Leave Space") } { _t("Leave Space") }

View file

@ -14,7 +14,14 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React, { createRef, InputHTMLAttributes, LegacyRef } from "react"; import React, {
createRef,
MouseEvent,
InputHTMLAttributes,
LegacyRef,
ComponentProps,
ComponentType,
} from "react";
import classNames from "classnames"; import classNames from "classnames";
import { Room } from "matrix-js-sdk/src/models/room"; import { Room } from "matrix-js-sdk/src/models/room";
@ -23,31 +30,104 @@ import SpaceStore from "../../../stores/SpaceStore";
import SpaceTreeLevelLayoutStore from "../../../stores/SpaceTreeLevelLayoutStore"; import SpaceTreeLevelLayoutStore from "../../../stores/SpaceTreeLevelLayoutStore";
import NotificationBadge from "../rooms/NotificationBadge"; import NotificationBadge from "../rooms/NotificationBadge";
import { RovingAccessibleTooltipButton } from "../../../accessibility/roving/RovingAccessibleTooltipButton"; import { RovingAccessibleTooltipButton } from "../../../accessibility/roving/RovingAccessibleTooltipButton";
import IconizedContextMenu, {
IconizedContextMenuOption,
IconizedContextMenuOptionList,
} from "../context_menus/IconizedContextMenu";
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import { ContextMenuTooltipButton } from "../../../accessibility/context_menu/ContextMenuTooltipButton"; import { ContextMenuTooltipButton } from "../../../accessibility/context_menu/ContextMenuTooltipButton";
import { toRightOf } from "../../structures/ContextMenu"; import { toRightOf, useContextMenu } from "../../structures/ContextMenu";
import {
shouldShowSpaceSettings,
showAddExistingRooms,
showCreateNewRoom,
showSpaceInvite,
showSpaceSettings,
} from "../../../utils/space";
import MatrixClientContext from "../../../contexts/MatrixClientContext"; import MatrixClientContext from "../../../contexts/MatrixClientContext";
import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton"; import AccessibleButton from "../elements/AccessibleButton";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import { Action } from "../../../dispatcher/actions";
import RoomViewStore from "../../../stores/RoomViewStore";
import { SetRightPanelPhasePayload } from "../../../dispatcher/payloads/SetRightPanelPhasePayload";
import { RightPanelPhases } from "../../../stores/RightPanelStorePhases";
import { EventType } from "matrix-js-sdk/src/@types/event";
import { StaticNotificationState } from "../../../stores/notifications/StaticNotificationState"; import { StaticNotificationState } from "../../../stores/notifications/StaticNotificationState";
import { NotificationColor } from "../../../stores/notifications/NotificationColor"; import { NotificationColor } from "../../../stores/notifications/NotificationColor";
import { getKeyBindingsManager, RoomListAction } from "../../../KeyBindingsManager"; import { getKeyBindingsManager, RoomListAction } from "../../../KeyBindingsManager";
import { NotificationState } from "../../../stores/notifications/NotificationState";
import SpaceContextMenu from "../context_menus/SpaceContextMenu";
interface IButtonProps extends Omit<ComponentProps<typeof RovingAccessibleTooltipButton>, "title"> {
space?: Room;
className?: string;
selected?: boolean;
label: string;
contextMenuTooltip?: string;
notificationState?: NotificationState;
isNarrow?: boolean;
avatarSize?: number;
ContextMenuComponent?: ComponentType<ComponentProps<typeof SpaceContextMenu>>;
onClick(ev: MouseEvent): void;
}
export const SpaceButton: React.FC<IButtonProps> = ({
space,
className,
selected,
onClick,
label,
contextMenuTooltip,
notificationState,
avatarSize,
isNarrow,
children,
ContextMenuComponent,
...props
}) => {
const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu<HTMLElement>();
let avatar = <div className="mx_SpaceButton_avatarPlaceholder"><div className="mx_SpaceButton_icon" /></div>;
if (space) {
avatar = <RoomAvatar width={avatarSize} height={avatarSize} room={space} />;
}
let notifBadge;
if (notificationState) {
notifBadge = <div className="mx_SpacePanel_badgeContainer">
<NotificationBadge
onClick={() => SpaceStore.instance.setActiveRoomInSpace(space || null)}
forceCount={false}
notification={notificationState}
/>
</div>;
}
let contextMenu: JSX.Element;
if (menuDisplayed && ContextMenuComponent) {
contextMenu = <ContextMenuComponent
{...toRightOf(handle.current?.getBoundingClientRect(), 0)}
space={space}
onFinished={closeMenu}
/>;
}
return (
<RovingAccessibleTooltipButton
{...props}
className={classNames("mx_SpaceButton", className, {
mx_SpaceButton_active: selected,
mx_SpaceButton_hasMenuOpen: menuDisplayed,
mx_SpaceButton_narrow: isNarrow,
})}
title={label}
onClick={onClick}
onContextMenu={openMenu}
forceHide={!isNarrow || menuDisplayed}
role="treeitem"
inputRef={handle}
>
{ children }
<div className="mx_SpaceButton_selectionWrapper">
{ avatar }
{ !isNarrow && <span className="mx_SpaceButton_name">{ label }</span> }
{ notifBadge }
{ ContextMenuComponent && <ContextMenuTooltipButton
className="mx_SpaceButton_menuButton"
onClick={openMenu}
title={contextMenuTooltip}
isExpanded={menuDisplayed}
/> }
{ contextMenu }
</div>
</RovingAccessibleTooltipButton>
);
};
interface IItemProps extends InputHTMLAttributes<HTMLLIElement> { interface IItemProps extends InputHTMLAttributes<HTMLLIElement> {
space?: Room; space?: Room;
@ -61,7 +141,6 @@ interface IItemProps extends InputHTMLAttributes<HTMLLIElement> {
interface IItemState { interface IItemState {
collapsed: boolean; collapsed: boolean;
contextMenuPosition: Pick<DOMRect, "right" | "top" | "height">;
childSpaces: Room[]; childSpaces: Room[];
} }
@ -81,7 +160,6 @@ export class SpaceItem extends React.PureComponent<IItemProps, IItemState> {
this.state = { this.state = {
collapsed: collapsed, collapsed: collapsed,
contextMenuPosition: null,
childSpaces: this.childSpaces, childSpaces: this.childSpaces,
}; };
@ -124,19 +202,6 @@ export class SpaceItem extends React.PureComponent<IItemProps, IItemState> {
evt.stopPropagation(); evt.stopPropagation();
}; };
private onContextMenu = (ev: React.MouseEvent) => {
if (this.props.space.getMyMembership() !== "join") return;
ev.preventDefault();
ev.stopPropagation();
this.setState({
contextMenuPosition: {
right: ev.clientX,
top: ev.clientY,
height: 0,
},
});
};
private onKeyDown = (ev: React.KeyboardEvent) => { private onKeyDown = (ev: React.KeyboardEvent) => {
let handled = true; let handled = true;
const action = getKeyBindingsManager().getRoomListAction(ev); const action = getKeyBindingsManager().getRoomListAction(ev);
@ -180,188 +245,6 @@ export class SpaceItem extends React.PureComponent<IItemProps, IItemState> {
SpaceStore.instance.setActiveSpace(this.props.space); SpaceStore.instance.setActiveSpace(this.props.space);
}; };
private onMenuOpenClick = (ev: React.MouseEvent) => {
ev.preventDefault();
ev.stopPropagation();
const target = ev.target as HTMLButtonElement;
this.setState({ contextMenuPosition: target.getBoundingClientRect() });
};
private onMenuClose = () => {
this.setState({ contextMenuPosition: null });
};
private onInviteClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
showSpaceInvite(this.props.space);
this.setState({ contextMenuPosition: null }); // also close the menu
};
private onSettingsClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
showSpaceSettings(this.props.space);
this.setState({ contextMenuPosition: null }); // also close the menu
};
private onLeaveClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
defaultDispatcher.dispatch({
action: "leave_room",
room_id: this.props.space.roomId,
});
this.setState({ contextMenuPosition: null }); // also close the menu
};
private onNewRoomClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
showCreateNewRoom(this.props.space);
this.setState({ contextMenuPosition: null }); // also close the menu
};
private onAddExistingRoomClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
showAddExistingRooms(this.props.space);
this.setState({ contextMenuPosition: null }); // also close the menu
};
private onMembersClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
if (!RoomViewStore.getRoomId()) {
defaultDispatcher.dispatch({
action: "view_room",
room_id: this.props.space.roomId,
}, true);
}
defaultDispatcher.dispatch<SetRightPanelPhasePayload>({
action: Action.SetRightPanelPhase,
phase: RightPanelPhases.SpaceMemberList,
refireParams: { space: this.props.space },
});
this.setState({ contextMenuPosition: null }); // also close the menu
};
private onExploreRoomsClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
defaultDispatcher.dispatch({
action: "view_room",
room_id: this.props.space.roomId,
});
this.setState({ contextMenuPosition: null }); // also close the menu
};
private renderContextMenu(): React.ReactElement {
if (this.props.space.getMyMembership() !== "join") return null;
let contextMenu = null;
if (this.state.contextMenuPosition) {
const userId = this.context.getUserId();
let inviteOption;
if (this.props.space.getJoinRule() === "public" || this.props.space.canInvite(userId)) {
inviteOption = (
<IconizedContextMenuOption
className="mx_SpacePanel_contextMenu_inviteButton"
iconClassName="mx_SpacePanel_iconInvite"
label={_t("Invite people")}
onClick={this.onInviteClick}
/>
);
}
let settingsOption;
let leaveSection;
if (shouldShowSpaceSettings(this.props.space)) {
settingsOption = (
<IconizedContextMenuOption
iconClassName="mx_SpacePanel_iconSettings"
label={_t("Settings")}
onClick={this.onSettingsClick}
/>
);
} else {
leaveSection = <IconizedContextMenuOptionList red first>
<IconizedContextMenuOption
iconClassName="mx_SpacePanel_iconLeave"
label={_t("Leave space")}
onClick={this.onLeaveClick}
/>
</IconizedContextMenuOptionList>;
}
const canAddRooms = this.props.space.currentState.maySendStateEvent(EventType.SpaceChild, userId);
let newRoomSection;
if (this.props.space.currentState.maySendStateEvent(EventType.SpaceChild, userId)) {
newRoomSection = <IconizedContextMenuOptionList first>
<IconizedContextMenuOption
iconClassName="mx_SpacePanel_iconPlus"
label={_t("Create new room")}
onClick={this.onNewRoomClick}
/>
<IconizedContextMenuOption
iconClassName="mx_SpacePanel_iconHash"
label={_t("Add existing room")}
onClick={this.onAddExistingRoomClick}
/>
</IconizedContextMenuOptionList>;
}
contextMenu = <IconizedContextMenu
{...toRightOf(this.state.contextMenuPosition, 0)}
onFinished={this.onMenuClose}
className="mx_SpacePanel_contextMenu"
compact
>
<div className="mx_SpacePanel_contextMenu_header">
{ this.props.space.name }
</div>
<IconizedContextMenuOptionList first>
{ inviteOption }
<IconizedContextMenuOption
iconClassName="mx_SpacePanel_iconMembers"
label={_t("Members")}
onClick={this.onMembersClick}
/>
{ settingsOption }
<IconizedContextMenuOption
iconClassName="mx_SpacePanel_iconExplore"
label={canAddRooms ? _t("Manage & explore rooms") : _t("Explore rooms")}
onClick={this.onExploreRoomsClick}
/>
</IconizedContextMenuOptionList>
{ newRoomSection }
{ leaveSection }
</IconizedContextMenu>;
}
return (
<React.Fragment>
<ContextMenuTooltipButton
className="mx_SpaceButton_menuButton"
onClick={this.onMenuOpenClick}
title={_t("Space options")}
isExpanded={!!this.state.contextMenuPosition}
/>
{ contextMenu }
</React.Fragment>
);
}
render() { render() {
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
const { space, activeSpaces, isNested, isPanelCollapsed, onExpand, parents, innerRef, const { space, activeSpaces, isNested, isPanelCollapsed, onExpand, parents, innerRef,
@ -369,7 +252,6 @@ export class SpaceItem extends React.PureComponent<IItemProps, IItemState> {
const collapsed = this.isCollapsed; const collapsed = this.isCollapsed;
const isActive = activeSpaces.includes(space);
const itemClasses = classNames(this.props.className, { const itemClasses = classNames(this.props.className, {
"mx_SpaceItem": true, "mx_SpaceItem": true,
"mx_SpaceItem_narrow": isPanelCollapsed, "mx_SpaceItem_narrow": isPanelCollapsed,
@ -378,12 +260,7 @@ export class SpaceItem extends React.PureComponent<IItemProps, IItemState> {
}); });
const isInvite = space.getMyMembership() === "invite"; const isInvite = space.getMyMembership() === "invite";
const classes = classNames("mx_SpaceButton", {
mx_SpaceButton_active: isActive,
mx_SpaceButton_hasMenuOpen: !!this.state.contextMenuPosition,
mx_SpaceButton_narrow: isPanelCollapsed,
mx_SpaceButton_invite: isInvite,
});
const notificationState = isInvite const notificationState = isInvite
? StaticNotificationState.forSymbol("!", NotificationColor.Red) ? StaticNotificationState.forSymbol("!", NotificationColor.Red)
: SpaceStore.instance.getNotificationState(space.roomId); : SpaceStore.instance.getNotificationState(space.roomId);
@ -398,19 +275,6 @@ export class SpaceItem extends React.PureComponent<IItemProps, IItemState> {
/>; />;
} }
let notifBadge;
if (notificationState) {
notifBadge = <div className="mx_SpacePanel_badgeContainer">
<NotificationBadge
onClick={() => SpaceStore.instance.setActiveRoomInSpace(space)}
forceCount={false}
notification={notificationState}
/>
</div>;
}
const avatarSize = isNested ? 24 : 32;
const toggleCollapseButton = this.state.childSpaces?.length ? const toggleCollapseButton = this.state.childSpaces?.length ?
<AccessibleButton <AccessibleButton
className="mx_SpaceButton_toggleCollapse" className="mx_SpaceButton_toggleCollapse"
@ -421,25 +285,23 @@ export class SpaceItem extends React.PureComponent<IItemProps, IItemState> {
return ( return (
<li {...otherProps} className={itemClasses} ref={innerRef}> <li {...otherProps} className={itemClasses} ref={innerRef}>
<RovingAccessibleTooltipButton <SpaceButton
className={classes} space={space}
title={space.name} className={isInvite ? "mx_SpaceButton_invite" : undefined}
selected={activeSpaces.includes(space)}
label={space.name}
contextMenuTooltip={_t("Space options")}
notificationState={notificationState}
isNarrow={isPanelCollapsed}
avatarSize={isNested ? 24 : 32}
onClick={this.onClick} onClick={this.onClick}
onContextMenu={this.onContextMenu}
forceHide={!isPanelCollapsed || !!this.state.contextMenuPosition}
role="treeitem"
aria-expanded={!collapsed}
inputRef={this.buttonRef}
onKeyDown={this.onKeyDown} onKeyDown={this.onKeyDown}
aria-expanded={!collapsed}
ContextMenuComponent={this.props.space.getMyMembership() === "join"
? SpaceContextMenu : undefined}
> >
{ toggleCollapseButton } { toggleCollapseButton }
<div className="mx_SpaceButton_selectionWrapper"> </SpaceButton>
<RoomAvatar width={avatarSize} height={avatarSize} room={space} />
{ !isPanelCollapsed && <span className="mx_SpaceButton_name">{ space.name }</span> }
{ notifBadge }
{ this.renderContextMenu() }
</div>
</RovingAccessibleTooltipButton>
{ childItems } { childItems }
</li> </li>

View file

@ -1,5 +1,6 @@
/* /*
Copyright 2020 The Matrix.org Foundation C.I.C. Copyright 2020 The Matrix.org Foundation C.I.C.
Copyright 2021 Šimon Brandner <simon.bra.ag@gmail.com>
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -15,7 +16,6 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import IncomingCallBox from './IncomingCallBox';
import CallPreview from './CallPreview'; import CallPreview from './CallPreview';
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
@ -31,7 +31,6 @@ interface IState {
export default class CallContainer extends React.PureComponent<IProps, IState> { export default class CallContainer extends React.PureComponent<IProps, IState> {
public render() { public render() {
return <div className="mx_CallContainer"> return <div className="mx_CallContainer">
<IncomingCallBox />
<CallPreview /> <CallPreview />
</div>; </div>;
} }

View file

@ -36,7 +36,7 @@ const PIP_VIEW_WIDTH = 336;
const PIP_VIEW_HEIGHT = 232; const PIP_VIEW_HEIGHT = 232;
const MOVING_AMT = 0.2; const MOVING_AMT = 0.2;
const SNAPPING_AMT = 0.05; const SNAPPING_AMT = 0.1;
const PADDING = { const PADDING = {
top: 58, top: 58,

View file

@ -23,11 +23,16 @@ import { MatrixClientPeg } from '../../../MatrixClientPeg';
import { _t, _td } from '../../../languageHandler'; import { _t, _td } from '../../../languageHandler';
import VideoFeed from './VideoFeed'; import VideoFeed from './VideoFeed';
import RoomAvatar from "../avatars/RoomAvatar"; import RoomAvatar from "../avatars/RoomAvatar";
import { CallState, CallType, MatrixCall, CallEvent } from 'matrix-js-sdk/src/webrtc/call'; import { CallEvent, CallState, CallType, MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
import classNames from 'classnames'; import classNames from 'classnames';
import AccessibleButton from '../elements/AccessibleButton'; import AccessibleButton from '../elements/AccessibleButton';
import { isOnlyCtrlOrCmdKeyEvent, Key } from '../../../Keyboard'; import { isOnlyCtrlOrCmdKeyEvent, Key } from '../../../Keyboard';
import { alwaysAboveLeftOf, alwaysAboveRightOf, ChevronFace, ContextMenuButton } from '../../structures/ContextMenu'; import {
alwaysAboveLeftOf,
alwaysAboveRightOf,
ChevronFace,
ContextMenuTooltipButton,
} from '../../structures/ContextMenu';
import CallContextMenu from '../context_menus/CallContextMenu'; import CallContextMenu from '../context_menus/CallContextMenu';
import { avatarUrlForMember } from '../../../Avatar'; import { avatarUrlForMember } from '../../../Avatar';
import DialpadContextMenu from '../context_menus/DialpadContextMenu'; import DialpadContextMenu from '../context_menus/DialpadContextMenu';
@ -37,6 +42,8 @@ import DesktopCapturerSourcePicker from "../elements/DesktopCapturerSourcePicker
import Modal from '../../../Modal'; import Modal from '../../../Modal';
import { SDPStreamMetadataPurpose } from 'matrix-js-sdk/src/webrtc/callEventTypes'; import { SDPStreamMetadataPurpose } from 'matrix-js-sdk/src/webrtc/callEventTypes';
import CallViewSidebar from './CallViewSidebar'; import CallViewSidebar from './CallViewSidebar';
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import { Alignment } from "../elements/Tooltip";
interface IProps { interface IProps {
// The call for us to display // The call for us to display
@ -67,6 +74,7 @@ interface IState {
screensharing: boolean; screensharing: boolean;
callState: CallState; callState: CallState;
controlsVisible: boolean; controlsVisible: boolean;
hoveringControls: boolean;
showMoreMenu: boolean; showMoreMenu: boolean;
showDialpad: boolean; showDialpad: boolean;
primaryFeed: CallFeed; primaryFeed: CallFeed;
@ -74,6 +82,8 @@ interface IState {
sidebarShown: boolean; sidebarShown: boolean;
} }
const tooltipYOffset = -24;
function getFullScreenElement() { function getFullScreenElement() {
return ( return (
document.fullscreenElement || document.fullscreenElement ||
@ -102,7 +112,7 @@ function exitFullscreen() {
if (exitMethod) exitMethod.call(document); if (exitMethod) exitMethod.call(document);
} }
const CONTROLS_HIDE_DELAY = 1000; const CONTROLS_HIDE_DELAY = 2000;
// Height of the header duplicated from CSS because we need to subtract it from our max // Height of the header duplicated from CSS because we need to subtract it from our max
// height to get the max height of the video // height to get the max height of the video
const CONTEXT_MENU_VPADDING = 8; // How far the context menu sits above the button (px) const CONTEXT_MENU_VPADDING = 8; // How far the context menu sits above the button (px)
@ -128,6 +138,7 @@ export default class CallView extends React.Component<IProps, IState> {
screensharing: this.props.call.isScreensharing(), screensharing: this.props.call.isScreensharing(),
callState: this.props.call.state, callState: this.props.call.state,
controlsVisible: true, controlsVisible: true,
hoveringControls: false,
showMoreMenu: false, showMoreMenu: false,
showDialpad: false, showDialpad: false,
primaryFeed: primary, primaryFeed: primary,
@ -244,6 +255,7 @@ export default class CallView extends React.Component<IProps, IState> {
}; };
private onControlsHideTimer = () => { private onControlsHideTimer = () => {
if (this.state.hoveringControls || this.state.showDialpad || this.state.showMoreMenu) return;
this.controlsHideTimer = null; this.controlsHideTimer = null;
this.setState({ this.setState({
controlsVisible: false, controlsVisible: false,
@ -293,24 +305,10 @@ export default class CallView extends React.Component<IProps, IState> {
private onDialpadClick = (): void => { private onDialpadClick = (): void => {
if (!this.state.showDialpad) { if (!this.state.showDialpad) {
if (this.controlsHideTimer) { this.setState({ showDialpad: true });
clearTimeout(this.controlsHideTimer); this.showControls();
this.controlsHideTimer = null;
}
this.setState({
showDialpad: true,
controlsVisible: true,
});
} else { } else {
if (this.controlsHideTimer !== null) { this.setState({ showDialpad: false });
clearTimeout(this.controlsHideTimer);
}
this.controlsHideTimer = window.setTimeout(this.onControlsHideTimer, CONTROLS_HIDE_DELAY);
this.setState({
showDialpad: false,
});
} }
}; };
@ -345,29 +343,16 @@ export default class CallView extends React.Component<IProps, IState> {
}; };
private onMoreClick = (): void => { private onMoreClick = (): void => {
if (this.controlsHideTimer) { this.setState({ showMoreMenu: true });
clearTimeout(this.controlsHideTimer); this.showControls();
this.controlsHideTimer = null;
}
this.setState({
showMoreMenu: true,
controlsVisible: true,
});
}; };
private closeDialpad = (): void => { private closeDialpad = (): void => {
this.setState({ this.setState({ showDialpad: false });
showDialpad: false,
});
this.controlsHideTimer = window.setTimeout(this.onControlsHideTimer, CONTROLS_HIDE_DELAY);
}; };
private closeContextMenu = (): void => { private closeContextMenu = (): void => {
this.setState({ this.setState({ showMoreMenu: false });
showMoreMenu: false,
});
this.controlsHideTimer = window.setTimeout(this.onControlsHideTimer, CONTROLS_HIDE_DELAY);
}; };
// we register global shortcuts here, they *must not conflict* with local shortcuts elsewhere or both will fire // we register global shortcuts here, they *must not conflict* with local shortcuts elsewhere or both will fire
@ -403,6 +388,15 @@ export default class CallView extends React.Component<IProps, IState> {
} }
}; };
private onCallControlsMouseEnter = (): void => {
this.setState({ hoveringControls: true });
this.showControls();
};
private onCallControlsMouseLeave = (): void => {
this.setState({ hoveringControls: false });
};
private onRoomAvatarClick = (): void => { private onRoomAvatarClick = (): void => {
const userFacingRoomId = CallHandler.sharedInstance().roomIdForCall(this.props.call); const userFacingRoomId = CallHandler.sharedInstance().roomIdForCall(this.props.call);
dis.dispatch({ dis.dispatch({
@ -493,9 +487,12 @@ export default class CallView extends React.Component<IProps, IState> {
let vidMuteButton; let vidMuteButton;
if (this.props.call.type === CallType.Video) { if (this.props.call.type === CallType.Video) {
vidMuteButton = ( vidMuteButton = (
<AccessibleButton <AccessibleTooltipButton
className={vidClasses} className={vidClasses}
onClick={this.onVidMuteClick} onClick={this.onVidMuteClick}
title={this.state.vidMuted ? _t("Start the camera") : _t("Stop the camera")}
alignment={Alignment.Top}
yOffset={tooltipYOffset}
/> />
); );
} }
@ -510,9 +507,15 @@ export default class CallView extends React.Component<IProps, IState> {
this.props.call.state === CallState.Connected this.props.call.state === CallState.Connected
) { ) {
screensharingButton = ( screensharingButton = (
<AccessibleButton <AccessibleTooltipButton
className={screensharingClasses} className={screensharingClasses}
onClick={this.onScreenshareClick} onClick={this.onScreenshareClick}
title={this.state.screensharing
? _t("Stop sharing your screen")
: _t("Start sharing your screen")
}
alignment={Alignment.Top}
yOffset={tooltipYOffset}
/> />
); );
} }
@ -532,39 +535,88 @@ export default class CallView extends React.Component<IProps, IState> {
<AccessibleButton <AccessibleButton
className={sidebarButtonClasses} className={sidebarButtonClasses}
onClick={this.onToggleSidebar} onClick={this.onToggleSidebar}
aria-label={this.state.sidebarShown ? _t("Hide sidebar") : _t("Show sidebar")}
/> />
); );
} }
// The dial pad & 'more' button actions are only relevant in a connected call // The dial pad & 'more' button actions are only relevant in a connected call
// When not connected, we have to put something there to make the flexbox alignment correct
let dialpadButton;
let contextMenuButton; let contextMenuButton;
if (this.state.callState === CallState.Connected) { if (this.state.callState === CallState.Connected) {
contextMenuButton = ( contextMenuButton = (
<ContextMenuButton <ContextMenuTooltipButton
className="mx_CallView_callControls_button mx_CallView_callControls_button_more" className="mx_CallView_callControls_button mx_CallView_callControls_button_more"
onClick={this.onMoreClick} onClick={this.onMoreClick}
inputRef={this.contextMenuButton} inputRef={this.contextMenuButton}
isExpanded={this.state.showMoreMenu} isExpanded={this.state.showMoreMenu}
title={_t("More")}
alignment={Alignment.Top}
yOffset={tooltipYOffset}
/> />
); );
}
let dialpadButton;
if (this.state.callState === CallState.Connected && this.props.call.opponentSupportsDTMF()) {
dialpadButton = ( dialpadButton = (
<ContextMenuButton <ContextMenuTooltipButton
className="mx_CallView_callControls_button mx_CallView_callControls_dialpad" className="mx_CallView_callControls_button mx_CallView_callControls_dialpad"
inputRef={this.dialpadButton} inputRef={this.dialpadButton}
onClick={this.onDialpadClick} onClick={this.onDialpadClick}
isExpanded={this.state.showDialpad} isExpanded={this.state.showDialpad}
title={_t("Dialpad")}
alignment={Alignment.Top}
yOffset={tooltipYOffset}
/> />
); );
} }
let dialPad;
if (this.state.showDialpad) {
dialPad = <DialpadContextMenu
{...alwaysAboveRightOf(
this.dialpadButton.current.getBoundingClientRect(),
ChevronFace.None,
CONTEXT_MENU_VPADDING,
)}
// We mount the context menus as a as a child typically in order to include the
// context menus when fullscreening the call content.
// However, this does not work as well when the call is embedded in a
// picture-in-picture frame. Thus, only mount as child when we are *not* in PiP.
mountAsChild={!this.props.pipMode}
onFinished={this.closeDialpad}
call={this.props.call}
/>;
}
let contextMenu;
if (this.state.showMoreMenu) {
contextMenu = <CallContextMenu
{...alwaysAboveLeftOf(
this.contextMenuButton.current.getBoundingClientRect(),
ChevronFace.None,
CONTEXT_MENU_VPADDING,
)}
mountAsChild={!this.props.pipMode}
onFinished={this.closeContextMenu}
call={this.props.call}
/>;
}
return ( return (
<div className={callControlsClasses}> <div
className={callControlsClasses}
onMouseEnter={this.onCallControlsMouseEnter}
onMouseLeave={this.onCallControlsMouseLeave}
>
{ dialPad }
{ contextMenu }
{ dialpadButton } { dialpadButton }
<AccessibleButton <AccessibleTooltipButton
className={micClasses} className={micClasses}
onClick={this.onMicMuteClick} onClick={this.onMicMuteClick}
title={this.state.micMuted ? _t("Unmute the microphone") : _t("Mute the microphone")}
alignment={Alignment.Top}
yOffset={tooltipYOffset}
/> />
{ vidMuteButton } { vidMuteButton }
<div className={micCacheClasses} /> <div className={micCacheClasses} />
@ -572,9 +624,12 @@ export default class CallView extends React.Component<IProps, IState> {
{ screensharingButton } { screensharingButton }
{ sidebarButton } { sidebarButton }
{ contextMenuButton } { contextMenuButton }
<AccessibleButton <AccessibleTooltipButton
className="mx_CallView_callControls_button mx_CallView_callControls_button_hangup" className="mx_CallView_callControls_button mx_CallView_callControls_button_hangup"
onClick={this.onHangupClick} onClick={this.onHangupClick}
title={_t("Hangup")}
alignment={Alignment.Top}
yOffset={tooltipYOffset}
/> />
</div> </div>
); );
@ -799,7 +854,7 @@ export default class CallView extends React.Component<IProps, IState> {
let fullScreenButton; let fullScreenButton;
if (!this.props.pipMode) { if (!this.props.pipMode) {
fullScreenButton = ( fullScreenButton = (
<div <AccessibleTooltipButton
className="mx_CallView_header_button mx_CallView_header_button_fullscreen" className="mx_CallView_header_button mx_CallView_header_button_fullscreen"
onClick={this.onFullscreenClick} onClick={this.onFullscreenClick}
title={_t("Fill Screen")} title={_t("Fill Screen")}
@ -809,7 +864,7 @@ export default class CallView extends React.Component<IProps, IState> {
let expandButton; let expandButton;
if (this.props.pipMode) { if (this.props.pipMode) {
expandButton = <div expandButton = <AccessibleTooltipButton
className="mx_CallView_header_button mx_CallView_header_button_expand" className="mx_CallView_header_button mx_CallView_header_button_expand"
onClick={this.onExpandClick} onClick={this.onExpandClick}
title={_t("Return to call")} title={_t("Return to call")}
@ -821,10 +876,15 @@ export default class CallView extends React.Component<IProps, IState> {
{ expandButton } { expandButton }
</div>; </div>;
const callTypeIconClassName = classNames("mx_CallView_header_callTypeIcon", {
"mx_CallView_header_callTypeIcon_voice": !isVideoCall,
"mx_CallView_header_callTypeIcon_video": isVideoCall,
});
let header: React.ReactNode; let header: React.ReactNode;
if (!this.props.pipMode) { if (!this.props.pipMode) {
header = <div className="mx_CallView_header"> header = <div className="mx_CallView_header">
<div className="mx_CallView_header_phoneIcon" /> <div className={callTypeIconClassName} />
<span className="mx_CallView_header_callType">{ callTypeText }</span> <span className="mx_CallView_header_callType">{ callTypeText }</span>
{ headerControls } { headerControls }
</div>; </div>;
@ -863,37 +923,9 @@ export default class CallView extends React.Component<IProps, IState> {
myClassName = 'mx_CallView_pip'; myClassName = 'mx_CallView_pip';
} }
let dialPad;
if (this.state.showDialpad) {
dialPad = <DialpadContextMenu
{...alwaysAboveRightOf(
this.dialpadButton.current.getBoundingClientRect(),
ChevronFace.None,
CONTEXT_MENU_VPADDING,
)}
onFinished={this.closeDialpad}
call={this.props.call}
/>;
}
let contextMenu;
if (this.state.showMoreMenu) {
contextMenu = <CallContextMenu
{...alwaysAboveLeftOf(
this.contextMenuButton.current.getBoundingClientRect(),
ChevronFace.None,
CONTEXT_MENU_VPADDING,
)}
onFinished={this.closeContextMenu}
call={this.props.call}
/>;
}
return <div className={"mx_CallView " + myClassName}> return <div className={"mx_CallView " + myClassName}>
{ header } { header }
{ contentView } { contentView }
{ dialPad }
{ contextMenu }
</div>; </div>;
} }
} }

View file

@ -1,176 +0,0 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2018 New Vector Ltd
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import { MatrixClientPeg } from '../../../MatrixClientPeg';
import dis from '../../../dispatcher/dispatcher';
import { _t } from '../../../languageHandler';
import { ActionPayload } from '../../../dispatcher/payloads';
import CallHandler, { CallHandlerEvent } from '../../../CallHandler';
import RoomAvatar from '../avatars/RoomAvatar';
import AccessibleButton from '../elements/AccessibleButton';
import { CallState } from 'matrix-js-sdk/src/webrtc/call';
import { replaceableComponent } from "../../../utils/replaceableComponent";
import AccessibleTooltipButton from '../elements/AccessibleTooltipButton';
import classNames from 'classnames';
interface IProps {
}
interface IState {
incomingCall: any;
silenced: boolean;
}
@replaceableComponent("views.voip.IncomingCallBox")
export default class IncomingCallBox extends React.Component<IProps, IState> {
private dispatcherRef: string;
constructor(props: IProps) {
super(props);
this.dispatcherRef = dis.register(this.onAction);
this.state = {
incomingCall: null,
silenced: false,
};
}
componentDidMount = () => {
CallHandler.sharedInstance().addListener(CallHandlerEvent.SilencedCallsChanged, this.onSilencedCallsChanged);
};
public componentWillUnmount() {
dis.unregister(this.dispatcherRef);
CallHandler.sharedInstance().removeListener(CallHandlerEvent.SilencedCallsChanged, this.onSilencedCallsChanged);
}
private onAction = (payload: ActionPayload) => {
switch (payload.action) {
case 'call_state': {
const call = CallHandler.sharedInstance().getCallForRoom(payload.room_id);
if (call && call.state === CallState.Ringing) {
this.setState({
incomingCall: call,
silenced: false, // Reset silenced state for new call
});
} else {
this.setState({
incomingCall: null,
});
}
}
}
};
private onSilencedCallsChanged = () => {
const callId = this.state.incomingCall?.callId;
if (!callId) return;
this.setState({ silenced: CallHandler.sharedInstance().isCallSilenced(callId) });
};
private onAnswerClick: React.MouseEventHandler = (e) => {
e.stopPropagation();
dis.dispatch({
action: 'answer',
room_id: CallHandler.sharedInstance().roomIdForCall(this.state.incomingCall),
});
};
private onRejectClick: React.MouseEventHandler = (e) => {
e.stopPropagation();
dis.dispatch({
action: 'reject',
room_id: CallHandler.sharedInstance().roomIdForCall(this.state.incomingCall),
});
};
private onSilenceClick: React.MouseEventHandler = (e) => {
e.stopPropagation();
const callId = this.state.incomingCall.callId;
this.state.silenced ?
CallHandler.sharedInstance().unSilenceCall(callId):
CallHandler.sharedInstance().silenceCall(callId);
};
public render() {
if (!this.state.incomingCall) {
return null;
}
let room = null;
if (this.state.incomingCall) {
room = MatrixClientPeg.get().getRoom(CallHandler.sharedInstance().roomIdForCall(this.state.incomingCall));
}
const caller = room ? room.name : _t("Unknown caller");
let incomingCallText = null;
if (this.state.incomingCall) {
if (this.state.incomingCall.type === "voice") {
incomingCallText = _t("Incoming voice call");
} else if (this.state.incomingCall.type === "video") {
incomingCallText = _t("Incoming video call");
} else {
incomingCallText = _t("Incoming call");
}
}
const silenceClass = classNames({
"mx_IncomingCallBox_iconButton": true,
"mx_IncomingCallBox_unSilence": this.state.silenced,
"mx_IncomingCallBox_silence": !this.state.silenced,
});
return <div className="mx_IncomingCallBox">
<div className="mx_IncomingCallBox_CallerInfo">
<RoomAvatar
room={room}
height={32}
width={32}
/>
<div>
<h1>{ caller }</h1>
<p>{ incomingCallText }</p>
</div>
<AccessibleTooltipButton
className={silenceClass}
onClick={this.onSilenceClick}
title={this.state.silenced ? _t("Sound on"): _t("Silence call")}
/>
</div>
<div className="mx_IncomingCallBox_buttons">
<AccessibleButton
className="mx_IncomingCallBox_decline"
onClick={this.onRejectClick}
kind="danger"
>
{ _t("Decline") }
</AccessibleButton>
<div className="mx_IncomingCallBox_spacer" />
<AccessibleButton
className="mx_IncomingCallBox_accept"
onClick={this.onAnswerClick}
kind="primary"
>
{ _t("Accept") }
</AccessibleButton>
</div>
</div>;
}
}

View file

@ -22,6 +22,7 @@ import { CallFeed, CallFeedEvent } from 'matrix-js-sdk/src/webrtc/callFeed';
import { logger } from 'matrix-js-sdk/src/logger'; import { logger } from 'matrix-js-sdk/src/logger';
import MemberAvatar from "../avatars/MemberAvatar"; import MemberAvatar from "../avatars/MemberAvatar";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import { SDPStreamMetadataPurpose } from 'matrix-js-sdk/src/webrtc/callEventTypes';
interface IProps { interface IProps {
call: MatrixCall; call: MatrixCall;
@ -47,7 +48,7 @@ interface IState {
} }
@replaceableComponent("views.voip.VideoFeed") @replaceableComponent("views.voip.VideoFeed")
export default class VideoFeed extends React.Component<IProps, IState> { export default class VideoFeed extends React.PureComponent<IProps, IState> {
private element: HTMLVideoElement; private element: HTMLVideoElement;
constructor(props: IProps) { constructor(props: IProps) {
@ -68,8 +69,15 @@ export default class VideoFeed extends React.Component<IProps, IState> {
this.updateFeed(this.props.feed, null); this.updateFeed(this.props.feed, null);
} }
componentDidUpdate(prevProps: IProps) { componentDidUpdate(prevProps: IProps, prevState: IState) {
this.updateFeed(prevProps.feed, this.props.feed); this.updateFeed(prevProps.feed, this.props.feed);
// If the mutes state has changed, we try to playMedia()
if (
prevState.videoMuted !== this.state.videoMuted ||
prevProps.feed.stream !== this.props.feed.stream
) {
this.playMedia();
}
} }
static getDerivedStateFromProps(props: IProps) { static getDerivedStateFromProps(props: IProps) {
@ -94,10 +102,12 @@ export default class VideoFeed extends React.Component<IProps, IState> {
if (oldFeed) { if (oldFeed) {
this.props.feed.removeListener(CallFeedEvent.NewStream, this.onNewStream); this.props.feed.removeListener(CallFeedEvent.NewStream, this.onNewStream);
this.props.feed.removeListener(CallFeedEvent.MuteStateChanged, this.onMuteStateChanged);
this.stopMedia(); this.stopMedia();
} }
if (newFeed) { if (newFeed) {
this.props.feed.addListener(CallFeedEvent.NewStream, this.onNewStream); this.props.feed.addListener(CallFeedEvent.NewStream, this.onNewStream);
this.props.feed.addListener(CallFeedEvent.MuteStateChanged, this.onMuteStateChanged);
this.playMedia(); this.playMedia();
} }
} }
@ -143,7 +153,13 @@ export default class VideoFeed extends React.Component<IProps, IState> {
audioMuted: this.props.feed.isAudioMuted(), audioMuted: this.props.feed.isAudioMuted(),
videoMuted: this.props.feed.isVideoMuted(), videoMuted: this.props.feed.isVideoMuted(),
}); });
this.playMedia(); };
private onMuteStateChanged = () => {
this.setState({
audioMuted: this.props.feed.isAudioMuted(),
videoMuted: this.props.feed.isVideoMuted(),
});
}; };
private onResize = (e) => { private onResize = (e) => {
@ -153,39 +169,59 @@ export default class VideoFeed extends React.Component<IProps, IState> {
}; };
render() { render() {
const videoClasses = { const { pipMode, primary, feed } = this.props;
mx_VideoFeed: true,
const wrapperClasses = classnames("mx_VideoFeed", {
mx_VideoFeed_voice: this.state.videoMuted, mx_VideoFeed_voice: this.state.videoMuted,
mx_VideoFeed_video: !this.state.videoMuted, });
mx_VideoFeed_mirror: ( const micIconClasses = classnames("mx_VideoFeed_mic", {
this.props.feed.isLocal() && mx_VideoFeed_mic_muted: this.state.audioMuted,
SettingsStore.getValue('VideoView.flipVideoHorizontally') mx_VideoFeed_mic_unmuted: !this.state.audioMuted,
), });
};
const { pipMode, primary } = this.props; let micIcon;
if (feed.purpose !== SDPStreamMetadataPurpose.Screenshare && !pipMode) {
micIcon = (
<div className={micIconClasses} />
);
}
let content;
if (this.state.videoMuted) { if (this.state.videoMuted) {
const member = this.props.feed.getMember(); const member = this.props.feed.getMember();
let avatarSize; let avatarSize;
if (pipMode && primary) avatarSize = 76; if (pipMode && primary) avatarSize = 76;
else if (pipMode && !primary) avatarSize = 16; else if (pipMode && !primary) avatarSize = 16;
else if (!pipMode && primary) avatarSize = 160; else if (!pipMode && primary) avatarSize = 160;
else; // TBD else; // TBD
return ( content =(
<div className={classnames(videoClasses)}> <MemberAvatar
<MemberAvatar member={member}
member={member} height={avatarSize}
height={avatarSize} width={avatarSize}
width={avatarSize} />
/>
</div>
); );
} else { } else {
return ( const videoClasses = classnames("mx_VideoFeed_video", {
<video className={classnames(videoClasses)} ref={this.setElementRef} /> mx_VideoFeed_video_mirror: (
this.props.feed.isLocal() &&
this.props.feed.purpose === SDPStreamMetadataPurpose.Usermedia &&
SettingsStore.getValue('VideoView.flipVideoHorizontally')
),
});
content= (
<video className={videoClasses} ref={this.setElementRef} />
); );
} }
return (
<div className={wrapperClasses}>
{ micIcon }
{ content }
</div>
);
} }
} }

View file

@ -18,9 +18,15 @@ limitations under the License.
import { MatrixClient } from "matrix-js-sdk/src/client"; import { MatrixClient } from "matrix-js-sdk/src/client";
import { Room } from "matrix-js-sdk/src/models/room"; import { Room } from "matrix-js-sdk/src/models/room";
import { RoomMember } from "matrix-js-sdk/src/models/room-member"; import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { EventType } from "matrix-js-sdk/src/@types/event"; import { EventType, RoomCreateTypeField, RoomType } from "matrix-js-sdk/src/@types/event";
import { ICreateRoomOpts } from "matrix-js-sdk/src/@types/requests"; import { ICreateRoomOpts } from "matrix-js-sdk/src/@types/requests";
import { JoinRule, Preset, RestrictedAllowType, Visibility } from "matrix-js-sdk/src/@types/partials"; import {
HistoryVisibility,
JoinRule,
Preset,
RestrictedAllowType,
Visibility,
} from "matrix-js-sdk/src/@types/partials";
import { MatrixClientPeg } from './MatrixClientPeg'; import { MatrixClientPeg } from './MatrixClientPeg';
import Modal from './Modal'; import Modal from './Modal';
@ -52,6 +58,9 @@ export interface IOpts {
inlineErrors?: boolean; inlineErrors?: boolean;
andView?: boolean; andView?: boolean;
associatedWithCommunity?: string; associatedWithCommunity?: string;
avatar?: File | string; // will upload if given file, else mxcUrl is needed
roomType?: RoomType | string;
historyVisibility?: HistoryVisibility;
parentSpace?: Room; parentSpace?: Room;
joinRule?: JoinRule; joinRule?: JoinRule;
} }
@ -112,6 +121,13 @@ export default async function createRoom(opts: IOpts): Promise<string | null> {
createOpts.is_direct = true; createOpts.is_direct = true;
} }
if (opts.roomType) {
createOpts.creation_content = {
...createOpts.creation_content,
[RoomCreateTypeField]: opts.roomType,
};
}
// By default, view the room after creating it // By default, view the room after creating it
if (opts.andView === undefined) { if (opts.andView === undefined) {
opts.andView = true; opts.andView = true;
@ -144,12 +160,11 @@ export default async function createRoom(opts: IOpts): Promise<string | null> {
if (opts.parentSpace) { if (opts.parentSpace) {
createOpts.initial_state.push(makeSpaceParentEvent(opts.parentSpace, true)); createOpts.initial_state.push(makeSpaceParentEvent(opts.parentSpace, true));
createOpts.initial_state.push({ if (!opts.historyVisibility) {
type: EventType.RoomHistoryVisibility, opts.historyVisibility = createOpts.preset === Preset.PublicChat
content: { ? HistoryVisibility.WorldReadable
"history_visibility": createOpts.preset === Preset.PublicChat ? "world_readable" : "invited", : HistoryVisibility.Invited;
}, }
});
if (opts.joinRule === JoinRule.Restricted) { if (opts.joinRule === JoinRule.Restricted) {
if (SpaceStore.instance.restrictedJoinRuleSupport?.preferred) { if (SpaceStore.instance.restrictedJoinRuleSupport?.preferred) {
@ -169,13 +184,35 @@ export default async function createRoom(opts: IOpts): Promise<string | null> {
} }
} }
if (opts.joinRule !== JoinRule.Restricted) { // we handle the restricted join rule in the parentSpace handling block above
if (opts.joinRule && opts.joinRule !== JoinRule.Restricted) {
createOpts.initial_state.push({ createOpts.initial_state.push({
type: EventType.RoomJoinRules, type: EventType.RoomJoinRules,
content: { join_rule: opts.joinRule }, content: { join_rule: opts.joinRule },
}); });
} }
if (opts.avatar) {
let url = opts.avatar;
if (opts.avatar instanceof File) {
url = await client.uploadContent(opts.avatar);
}
createOpts.initial_state.push({
type: EventType.RoomAvatar,
content: { url },
});
}
if (opts.historyVisibility) {
createOpts.initial_state.push({
type: EventType.RoomHistoryVisibility,
content: {
"history_visibility": opts.historyVisibility,
},
});
}
let modal; let modal;
if (opts.spinner) modal = Modal.createDialog(Spinner, null, 'mx_Dialog_spinner'); if (opts.spinner) modal = Modal.createDialog(Spinner, null, 'mx_Dialog_spinner');

View file

@ -0,0 +1,53 @@
/*
* Copyright 2021 The Matrix.org Foundation C.I.C.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// Populate this class with the details of your customisations when copying it.
import { ITemplateParams } from "matrix-widget-api";
/**
* Provides a partial set of the variables needed to render any widget. If
* variables are missing or not provided then they will be filled with the
* application-determined defaults.
*
* This will not be called until after isReady() resolves.
* @returns {Partial<Omit<ITemplateParams, "widgetRoomId">>} The variables.
*/
function provideVariables(): Partial<Omit<ITemplateParams, "widgetRoomId">> {
return {};
}
/**
* Resolves to whether or not the customisation point is ready for variables
* to be provided. This will block widgets being rendered.
* @returns {Promise<boolean>} Resolves when ready.
*/
async function isReady(): Promise<void> {
return; // default no waiting
}
// This interface summarises all available customisation points and also marks
// them all as optional. This allows customisers to only define and export the
// customisations they need while still maintaining type safety.
export interface IWidgetVariablesCustomisations {
provideVariables?: typeof provideVariables;
// If not provided, the app will assume that the customisation is always ready.
isReady?: typeof isReady;
}
// A real customisation module will define and export one or more of the
// customisation points that make up the interface above.
export const WidgetVariableCustomisations: IWidgetVariablesCustomisations = {};

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