Merge branch 't3chguy/ts/8' into text-for-event-perf

This commit is contained in:
Robin Townsend 2021-06-24 18:39:36 -04:00
commit f9fe28a6ad
179 changed files with 5561 additions and 3317 deletions

3
.github/PULL_REQUEST_TEMPLATE.md vendored Normal file
View file

@ -0,0 +1,3 @@
<!-- Please read https://github.com/matrix-org/matrix-js-sdk/blob/develop/CONTRIBUTING.rst before submitting your pull request -->
<!-- Include a Sign-Off as described in https://github.com/matrix-org/matrix-js-sdk/blob/develop/CONTRIBUTING.rst#sign-off -->

View file

@ -11,10 +11,13 @@ jobs:
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v2 uses: actions/checkout@v2
- name: End-to-End tests - name: Prepare End-to-End tests
run: ./scripts/ci/end-to-end-tests.sh run: ./scripts/ci/prepare-end-to-end-tests.sh
- name: Run End-to-End tests
run: ./scripts/ci/run-end-to-end-tests.sh
- name: Archive logs - name: Archive logs
uses: actions/upload-artifact@v2 uses: actions/upload-artifact@v2
if: ${{ always() }}
with: with:
path: | path: |
test/end-to-end-tests/logs/**/* test/end-to-end-tests/logs/**/*

View file

@ -1,3 +1,133 @@
Changes in [3.24.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.24.0) (2021-06-21)
=====================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.24.0-rc.1...v3.24.0)
* Upgrade to JS SDK 12.0.0
* [Release] Keep composer reply when scrolling away from a highlighted event
[\#6211](https://github.com/matrix-org/matrix-react-sdk/pull/6211)
* [Release] Remove stray bullet point in reply preview
[\#6210](https://github.com/matrix-org/matrix-react-sdk/pull/6210)
* [Release] Stop requesting null next replies from the server
[\#6209](https://github.com/matrix-org/matrix-react-sdk/pull/6209)
Changes in [3.24.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.24.0-rc.1) (2021-06-15)
===============================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.23.0...v3.24.0-rc.1)
* Upgrade to JS SDK 12.0.0-rc.1
* Translations update from Weblate
[\#6192](https://github.com/matrix-org/matrix-react-sdk/pull/6192)
* Disable comment-on-alert for PR coming from a fork
[\#6189](https://github.com/matrix-org/matrix-react-sdk/pull/6189)
* Add JS benchmark tracking in CI
[\#6177](https://github.com/matrix-org/matrix-react-sdk/pull/6177)
* Upgrade matrix-react-test-utils for React 17 peer deps
[\#6187](https://github.com/matrix-org/matrix-react-sdk/pull/6187)
* Fix display name overlaps on the IRC layout
[\#6186](https://github.com/matrix-org/matrix-react-sdk/pull/6186)
* Small fixes to the spaces experience
[\#6184](https://github.com/matrix-org/matrix-react-sdk/pull/6184)
* Add footer and privacy note to the start dm dialog
[\#6111](https://github.com/matrix-org/matrix-react-sdk/pull/6111)
* Format mxids when disambiguation needed
[\#5880](https://github.com/matrix-org/matrix-react-sdk/pull/5880)
* Move various createRoom types to the js-sdk
[\#6183](https://github.com/matrix-org/matrix-react-sdk/pull/6183)
* Fix HTML tag for Event Tile when not rendered in a list
[\#6175](https://github.com/matrix-org/matrix-react-sdk/pull/6175)
* Remove legacy polyfills and unused dependencies
[\#6176](https://github.com/matrix-org/matrix-react-sdk/pull/6176)
* Fix buggy hovering/selecting of event tiles
[\#6173](https://github.com/matrix-org/matrix-react-sdk/pull/6173)
* Add room intro warning when e2ee is not enabled
[\#5929](https://github.com/matrix-org/matrix-react-sdk/pull/5929)
* Migrate end to end tests to GitHub actions
[\#6156](https://github.com/matrix-org/matrix-react-sdk/pull/6156)
* Fix expanding last collapsed sticky session when zoomed in
[\#6171](https://github.com/matrix-org/matrix-react-sdk/pull/6171)
* ⚛️ Upgrade to React@17
[\#6165](https://github.com/matrix-org/matrix-react-sdk/pull/6165)
* Revert refreshStickyHeaders optimisations
[\#6168](https://github.com/matrix-org/matrix-react-sdk/pull/6168)
* Add logging for which rooms calls are in
[\#6170](https://github.com/matrix-org/matrix-react-sdk/pull/6170)
* Restore read receipt animation from event to event
[\#6169](https://github.com/matrix-org/matrix-react-sdk/pull/6169)
* Restore copy button icon when sharing permalink
[\#6166](https://github.com/matrix-org/matrix-react-sdk/pull/6166)
* Restore Page Up/Down key bindings when focusing the composer
[\#6167](https://github.com/matrix-org/matrix-react-sdk/pull/6167)
* Timeline rendering optimizations
[\#6143](https://github.com/matrix-org/matrix-react-sdk/pull/6143)
* Bump css-what from 5.0.0 to 5.0.1
[\#6164](https://github.com/matrix-org/matrix-react-sdk/pull/6164)
* Bump ws from 6.2.1 to 6.2.2 in /test/end-to-end-tests
[\#6145](https://github.com/matrix-org/matrix-react-sdk/pull/6145)
* Bump trim-newlines from 3.0.0 to 3.0.1
[\#6163](https://github.com/matrix-org/matrix-react-sdk/pull/6163)
* Fix upgrade to element home button in top left menu
[\#6162](https://github.com/matrix-org/matrix-react-sdk/pull/6162)
* Fix unpinning of pinned messages and panel empty state
[\#6140](https://github.com/matrix-org/matrix-react-sdk/pull/6140)
* Better handling for widgets that fail to load
[\#6161](https://github.com/matrix-org/matrix-react-sdk/pull/6161)
* Improved forwarding UI
[\#5999](https://github.com/matrix-org/matrix-react-sdk/pull/5999)
* Fixes for sharing room links
[\#6118](https://github.com/matrix-org/matrix-react-sdk/pull/6118)
* Fix setting watchers
[\#6160](https://github.com/matrix-org/matrix-react-sdk/pull/6160)
* Fix Stickerpicker context menu
[\#6152](https://github.com/matrix-org/matrix-react-sdk/pull/6152)
* Add warning to private space creation flow
[\#6155](https://github.com/matrix-org/matrix-react-sdk/pull/6155)
* Add prop to alwaysShowTimestamps on TimelinePanel
[\#6159](https://github.com/matrix-org/matrix-react-sdk/pull/6159)
* Fix notif panel timestamp padding
[\#6157](https://github.com/matrix-org/matrix-react-sdk/pull/6157)
* Fixes and refactoring for the ImageView
[\#6149](https://github.com/matrix-org/matrix-react-sdk/pull/6149)
* Fix timestamps
[\#6148](https://github.com/matrix-org/matrix-react-sdk/pull/6148)
* Make it easier to pan images in the lightbox
[\#6147](https://github.com/matrix-org/matrix-react-sdk/pull/6147)
* Fix scroll token for EventTile and EventListSummary node type
[\#6154](https://github.com/matrix-org/matrix-react-sdk/pull/6154)
* Convert bunch of things to Typescript
[\#6153](https://github.com/matrix-org/matrix-react-sdk/pull/6153)
* Lint the typescript tests
[\#6142](https://github.com/matrix-org/matrix-react-sdk/pull/6142)
* Fix jumping to bottom without a highlighted event
[\#6146](https://github.com/matrix-org/matrix-react-sdk/pull/6146)
* Repair event status position in timeline
[\#6141](https://github.com/matrix-org/matrix-react-sdk/pull/6141)
* Adapt for js-sdk MatrixClient conversion to TS
[\#6132](https://github.com/matrix-org/matrix-react-sdk/pull/6132)
* Improve pinned messages in Labs
[\#6096](https://github.com/matrix-org/matrix-react-sdk/pull/6096)
* Map phone number lookup results to their native rooms
[\#6136](https://github.com/matrix-org/matrix-react-sdk/pull/6136)
* Fix mx_Event containment rules and empty read avatar row
[\#6138](https://github.com/matrix-org/matrix-react-sdk/pull/6138)
* Improve switch room rendering
[\#6079](https://github.com/matrix-org/matrix-react-sdk/pull/6079)
* Add CSS containment rules for shorter reflow operations
[\#6127](https://github.com/matrix-org/matrix-react-sdk/pull/6127)
* ignore hash/fragment when de-duplicating links for url previews
[\#6135](https://github.com/matrix-org/matrix-react-sdk/pull/6135)
* Clicking jump to bottom resets room hash
[\#5823](https://github.com/matrix-org/matrix-react-sdk/pull/5823)
* Use passive option for scroll handlers
[\#6113](https://github.com/matrix-org/matrix-react-sdk/pull/6113)
* Optimise memberSort performance for large list
[\#6130](https://github.com/matrix-org/matrix-react-sdk/pull/6130)
* Tweak event border radius to match action bar
[\#6133](https://github.com/matrix-org/matrix-react-sdk/pull/6133)
* Log when we ignore a second call in a room
[\#6131](https://github.com/matrix-org/matrix-react-sdk/pull/6131)
* Performance monitoring measurements
[\#6041](https://github.com/matrix-org/matrix-react-sdk/pull/6041)
Changes in [3.23.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.23.0) (2021-06-07) Changes in [3.23.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.23.0) (2021-06-07)
===================================================================================================== =====================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.23.0-rc.1...v3.23.0) [Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.23.0-rc.1...v3.23.0)

View file

@ -1,6 +1,6 @@
{ {
"name": "matrix-react-sdk", "name": "matrix-react-sdk",
"version": "3.23.0", "version": "3.24.0",
"description": "SDK for matrix.org using React", "description": "SDK for matrix.org using React",
"author": "matrix.org", "author": "matrix.org",
"repository": { "repository": {
@ -78,8 +78,8 @@
"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": "github:matrix-org/matrix-js-sdk#develop", "matrix-js-sdk": "12.0.0",
"matrix-widget-api": "^0.1.0-beta.14", "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",
@ -89,7 +89,7 @@
"qrcode": "^1.4.4", "qrcode": "^1.4.4",
"re-resizable": "^6.9.0", "re-resizable": "^6.9.0",
"react": "^17.0.2", "react": "^17.0.2",
"react-beautiful-dnd": "^4.0.1", "react-beautiful-dnd": "^13.1.0",
"react-dom": "^17.0.2", "react-dom": "^17.0.2",
"react-focus-lock": "^2.5.0", "react-focus-lock": "^2.5.0",
"react-transition-group": "^4.4.1", "react-transition-group": "^4.4.1",
@ -123,6 +123,7 @@
"@sinonjs/fake-timers": "^7.0.2", "@sinonjs/fake-timers": "^7.0.2",
"@types/classnames": "^2.2.11", "@types/classnames": "^2.2.11",
"@types/counterpart": "^0.18.1", "@types/counterpart": "^0.18.1",
"@types/diff-match-patch": "^1.0.32",
"@types/flux": "^3.1.9", "@types/flux": "^3.1.9",
"@types/jest": "^26.0.20", "@types/jest": "^26.0.20",
"@types/linkifyjs": "^2.1.3", "@types/linkifyjs": "^2.1.3",
@ -132,19 +133,20 @@
"@types/pako": "^1.0.1", "@types/pako": "^1.0.1",
"@types/parse5": "^6.0.0", "@types/parse5": "^6.0.0",
"@types/qrcode": "^1.3.5", "@types/qrcode": "^1.3.5",
"@types/react": "^16.9", "@types/react": "^17.0.2",
"@types/react-dom": "^16.9.10", "@types/react-beautiful-dnd": "^13.0.0",
"@types/react-dom": "^17.0.2",
"@types/react-transition-group": "^4.4.0", "@types/react-transition-group": "^4.4.0",
"@types/sanitize-html": "^2.3.1", "@types/sanitize-html": "^2.3.1",
"@types/zxcvbn": "^4.4.0", "@types/zxcvbn": "^4.4.0",
"@typescript-eslint/eslint-plugin": "^4.14.0", "@typescript-eslint/eslint-plugin": "^4.14.0",
"@typescript-eslint/parser": "^4.14.0", "@typescript-eslint/parser": "^4.14.0",
"@wojtekmaj/enzyme-adapter-react-17": "^0.6.1",
"babel-eslint": "^10.1.0", "babel-eslint": "^10.1.0",
"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",
"@wojtekmaj/enzyme-adapter-react-17": "^0.6.1",
"eslint": "7.18.0", "eslint": "7.18.0",
"eslint-config-matrix-org": "^0.2.0", "eslint-config-matrix-org": "^0.2.0",
"eslint-plugin-babel": "^5.3.1", "eslint-plugin-babel": "^5.3.1",
@ -167,13 +169,10 @@
"typescript": "^4.1.3", "typescript": "^4.1.3",
"walk": "^2.3.14" "walk": "^2.3.14"
}, },
"resolutions": {
"**/@types/react": "^16.14"
},
"jest": { "jest": {
"testEnvironment": "./__test-utils__/environment.js", "testEnvironment": "./__test-utils__/environment.js",
"testMatch": [ "testMatch": [
"<rootDir>/test/**/*-test.[jt]s" "<rootDir>/test/**/*-test.[jt]s?(x)"
], ],
"setupFiles": [ "setupFiles": [
"jest-canvas-mock" "jest-canvas-mock"

View file

@ -123,7 +123,6 @@
@import "./views/elements/_EventListSummary.scss"; @import "./views/elements/_EventListSummary.scss";
@import "./views/elements/_FacePile.scss"; @import "./views/elements/_FacePile.scss";
@import "./views/elements/_Field.scss"; @import "./views/elements/_Field.scss";
@import "./views/elements/_FormButton.scss";
@import "./views/elements/_ImageView.scss"; @import "./views/elements/_ImageView.scss";
@import "./views/elements/_InfoTooltip.scss"; @import "./views/elements/_InfoTooltip.scss";
@import "./views/elements/_InlineSpinner.scss"; @import "./views/elements/_InlineSpinner.scss";

View file

@ -31,7 +31,6 @@ $activeBorderColor: $secondary-fg-color;
// Create another flexbox so the Panel fills the container // Create another flexbox so the Panel fills the container
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow-y: auto;
.mx_SpacePanel_spaceTreeWrapper { .mx_SpacePanel_spaceTreeWrapper {
flex: 1; flex: 1;
@ -69,6 +68,12 @@ $activeBorderColor: $secondary-fg-color;
cursor: pointer; cursor: pointer;
} }
.mx_SpaceItem_dragging {
.mx_SpaceButton_toggleCollapse {
visibility: hidden;
}
}
.mx_SpaceTreeLevel { .mx_SpaceTreeLevel {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View file

@ -71,7 +71,7 @@ limitations under the License.
&::before { &::before {
background-color: #ffffff; background-color: #ffffff;
mask-image: url('$(res)/img/e2e/normal.svg'); mask-image: url('$(res)/img/e2e/normal.svg');
mask-size: 90%; mask-size: 80%;
} }
&::after { &::after {
@ -134,8 +134,9 @@ limitations under the License.
.mx_Toast_buttons { .mx_Toast_buttons {
float: right; float: right;
display: flex; display: flex;
gap: 5px;
.mx_FormButton { .mx_AccessibleButton {
min-width: 96px; min-width: 96px;
box-sizing: border-box; box-sizing: border-box;
} }

View file

@ -19,9 +19,11 @@ limitations under the License.
padding: 24px; padding: 24px;
background-color: $settings-profile-placeholder-bg-color; background-color: $settings-profile-placeholder-bg-color;
border-radius: 8px; border-radius: 8px;
display: flex;
box-sizing: border-box; box-sizing: border-box;
.mx_BetaCard_columns {
display: flex;
> div { > div {
.mx_BetaCard_title { .mx_BetaCard_title {
font-weight: $font-semi-bold; font-weight: $font-semi-bold;
@ -42,7 +44,7 @@ limitations under the License.
margin-bottom: 20px; margin-bottom: 20px;
} }
.mx_AccessibleButton { .mx_BetaCard_buttons .mx_AccessibleButton {
display: block; display: block;
margin: 12px 0; margin: 12px 0;
padding: 7px 40px; padding: 7px 40px;
@ -65,6 +67,23 @@ limitations under the License.
} }
} }
.mx_BetaCard_relatedSettings {
.mx_SettingsFlag {
margin: 16px 0 0;
font-size: $font-15px;
line-height: $font-24px;
color: $primary-fg-color;
.mx_SettingsFlag_microcopy {
margin-top: 4px;
font-size: $font-12px;
line-height: $font-15px;
color: $secondary-fg-color;
}
}
}
}
.mx_BetaCard_betaPill { .mx_BetaCard_betaPill {
background-color: $accent-color-alt; background-color: $accent-color-alt;
padding: 4px 10px; padding: 4px 10px;

View file

@ -38,6 +38,15 @@ limitations under the License.
mask-image: url('$(res)/img/element-icons/view-community.svg'); mask-image: url('$(res)/img/element-icons/view-community.svg');
} }
.mx_TagTileContextMenu_moveUp::before {
transform: rotate(180deg);
mask-image: url('$(res)/img/feather-customised/chevron-down.svg');
}
.mx_TagTileContextMenu_moveDown::before {
mask-image: url('$(res)/img/feather-customised/chevron-down.svg');
}
.mx_TagTileContextMenu_hideCommunity::before { .mx_TagTileContextMenu_hideCommunity::before {
mask-image: url('$(res)/img/element-icons/hide.svg'); mask-image: url('$(res)/img/element-icons/hide.svg');
} }

View file

@ -295,6 +295,7 @@ limitations under the License.
.mx_InviteDialog_content { .mx_InviteDialog_content {
overflow: hidden; overflow: hidden;
height: 100%;
} }
} }
@ -316,3 +317,42 @@ limitations under the License.
.mx_InviteDialog_helpText .mx_AccessibleButton_kind_link { .mx_InviteDialog_helpText .mx_AccessibleButton_kind_link {
padding: 0; padding: 0;
} }
.mx_InviteDialog_multiInviterError {
> h4 {
font-size: $font-15px;
line-height: $font-24px;
color: $secondary-fg-color;
font-weight: normal;
}
> div {
.mx_InviteDialog_multiInviterError_entry {
margin-bottom: 24px;
.mx_InviteDialog_multiInviterError_entry_userProfile {
.mx_InviteDialog_multiInviterError_entry_name {
margin-left: 6px;
font-size: $font-15px;
line-height: $font-24px;
font-weight: $font-semi-bold;
color: $primary-fg-color;
}
.mx_InviteDialog_multiInviterError_entry_userId {
margin-left: 6px;
font-size: $font-12px;
line-height: $font-15px;
color: $tertiary-fg-color;
}
}
.mx_InviteDialog_multiInviterError_entry_error {
margin-left: 32px;
font-size: $font-15px;
line-height: $font-24px;
color: $notice-primary-color;
}
}
}
}

View file

@ -15,7 +15,7 @@ limitations under the License.
*/ */
// Not actually a component but things shared by settings components // Not actually a component but things shared by settings components
.mx_UserSettingsDialog, .mx_RoomSettingsDialog { .mx_UserSettingsDialog, .mx_RoomSettingsDialog, .mx_SpaceSettingsDialog {
width: 90vw; width: 90vw;
max-width: 1000px; max-width: 1000px;
// set the height too since tabbed view scrolls itself. // set the height too since tabbed view scrolls itself.

View file

@ -15,7 +15,6 @@ limitations under the License.
*/ */
.mx_SpaceSettingsDialog { .mx_SpaceSettingsDialog {
width: 480px;
color: $primary-fg-color; color: $primary-fg-color;
.mx_SpaceSettings_errorText { .mx_SpaceSettings_errorText {
@ -32,8 +31,44 @@ limitations under the License.
margin-left: 16px; margin-left: 16px;
} }
.mx_AccessibleButton_kind_danger { .mx_SettingsTab_section {
margin-top: 28px; .mx_SettingsTab_section_caption {
margin-top: 12px;
margin-bottom: 20px;
}
& + .mx_SettingsTab_subheading {
border-top: 1px solid $message-body-panel-bg-color;
margin-top: 0;
padding-top: 24px;
}
.mx_RadioButton {
margin-top: 8px;
margin-bottom: 4px;
.mx_RadioButton_content {
font-weight: $font-semi-bold;
line-height: $font-18px;
color: $primary-fg-color;
}
& + span {
font-size: $font-15px;
line-height: $font-18px;
color: $secondary-fg-color;
margin-left: 26px;
}
}
.mx_SettingsTab_showAdvanced {
margin: 16px 0;
padding: 0;
}
.mx_SettingsFlag {
margin-top: 24px;
}
} }
.mx_SpaceSettingsDialog_buttons { .mx_SpaceSettingsDialog_buttons {
@ -52,4 +87,14 @@ limitations under the License.
.mx_AccessibleButton_hasKind { .mx_AccessibleButton_hasKind {
padding: 8px 22px; padding: 8px 22px;
} }
.mx_TabbedView_tabLabel {
.mx_SpaceSettingsDialog_generalIcon::before {
mask-image: url('$(res)/img/element-icons/settings.svg');
}
.mx_SpaceSettingsDialog_visibilityIcon::before {
mask-image: url('$(res)/img/element-icons/eye.svg');
}
}
} }

View file

@ -1,42 +0,0 @@
/*
Copyright 2019 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_FormButton {
line-height: $font-16px;
padding: 5px 15px;
font-size: $font-12px;
height: min-content;
&:not(:last-child) {
margin-right: 8px;
}
&.mx_AccessibleButton_kind_primary {
color: $accent-color;
background-color: $accent-bg-color;
}
&.mx_AccessibleButton_kind_danger {
color: $notice-primary-color;
background-color: $notice-primary-bg-color;
}
&.mx_AccessibleButton_kind_secondary {
color: $secondary-fg-color;
border: 1px solid $secondary-fg-color;
background-color: unset;
}
}

View file

@ -21,7 +21,7 @@ limitations under the License.
mask-image: url('$(res)/img/e2e/normal.svg'); mask-image: url('$(res)/img/e2e/normal.svg');
mask-repeat: no-repeat; mask-repeat: no-repeat;
mask-position: center; mask-position: center;
mask-size: 90%; mask-size: 80%;
} }
&.mx_cryptoEvent_icon::after { &.mx_cryptoEvent_icon::after {

View file

@ -259,16 +259,6 @@ limitations under the License.
.mx_AccessibleButton.mx_AccessibleButton_hasKind { .mx_AccessibleButton.mx_AccessibleButton_hasKind {
padding: 8px 18px; padding: 8px 18px;
&.mx_AccessibleButton_kind_primary {
color: $accent-color;
background-color: $accent-bg-color;
}
&.mx_AccessibleButton_kind_danger {
color: $notice-primary-color;
background-color: $notice-primary-bg-color;
}
} }
.mx_VerificationShowSas .mx_AccessibleButton, .mx_VerificationShowSas .mx_AccessibleButton,

View file

@ -58,7 +58,7 @@ limitations under the License.
} }
.mx_VerificationPanel_reciprocate_section { .mx_VerificationPanel_reciprocate_section {
.mx_FormButton { .mx_AccessibleButton {
width: 100%; width: 100%;
box-sizing: border-box; box-sizing: border-box;
padding: 10px; padding: 10px;

View file

@ -45,7 +45,7 @@ limitations under the License.
mask-image: url('$(res)/img/e2e/normal.svg'); mask-image: url('$(res)/img/e2e/normal.svg');
mask-repeat: no-repeat; mask-repeat: no-repeat;
mask-position: center; mask-position: center;
mask-size: 90%; mask-size: 80%;
} }
// transparent-looking border surrounding the shield for when overlain over avatars // transparent-looking border surrounding the shield for when overlain over avatars
@ -59,7 +59,7 @@ limitations under the License.
} }
// shrink the infill of the badge // shrink the infill of the badge
&::before { &::before {
mask-size: 65%; mask-size: 60%;
} }
} }

View file

@ -345,7 +345,7 @@ $hover-select-border: 4px;
mask-image: url('$(res)/img/e2e/normal.svg'); mask-image: url('$(res)/img/e2e/normal.svg');
mask-repeat: no-repeat; mask-repeat: no-repeat;
mask-position: center; mask-position: center;
mask-size: 90%; mask-size: 80%;
} }
} }

View file

@ -16,7 +16,7 @@ limitations under the License.
.mx_SpaceBasicSettings { .mx_SpaceBasicSettings {
.mx_Field { .mx_Field {
margin: 32px 0; margin: 24px 0;
} }
.mx_SpaceBasicSettings_avatarContainer { .mx_SpaceBasicSettings_avatarContainer {
@ -73,7 +73,7 @@ limitations under the License.
} }
} }
.mx_FormButton { .mx_AccessibleButton {
padding: 8px 22px; padding: 8px 22px;
margin-left: auto; margin-left: auto;
display: block; display: block;

View file

@ -23,7 +23,7 @@ limitations under the License.
.mx_DialPad_button { .mx_DialPad_button {
width: 40px; width: 40px;
height: 40px; height: 40px;
background-color: $theme-button-bg-color; background-color: $dialpad-button-bg-color;
border-radius: 40px; border-radius: 40px;
font-size: 18px; font-size: 18px;
font-weight: 600; font-weight: 600;

View file

@ -27,9 +27,22 @@ limitations under the License.
} }
.mx_DialPadContextMenu_dialled { .mx_DialPadContextMenu_dialled {
height: 1em; height: 1.5em;
font-size: 18px; font-size: 18px;
font-weight: 600; font-weight: 600;
max-width: 150px;
border: none;
margin: 0px;
}
.mx_DialPadContextMenu_dialled input {
font-size: 18px;
font-weight: 600;
overflow: hidden;
max-width: 150px;
text-align: left;
direction: rtl;
padding: 8px 0px;
background-color: rgb(0, 0, 0, 0);
} }
.mx_DialPadContextMenu_dialPad { .mx_DialPadContextMenu_dialPad {

View file

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M14.3094 5.96587C15.3206 7.15704 15.3417 8.85457 14.3412 10.0548C13.0889 11.5571 10.9822 13.3332 8.02104 13.3332C5.05992 13.3332 2.9532 11.5571 1.70087 10.0548C0.700398 8.85457 0.721506 7.15704 1.7327 5.96587C3.01174 4.45918 5.1391 2.6665 8.02104 2.6665C10.903 2.6665 13.0303 4.45918 14.3094 5.96587ZM11.5556 7.99984C11.5556 9.96352 9.96369 11.5554 8.00001 11.5554C6.03633 11.5554 4.44446 9.96352 4.44446 7.99984C4.44446 6.03616 6.03633 4.44428 8.00001 4.44428C9.96369 4.44428 11.5556 6.03616 11.5556 7.99984ZM8.00001 9.77761C8.98185 9.77761 9.77779 8.98168 9.77779 7.99984C9.77779 7.018 8.98185 6.22206 8.00001 6.22206C7.01817 6.22206 6.22224 7.018 6.22224 7.99984C6.22224 8.98168 7.01817 9.77761 8.00001 9.77761Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 887 B

View file

@ -118,6 +118,9 @@ $voipcall-plinth-color: #394049;
// ******************** // ********************
$theme-button-bg-color: #e3e8f0; $theme-button-bg-color: #e3e8f0;
$dialpad-button-bg-color: #6F7882;
;
$roomlist-button-bg-color: rgba(141, 151, 165, 0.2); // Buttons include the filter box, explore button, and sublist buttons $roomlist-button-bg-color: rgba(141, 151, 165, 0.2); // Buttons include the filter box, explore button, and sublist buttons
$roomlist-filter-active-bg-color: $bg-color; $roomlist-filter-active-bg-color: $bg-color;

View file

@ -114,6 +114,8 @@ $voipcall-plinth-color: #394049;
// ******************** // ********************
$theme-button-bg-color: #e3e8f0; $theme-button-bg-color: #e3e8f0;
$dialpad-button-bg-color: #6F7882;
;
$roomlist-button-bg-color: #1A1D23; // Buttons include the filter box, explore button, and sublist buttons $roomlist-button-bg-color: #1A1D23; // Buttons include the filter box, explore button, and sublist buttons
$roomlist-filter-active-bg-color: $roomlist-button-bg-color; $roomlist-filter-active-bg-color: $roomlist-button-bg-color;

View file

@ -181,6 +181,8 @@ $voipcall-plinth-color: #F4F6FA;
// ******************** // ********************
$theme-button-bg-color: #e3e8f0; $theme-button-bg-color: #e3e8f0;
$dialpad-button-bg-color: #e3e8f0;
$roomlist-button-bg-color: #fff; // Buttons include the filter box, explore button, and sublist buttons $roomlist-button-bg-color: #fff; // Buttons include the filter box, explore button, and sublist buttons
$roomlist-filter-active-bg-color: $roomlist-button-bg-color; $roomlist-filter-active-bg-color: $roomlist-button-bg-color;

View file

@ -173,6 +173,8 @@ $voipcall-plinth-color: #F4F6FA;
// ******************** // ********************
$theme-button-bg-color: #e3e8f0; $theme-button-bg-color: #e3e8f0;
$dialpad-button-bg-color: #e3e8f0;
$roomlist-button-bg-color: rgba(141, 151, 165, 0.2); // Buttons include the filter box, explore button, and sublist buttons $roomlist-button-bg-color: rgba(141, 151, 165, 0.2); // Buttons include the filter box, explore button, and sublist buttons
$roomlist-filter-active-bg-color: #ffffff; $roomlist-filter-active-bg-color: #ffffff;

View file

@ -3,6 +3,6 @@
# docker push vectorim/element-web-ci-e2etests-env:latest # docker push vectorim/element-web-ci-e2etests-env:latest
FROM node:14-buster FROM node:14-buster
RUN apt-get update RUN apt-get update
RUN apt-get -y install build-essential python3-dev libffi-dev python-pip python-setuptools sqlite3 libssl-dev python-virtualenv libjpeg-dev libxslt1-dev uuid-runtime RUN apt-get -y install jq build-essential python3-dev libffi-dev python-pip python-setuptools sqlite3 libssl-dev python-virtualenv libjpeg-dev libxslt1-dev uuid-runtime
# dependencies for chrome (installed by puppeteer) # dependencies for chrome (installed by puppeteer)
RUN apt-get -y install gconf-service libasound2 libatk1.0-0 libatk-bridge2.0-0 libc6 libcairo2 libcups2 libdbus-1-3 libexpat1 libfontconfig1 libgcc1 libgconf-2-4 libgdk-pixbuf2.0-0 libglib2.0-0 libgtk-3-0 libnspr4 libpango-1.0-0 libpangocairo-1.0-0 libstdc++6 libx11-6 libx11-xcb1 libxcb1 libxcomposite1 libxcursor1 libxdamage1 libxext6 libxfixes3 libxi6 libxrandr2 libxrender1 libxss1 libxtst6 ca-certificates fonts-liberation libappindicator1 libnss3 lsb-release xdg-utils wget RUN apt-get -y install gconf-service libasound2 libatk1.0-0 libatk-bridge2.0-0 libc6 libcairo2 libcups2 libdbus-1-3 libexpat1 libfontconfig1 libgcc1 libgconf-2-4 libgdk-pixbuf2.0-0 libglib2.0-0 libgtk-3-0 libnspr4 libpango-1.0-0 libpangocairo-1.0-0 libstdc++6 libx11-6 libx11-xcb1 libxcb1 libxcomposite1 libxcursor1 libxdamage1 libxext6 libxfixes3 libxi6 libxrandr2 libxrender1 libxss1 libxtst6 ca-certificates fonts-liberation libappindicator1 libnss3 lsb-release xdg-utils wget

View file

@ -1,8 +1,4 @@
#!/bin/bash #!/bin/bash
#
# script which is run by the CI build (after `yarn test`).
#
# clones element-web develop and runs the tests against our version of react-sdk.
set -ev set -ev
@ -19,7 +15,7 @@ cd element-web
element_web_dir=`pwd` element_web_dir=`pwd`
CI_PACKAGE=true yarn build CI_PACKAGE=true yarn build
cd .. cd ..
# run end to end tests # prepare end to end tests
pushd test/end-to-end-tests pushd test/end-to-end-tests
ln -s $element_web_dir element/element-web ln -s $element_web_dir element/element-web
# PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true ./install.sh # PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true ./install.sh
@ -28,9 +24,4 @@ echo "--- Install synapse & other dependencies"
./install.sh ./install.sh
# install static webserver to server symlinked local copy of element # install static webserver to server symlinked local copy of element
./element/install-webserver.sh ./element/install-webserver.sh
rm -r logs || true
mkdir logs
echo "+++ Running end-to-end tests"
TESTS_STARTED=1
./run.sh --no-sandbox --log-directory logs/
popd popd

View file

@ -0,0 +1,19 @@
#!/bin/bash
set -ev
handle_error() {
EXIT_CODE=$?
exit $EXIT_CODE
}
trap 'handle_error' ERR
# run end to end tests
pushd test/end-to-end-tests
rm -r logs || true
mkdir logs
echo "--- Running end-to-end tests"
TESTS_STARTED=1
./run.sh --no-sandbox --log-directory logs/
popd

View file

@ -22,29 +22,51 @@ clone() {
} }
# Try the PR author's branch in case it exists on the deps as well. # Try the PR author's branch in case it exists on the deps as well.
# First we check if BUILDKITE_BRANCH is defined, # First we check if GITHUB_HEAD_REF is defined,
# if it isn't we can assume this is a Netlify build # Then we check if BUILDKITE_BRANCH is defined,
if [ -z ${BUILDKITE_BRANCH+x} ]; then # if they aren't we can assume this is a Netlify build
if [ -n "$GITHUB_HEAD_REF" ]; then
head=$GITHUB_HEAD_REF
elif [ -n "$BUILDKITE_BRANCH" ]; then
head=$BUILDKITE_BRANCH
else
# Netlify doesn't give us info about the fork so we have to get it from GitHub API # Netlify doesn't give us info about the fork so we have to get it from GitHub API
apiEndpoint="https://api.github.com/repos/matrix-org/matrix-react-sdk/pulls/" apiEndpoint="https://api.github.com/repos/matrix-org/matrix-react-sdk/pulls/"
apiEndpoint+=$REVIEW_ID apiEndpoint+=$REVIEW_ID
head=$(curl $apiEndpoint | jq -r '.head.label') head=$(curl $apiEndpoint | jq -r '.head.label')
else
head=$BUILDKITE_BRANCH
fi fi
# If head is set, it will contain either: # If head is set, it will contain on Buildkite either:
# * "branch" when the author's branch and target branch are in the same repo # * "branch" when the author's branch and target branch are in the same repo
# * "fork:branch" when the author's branch is in their fork or if this is a Netlify build # * "fork:branch" when the author's branch is in their fork or if this is a Netlify build
# We can split on `:` into an array to check. # We can split on `:` into an array to check.
# For GitHub Actions we need to inspect GITHUB_REPOSITORY and GITHUB_ACTOR
# to determine whether the branch is from a fork or not
BRANCH_ARRAY=(${head//:/ }) BRANCH_ARRAY=(${head//:/ })
if [[ "${#BRANCH_ARRAY[@]}" == "1" ]]; then if [[ "${#BRANCH_ARRAY[@]}" == "1" ]]; then
if [ -n "$GITHUB_HEAD_REF" ]; then
if [[ "$GITHUB_REPOSITORY" == "$deforg"* ]]; then
clone $deforg $defrepo $GITHUB_HEAD_REF
else
REPO_ARRAY=(${GITHUB_REPOSITORY//\// })
clone $REPO_ARRAY[0] $defrepo $GITHUB_HEAD_REF
fi
else
clone $deforg $defrepo $BUILDKITE_BRANCH clone $deforg $defrepo $BUILDKITE_BRANCH
fi
elif [[ "${#BRANCH_ARRAY[@]}" == "2" ]]; then elif [[ "${#BRANCH_ARRAY[@]}" == "2" ]]; then
clone ${BRANCH_ARRAY[0]} $defrepo ${BRANCH_ARRAY[1]} clone ${BRANCH_ARRAY[0]} $defrepo ${BRANCH_ARRAY[1]}
fi fi
# Try the target branch of the push or PR. # Try the target branch of the push or PR.
if [ -n $GITHUB_BASE_REF ]; then
clone $deforg $defrepo $GITHUB_BASE_REF
elif [ -n $BUILDKITE_PULL_REQUEST_BASE_BRANCH ]; then
clone $deforg $defrepo $BUILDKITE_PULL_REQUEST_BASE_BRANCH clone $deforg $defrepo $BUILDKITE_PULL_REQUEST_BASE_BRANCH
fi
# Try HEAD which is the branch name in Netlify (not BRANCH which is pull/xxxx/head for PR builds) # Try HEAD which is the branch name in Netlify (not BRANCH which is pull/xxxx/head for PR builds)
clone $deforg $defrepo $HEAD clone $deforg $defrepo $HEAD
# Use the default branch as the last resort. # Use the default branch as the last resort.

38
src/@types/diff-dom.ts Normal file
View file

@ -0,0 +1,38 @@
/*
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.
*/
declare module "diff-dom" {
export interface IDiff {
action: string;
name: string;
text?: string;
route: number[];
value: string;
element: unknown;
oldValue: string;
newValue: string;
}
interface IOpts {
}
export class DiffDOM {
public constructor(opts?: IOpts);
public apply(tree: unknown, diffs: IDiff[]): unknown;
public undo(tree: unknown, diffs: IDiff[]): unknown;
public diff(a: HTMLElement | string, b: HTMLElement | string): IDiff[];
}
}

View file

@ -16,6 +16,7 @@ limitations under the License.
import "matrix-js-sdk/src/@types/global"; // load matrix-js-sdk's type extensions first import "matrix-js-sdk/src/@types/global"; // load matrix-js-sdk's type extensions first
import * as ModernizrStatic from "modernizr"; import * as ModernizrStatic from "modernizr";
import ContentMessages from "../ContentMessages"; import ContentMessages from "../ContentMessages";
import { IMatrixClientPeg } from "../MatrixClientPeg"; import { IMatrixClientPeg } from "../MatrixClientPeg";
import ToastStore from "../stores/ToastStore"; import ToastStore from "../stores/ToastStore";
@ -113,19 +114,6 @@ declare global {
usageDetails?: {[key: string]: number}; usageDetails?: {[key: string]: number};
} }
export interface ISettledFulfilled<T> {
status: "fulfilled";
value: T;
}
export interface ISettledRejected {
status: "rejected";
reason: any;
}
interface PromiseConstructor {
allSettled<T>(promises: Promise<T>[]): Promise<Array<ISettledFulfilled<T> | ISettledRejected>>;
}
interface HTMLAudioElement { interface HTMLAudioElement {
type?: string; type?: string;
// sinkId & setSinkId are experimental and typescript doesn't know about them // sinkId & setSinkId are experimental and typescript doesn't know about them
@ -140,11 +128,24 @@ declare global {
setSinkId(outputId: string); setSinkId(outputId: string);
} }
// Add Chrome-specific `instant` ScrollBehaviour
type _ScrollBehavior = ScrollBehavior | "instant";
interface _ScrollOptions {
behavior?: _ScrollBehavior;
}
interface _ScrollIntoViewOptions extends _ScrollOptions {
block?: ScrollLogicalPosition;
inline?: ScrollLogicalPosition;
}
interface Element { interface Element {
// Safari & IE11 only have this prefixed: we used prefixed versions // Safari & IE11 only have this prefixed: we used prefixed versions
// previously so let's continue to support them for now // previously so let's continue to support them for now
webkitRequestFullScreen(options?: FullscreenOptions): Promise<void>; webkitRequestFullScreen(options?: FullscreenOptions): Promise<void>;
msRequestFullscreen(options?: FullscreenOptions): Promise<void>; msRequestFullscreen(options?: FullscreenOptions): Promise<void>;
scrollIntoView(arg?: boolean | _ScrollIntoViewOptions): void;
} }
interface Error { interface Error {

View file

@ -17,13 +17,12 @@ limitations under the License.
import { RoomMember } from "matrix-js-sdk/src/models/room-member"; import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { User } from "matrix-js-sdk/src/models/user"; import { User } from "matrix-js-sdk/src/models/user";
import { Room } from "matrix-js-sdk/src/models/room"; import { Room } from "matrix-js-sdk/src/models/room";
import { ResizeMethod } from "matrix-js-sdk/src/@types/partials";
import DMRoomMap from './utils/DMRoomMap'; import DMRoomMap from './utils/DMRoomMap';
import { mediaFromMxc } from "./customisations/Media"; import { mediaFromMxc } from "./customisations/Media";
import SettingsStore from "./settings/SettingsStore"; import SettingsStore from "./settings/SettingsStore";
export type ResizeMethod = "crop" | "scale";
// Not to be used for BaseAvatar urls as that has similar default avatar fallback already // Not to be used for BaseAvatar urls as that has similar default avatar fallback already
export function avatarUrlForMember( export function avatarUrlForMember(
member: RoomMember, member: RoomMember,

View file

@ -1,85 +0,0 @@
/*
Copyright 2017 Michael Telatynski <7t3chguy@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.
*/
import SettingsStore from "./settings/SettingsStore";
import {SettingLevel} from "./settings/SettingLevel";
import {setMatrixCallAudioInput, setMatrixCallVideoInput} from "matrix-js-sdk/src/matrix";
export default {
hasAnyLabeledDevices: async function() {
const devices = await navigator.mediaDevices.enumerateDevices();
return devices.some(d => !!d.label);
},
getDevices: function() {
// Only needed for Electron atm, though should work in modern browsers
// once permission has been granted to the webapp
return navigator.mediaDevices.enumerateDevices().then(function(devices) {
const audiooutput = [];
const audioinput = [];
const videoinput = [];
devices.forEach((device) => {
switch (device.kind) {
case 'audiooutput': audiooutput.push(device); break;
case 'audioinput': audioinput.push(device); break;
case 'videoinput': videoinput.push(device); break;
}
});
// console.log("Loaded WebRTC Devices", mediaDevices);
return {
audiooutput,
audioinput,
videoinput,
};
}, (error) => { console.log('Unable to refresh WebRTC Devices: ', error); });
},
loadDevices: function() {
const audioDeviceId = SettingsStore.getValue("webrtc_audioinput");
const videoDeviceId = SettingsStore.getValue("webrtc_videoinput");
setMatrixCallAudioInput(audioDeviceId);
setMatrixCallVideoInput(videoDeviceId);
},
setAudioOutput: function(deviceId) {
SettingsStore.setValue("webrtc_audiooutput", null, SettingLevel.DEVICE, deviceId);
},
setAudioInput: function(deviceId) {
SettingsStore.setValue("webrtc_audioinput", null, SettingLevel.DEVICE, deviceId);
setMatrixCallAudioInput(deviceId);
},
setVideoInput: function(deviceId) {
SettingsStore.setValue("webrtc_videoinput", null, SettingLevel.DEVICE, deviceId);
setMatrixCallVideoInput(deviceId);
},
getAudioOutput: function() {
return SettingsStore.getValueAt(SettingLevel.DEVICE, "webrtc_audiooutput");
},
getAudioInput: function() {
return SettingsStore.getValueAt(SettingLevel.DEVICE, "webrtc_audioinput");
},
getVideoInput: function() {
return SettingsStore.getValueAt(SettingLevel.DEVICE, "webrtc_videoinput");
},
};

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2018 New Vector Ltd Copyright 2018 - 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -14,34 +14,40 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { MatrixError } from "matrix-js-sdk/src/http-api";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
export class DecryptionFailure { export class DecryptionFailure {
constructor(failedEventId, errorCode) { public readonly ts: number;
this.failedEventId = failedEventId;
this.errorCode = errorCode; constructor(public readonly failedEventId: string, public readonly errorCode: string) {
this.ts = Date.now(); this.ts = Date.now();
} }
} }
type TrackingFn = (count: number, trackedErrCode: string) => void;
type ErrCodeMapFn = (errcode: string) => string;
export class DecryptionFailureTracker { export class DecryptionFailureTracker {
// Array of items of type DecryptionFailure. Every `CHECK_INTERVAL_MS`, this list // Array of items of type DecryptionFailure. Every `CHECK_INTERVAL_MS`, this list
// is checked for failures that happened > `GRACE_PERIOD_MS` ago. Those that did // is checked for failures that happened > `GRACE_PERIOD_MS` ago. Those that did
// are accumulated in `failureCounts`. // are accumulated in `failureCounts`.
failures = []; public failures: DecryptionFailure[] = [];
// A histogram of the number of failures that will be tracked at the next tracking // A histogram of the number of failures that will be tracked at the next tracking
// interval, split by failure error code. // interval, split by failure error code.
failureCounts = { public failureCounts: Record<string, number> = {
// [errorCode]: 42 // [errorCode]: 42
}; };
// Event IDs of failures that were tracked previously // Event IDs of failures that were tracked previously
trackedEventHashMap = { public trackedEventHashMap: Record<string, boolean> = {
// [eventId]: true // [eventId]: true
}; };
// Set to an interval ID when `start` is called // Set to an interval ID when `start` is called
checkInterval = null; public checkInterval: NodeJS.Timeout = null;
trackInterval = null; public trackInterval: NodeJS.Timeout = null;
// Spread the load on `Analytics` by tracking at a low frequency, `TRACK_INTERVAL_MS`. // Spread the load on `Analytics` by tracking at a low frequency, `TRACK_INTERVAL_MS`.
static TRACK_INTERVAL_MS = 60000; static TRACK_INTERVAL_MS = 60000;
@ -67,7 +73,7 @@ export class DecryptionFailureTracker {
* @param {function?} errorCodeMapFn The function used to map error codes to the * @param {function?} errorCodeMapFn The function used to map error codes to the
* trackedErrorCode. If not provided, the `.code` of errors will be used. * trackedErrorCode. If not provided, the `.code` of errors will be used.
*/ */
constructor(fn, errorCodeMapFn) { constructor(private readonly fn: TrackingFn, private readonly errorCodeMapFn?: ErrCodeMapFn) {
if (!fn || typeof fn !== 'function') { if (!fn || typeof fn !== 'function') {
throw new Error('DecryptionFailureTracker requires tracking function'); throw new Error('DecryptionFailureTracker requires tracking function');
} }
@ -75,9 +81,6 @@ export class DecryptionFailureTracker {
if (errorCodeMapFn && typeof errorCodeMapFn !== 'function') { if (errorCodeMapFn && typeof errorCodeMapFn !== 'function') {
throw new Error('DecryptionFailureTracker second constructor argument should be a function'); throw new Error('DecryptionFailureTracker second constructor argument should be a function');
} }
this._trackDecryptionFailure = fn;
this._mapErrorCode = errorCodeMapFn;
} }
// loadTrackedEventHashMap() { // loadTrackedEventHashMap() {
@ -88,7 +91,7 @@ export class DecryptionFailureTracker {
// localStorage.setItem('mx-decryption-failure-event-id-hashes', JSON.stringify(this.trackedEventHashMap)); // localStorage.setItem('mx-decryption-failure-event-id-hashes', JSON.stringify(this.trackedEventHashMap));
// } // }
eventDecrypted(e, err) { public eventDecrypted(e: MatrixEvent, err: MatrixError | Error): void {
if (err) { if (err) {
this.addDecryptionFailure(new DecryptionFailure(e.getId(), err.code)); this.addDecryptionFailure(new DecryptionFailure(e.getId(), err.code));
} else { } else {
@ -97,18 +100,18 @@ export class DecryptionFailureTracker {
} }
} }
addDecryptionFailure(failure) { public addDecryptionFailure(failure: DecryptionFailure): void {
this.failures.push(failure); this.failures.push(failure);
} }
removeDecryptionFailuresForEvent(e) { public removeDecryptionFailuresForEvent(e: MatrixEvent): void {
this.failures = this.failures.filter((f) => f.failedEventId !== e.getId()); this.failures = this.failures.filter((f) => f.failedEventId !== e.getId());
} }
/** /**
* Start checking for and tracking failures. * Start checking for and tracking failures.
*/ */
start() { public start(): void {
this.checkInterval = setInterval( this.checkInterval = setInterval(
() => this.checkFailures(Date.now()), () => this.checkFailures(Date.now()),
DecryptionFailureTracker.CHECK_INTERVAL_MS, DecryptionFailureTracker.CHECK_INTERVAL_MS,
@ -123,7 +126,7 @@ export class DecryptionFailureTracker {
/** /**
* Clear state and stop checking for and tracking failures. * Clear state and stop checking for and tracking failures.
*/ */
stop() { public stop(): void {
clearInterval(this.checkInterval); clearInterval(this.checkInterval);
clearInterval(this.trackInterval); clearInterval(this.trackInterval);
@ -132,11 +135,11 @@ export class DecryptionFailureTracker {
} }
/** /**
* Mark failures that occured before nowTs - GRACE_PERIOD_MS as failures that should be * Mark failures that occurred before nowTs - GRACE_PERIOD_MS as failures that should be
* tracked. Only mark one failure per event ID. * tracked. Only mark one failure per event ID.
* @param {number} nowTs the timestamp that represents the time now. * @param {number} nowTs the timestamp that represents the time now.
*/ */
checkFailures(nowTs) { public checkFailures(nowTs: number): void {
const failuresGivenGrace = []; const failuresGivenGrace = [];
const failuresNotReady = []; const failuresNotReady = [];
while (this.failures.length > 0) { while (this.failures.length > 0) {
@ -175,10 +178,10 @@ export class DecryptionFailureTracker {
const dedupedFailures = dedupedFailuresMap.values(); const dedupedFailures = dedupedFailuresMap.values();
this._aggregateFailures(dedupedFailures); this.aggregateFailures(dedupedFailures);
} }
_aggregateFailures(failures) { private aggregateFailures(failures: DecryptionFailure[]): void {
for (const failure of failures) { for (const failure of failures) {
const errorCode = failure.errorCode; const errorCode = failure.errorCode;
this.failureCounts[errorCode] = (this.failureCounts[errorCode] || 0) + 1; this.failureCounts[errorCode] = (this.failureCounts[errorCode] || 0) + 1;
@ -189,12 +192,12 @@ export class DecryptionFailureTracker {
* If there are failures that should be tracked, call the given trackDecryptionFailure * If there are failures that should be tracked, call the given trackDecryptionFailure
* function with the number of failures that should be tracked. * function with the number of failures that should be tracked.
*/ */
trackFailures() { public trackFailures(): void {
for (const errorCode of Object.keys(this.failureCounts)) { for (const errorCode of Object.keys(this.failureCounts)) {
if (this.failureCounts[errorCode] > 0) { if (this.failureCounts[errorCode] > 0) {
const trackedErrorCode = this._mapErrorCode ? this._mapErrorCode(errorCode) : errorCode; const trackedErrorCode = this.errorCodeMapFn ? this.errorCodeMapFn(errorCode) : errorCode;
this._trackDecryptionFailure(this.failureCounts[errorCode], trackedErrorCode); this.fn(this.failureCounts[errorCode], trackedErrorCode);
this.failureCounts[errorCode] = 0; this.failureCounts[errorCode] = 0;
} }
} }

View file

@ -17,11 +17,10 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React from 'react'; import React, { ReactNode } from 'react';
import sanitizeHtml from 'sanitize-html'; import sanitizeHtml from 'sanitize-html';
import { IExtendedSanitizeOptions } from './@types/sanitize-html'; import cheerio from 'cheerio';
import * as linkify from 'linkifyjs'; import * as linkify from 'linkifyjs';
import linkifyMatrix from './linkify-matrix';
import _linkifyElement from 'linkifyjs/element'; import _linkifyElement from 'linkifyjs/element';
import _linkifyString from 'linkifyjs/string'; import _linkifyString from 'linkifyjs/string';
import classNames from 'classnames'; import classNames from 'classnames';
@ -29,9 +28,11 @@ import EMOJIBASE_REGEX from 'emojibase-regex';
import url from 'url'; import url from 'url';
import katex from 'katex'; import katex from 'katex';
import { AllHtmlEntities } from 'html-entities'; import { AllHtmlEntities } from 'html-entities';
import SettingsStore from './settings/SettingsStore'; import { IContent } from 'matrix-js-sdk/src/models/event';
import cheerio from 'cheerio';
import { IExtendedSanitizeOptions } from './@types/sanitize-html';
import linkifyMatrix from './linkify-matrix';
import SettingsStore from './settings/SettingsStore';
import { tryTransformPermalinkToLocalHref } from "./utils/permalinks/Permalinks"; import { tryTransformPermalinkToLocalHref } from "./utils/permalinks/Permalinks";
import { SHORTCODE_TO_EMOJI, getEmojiFromUnicode } from "./emoji"; import { SHORTCODE_TO_EMOJI, getEmojiFromUnicode } from "./emoji";
import ReplyThread from "./components/views/elements/ReplyThread"; import ReplyThread from "./components/views/elements/ReplyThread";
@ -66,7 +67,7 @@ export const PERMITTED_URL_SCHEMES = ['http', 'https', 'ftp', 'mailto', 'magnet'
* need emojification. * need emojification.
* unicodeToImage uses this function. * unicodeToImage uses this function.
*/ */
function mightContainEmoji(str: string) { function mightContainEmoji(str: string): boolean {
return SURROGATE_PAIR_PATTERN.test(str) || SYMBOL_PATTERN.test(str); return SURROGATE_PAIR_PATTERN.test(str) || SYMBOL_PATTERN.test(str);
} }
@ -76,7 +77,7 @@ function mightContainEmoji(str: string) {
* @param {String} char The emoji character * @param {String} char The emoji character
* @return {String} The shortcode (such as :thumbup:) * @return {String} The shortcode (such as :thumbup:)
*/ */
export function unicodeToShortcode(char: string) { export function unicodeToShortcode(char: string): string {
const data = getEmojiFromUnicode(char); const data = getEmojiFromUnicode(char);
return (data && data.shortcodes ? `:${data.shortcodes[0]}:` : ''); return (data && data.shortcodes ? `:${data.shortcodes[0]}:` : '');
} }
@ -87,7 +88,7 @@ export function unicodeToShortcode(char: string) {
* @param {String} shortcode The shortcode (such as :thumbup:) * @param {String} shortcode The shortcode (such as :thumbup:)
* @return {String} The emoji character; null if none exists * @return {String} The emoji character; null if none exists
*/ */
export function shortcodeToUnicode(shortcode: string) { export function shortcodeToUnicode(shortcode: string): string {
shortcode = shortcode.slice(1, shortcode.length - 1); shortcode = shortcode.slice(1, shortcode.length - 1);
const data = SHORTCODE_TO_EMOJI.get(shortcode); const data = SHORTCODE_TO_EMOJI.get(shortcode);
return data ? data.unicode : null; return data ? data.unicode : null;
@ -124,13 +125,13 @@ export function processHtmlForSending(html: string): string {
* Given an untrusted HTML string, return a React node with an sanitized version * Given an untrusted HTML string, return a React node with an sanitized version
* of that HTML. * of that HTML.
*/ */
export function sanitizedHtmlNode(insaneHtml: string) { export function sanitizedHtmlNode(insaneHtml: string): ReactNode {
const saneHtml = sanitizeHtml(insaneHtml, sanitizeHtmlParams); const saneHtml = sanitizeHtml(insaneHtml, sanitizeHtmlParams);
return <div dangerouslySetInnerHTML={{ __html: saneHtml }} dir="auto" />; return <div dangerouslySetInnerHTML={{ __html: saneHtml }} dir="auto" />;
} }
export function getHtmlText(insaneHtml: string) { export function getHtmlText(insaneHtml: string): string {
return sanitizeHtml(insaneHtml, { return sanitizeHtml(insaneHtml, {
allowedTags: [], allowedTags: [],
allowedAttributes: {}, allowedAttributes: {},
@ -148,7 +149,7 @@ export function getHtmlText(insaneHtml: string) {
* other places we need to sanitise URLs. * other places we need to sanitise URLs.
* @return true if permitted, otherwise false * @return true if permitted, otherwise false
*/ */
export function isUrlPermitted(inputUrl: string) { export function isUrlPermitted(inputUrl: string): boolean {
try { try {
const parsed = url.parse(inputUrl); const parsed = url.parse(inputUrl);
if (!parsed.protocol) return false; if (!parsed.protocol) return false;
@ -351,13 +352,6 @@ class HtmlHighlighter extends BaseHighlighter<string> {
} }
} }
interface IContent {
format?: string;
// eslint-disable-next-line camelcase
formatted_body?: string;
body: string;
}
interface IOpts { interface IOpts {
highlightLink?: string; highlightLink?: string;
disableBigEmoji?: boolean; disableBigEmoji?: boolean;
@ -367,6 +361,14 @@ interface IOpts {
ref?: React.Ref<any>; ref?: React.Ref<any>;
} }
export interface IOptsReturnNode extends IOpts {
returnString: false;
}
export interface IOptsReturnString extends IOpts {
returnString: true;
}
/* turn a matrix event body into html /* turn a matrix event body into html
* *
* content: 'content' of the MatrixEvent * content: 'content' of the MatrixEvent
@ -380,6 +382,8 @@ interface IOpts {
* opts.forComposerQuote: optional param to lessen the url rewriting done by sanitization, for quoting into composer * opts.forComposerQuote: optional param to lessen the url rewriting done by sanitization, for quoting into composer
* opts.ref: React ref to attach to any React components returned (not compatible with opts.returnString) * opts.ref: React ref to attach to any React components returned (not compatible with opts.returnString)
*/ */
export function bodyToHtml(content: IContent, highlights: string[], opts: IOptsReturnString): string;
export function bodyToHtml(content: IContent, highlights: string[], opts: IOptsReturnNode): ReactNode;
export function bodyToHtml(content: IContent, highlights: string[], opts: IOpts = {}) { export function bodyToHtml(content: IContent, highlights: string[], opts: IOpts = {}) {
const isHtmlMessage = content.format === "org.matrix.custom.html" && content.formatted_body; const isHtmlMessage = content.format === "org.matrix.custom.html" && content.formatted_body;
let bodyHasEmoji = false; let bodyHasEmoji = false;
@ -501,7 +505,7 @@ export function bodyToHtml(content: IContent, highlights: string[], opts: IOpts
* @param {object} [options] Options for linkifyString. Default: linkifyMatrix.options * @param {object} [options] Options for linkifyString. Default: linkifyMatrix.options
* @returns {string} Linkified string * @returns {string} Linkified string
*/ */
export function linkifyString(str: string, options = linkifyMatrix.options) { export function linkifyString(str: string, options = linkifyMatrix.options): string {
return _linkifyString(str, options); return _linkifyString(str, options);
} }
@ -512,7 +516,7 @@ export function linkifyString(str: string, options = linkifyMatrix.options) {
* @param {object} [options] Options for linkifyElement. Default: linkifyMatrix.options * @param {object} [options] Options for linkifyElement. Default: linkifyMatrix.options
* @returns {object} * @returns {object}
*/ */
export function linkifyElement(element: HTMLElement, options = linkifyMatrix.options) { export function linkifyElement(element: HTMLElement, options = linkifyMatrix.options): HTMLElement {
return _linkifyElement(element, options); return _linkifyElement(element, options);
} }
@ -523,7 +527,7 @@ export function linkifyElement(element: HTMLElement, options = linkifyMatrix.opt
* @param {object} [options] Options for linkifyString. Default: linkifyMatrix.options * @param {object} [options] Options for linkifyString. Default: linkifyMatrix.options
* @returns {string} * @returns {string}
*/ */
export function linkifyAndSanitizeHtml(dirtyHtml: string, options = linkifyMatrix.options) { export function linkifyAndSanitizeHtml(dirtyHtml: string, options = linkifyMatrix.options): string {
return sanitizeHtml(linkifyString(dirtyHtml, options), sanitizeHtmlParams); return sanitizeHtml(linkifyString(dirtyHtml, options), sanitizeHtmlParams);
} }
@ -534,7 +538,7 @@ export function linkifyAndSanitizeHtml(dirtyHtml: string, options = linkifyMatri
* @param {Node} node * @param {Node} node
* @returns {bool} * @returns {bool}
*/ */
export function checkBlockNode(node: Node) { export function checkBlockNode(node: Node): boolean {
switch (node.nodeName) { switch (node.nodeName) {
case "H1": case "H1":
case "H2": case "H2":

120
src/MediaDeviceHandler.ts Normal file
View file

@ -0,0 +1,120 @@
/*
Copyright 2017 Michael Telatynski <7t3chguy@gmail.com>
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.
*/
import SettingsStore from "./settings/SettingsStore";
import { SettingLevel } from "./settings/SettingLevel";
import { setMatrixCallAudioInput, setMatrixCallVideoInput } from "matrix-js-sdk/src/matrix";
import EventEmitter from 'events';
interface IMediaDevices {
audioOutput: Array<MediaDeviceInfo>;
audioInput: Array<MediaDeviceInfo>;
videoInput: Array<MediaDeviceInfo>;
}
export enum MediaDeviceHandlerEvent {
AudioOutputChanged = "audio_output_changed",
}
export default class MediaDeviceHandler extends EventEmitter {
private static internalInstance;
public static get instance(): MediaDeviceHandler {
if (!MediaDeviceHandler.internalInstance) {
MediaDeviceHandler.internalInstance = new MediaDeviceHandler();
}
return MediaDeviceHandler.internalInstance;
}
public static async hasAnyLabeledDevices(): Promise<boolean> {
const devices = await navigator.mediaDevices.enumerateDevices();
return devices.some(d => Boolean(d.label));
}
public static async getDevices(): Promise<IMediaDevices> {
// Only needed for Electron atm, though should work in modern browsers
// once permission has been granted to the webapp
try {
const devices = await navigator.mediaDevices.enumerateDevices();
const audioOutput = [];
const audioInput = [];
const videoInput = [];
devices.forEach((device) => {
switch (device.kind) {
case 'audiooutput': audioOutput.push(device); break;
case 'audioinput': audioInput.push(device); break;
case 'videoinput': videoInput.push(device); break;
}
});
return { audioOutput, audioInput, videoInput };
} catch (error) {
console.warn('Unable to refresh WebRTC Devices: ', error);
}
}
/**
* Retrieves devices from the SettingsStore and tells the js-sdk to use them
*/
public static loadDevices(): void {
const audioDeviceId = SettingsStore.getValue("webrtc_audioinput");
const videoDeviceId = SettingsStore.getValue("webrtc_videoinput");
setMatrixCallAudioInput(audioDeviceId);
setMatrixCallVideoInput(videoDeviceId);
}
public setAudioOutput(deviceId: string): void {
SettingsStore.setValue("webrtc_audiooutput", null, SettingLevel.DEVICE, deviceId);
this.emit(MediaDeviceHandlerEvent.AudioOutputChanged, deviceId);
}
/**
* This will not change the device that a potential call uses. The call will
* need to be ended and started again for this change to take effect
* @param {string} deviceId
*/
public setAudioInput(deviceId: string): void {
SettingsStore.setValue("webrtc_audioinput", null, SettingLevel.DEVICE, deviceId);
setMatrixCallAudioInput(deviceId);
}
/**
* This will not change the device that a potential call uses. The call will
* need to be ended and started again for this change to take effect
* @param {string} deviceId
*/
public setVideoInput(deviceId: string): void {
SettingsStore.setValue("webrtc_videoinput", null, SettingLevel.DEVICE, deviceId);
setMatrixCallVideoInput(deviceId);
}
public static getAudioOutput(): string {
return SettingsStore.getValueAt(SettingLevel.DEVICE, "webrtc_audiooutput");
}
public static getAudioInput(): string {
return SettingsStore.getValueAt(SettingLevel.DEVICE, "webrtc_audioinput");
}
public static getVideoInput(): string {
return SettingsStore.getValueAt(SettingLevel.DEVICE, "webrtc_videoinput");
}
}

View file

@ -385,7 +385,7 @@ export class ModalManager {
</div> </div>
); );
ReactDOM.render(dialog, ModalManager.getOrCreateContainer()); setImmediate(() => ReactDOM.render(dialog, ModalManager.getOrCreateContainer()));
} else { } else {
// This is safe to call repeatedly if we happen to do that // This is safe to call repeatedly if we happen to do that
ReactDOM.unmountComponentAtNode(ModalManager.getOrCreateContainer()); ReactDOM.unmountComponentAtNode(ModalManager.getOrCreateContainer());

View file

@ -1,7 +1,5 @@
/* /*
Copyright 2016 OpenMarket Ltd Copyright 2016 - 2021 The Matrix.org Foundation C.I.C.
Copyright 2017, 2018 New Vector Ltd
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -16,15 +14,26 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React from 'react'; import React from "react";
import { Room } from "matrix-js-sdk/src/models/room";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { User } from "matrix-js-sdk/src/models/user";
import { MatrixClientPeg } from './MatrixClientPeg'; import { MatrixClientPeg } from './MatrixClientPeg';
import MultiInviter from './utils/MultiInviter'; import MultiInviter, { CompletionStates } from './utils/MultiInviter';
import Modal from './Modal'; import Modal from './Modal';
import * as sdk from './'; import * as sdk from './';
import { _t } from './languageHandler'; import { _t } from './languageHandler';
import InviteDialog, {KIND_DM, KIND_INVITE} from "./components/views/dialogs/InviteDialog"; import InviteDialog, { KIND_DM, KIND_INVITE, Member } from "./components/views/dialogs/InviteDialog";
import CommunityPrototypeInviteDialog from "./components/views/dialogs/CommunityPrototypeInviteDialog"; import CommunityPrototypeInviteDialog from "./components/views/dialogs/CommunityPrototypeInviteDialog";
import { CommunityPrototypeStore } from "./stores/CommunityPrototypeStore"; import { CommunityPrototypeStore } from "./stores/CommunityPrototypeStore";
import BaseAvatar from "./components/views/avatars/BaseAvatar";
import { mediaFromMxc } from "./customisations/Media";
export interface IInviteResult {
states: CompletionStates;
inviter: MultiInviter;
}
/** /**
* Invites multiple addresses to a room * Invites multiple addresses to a room
@ -32,15 +41,15 @@ import {CommunityPrototypeStore} from "./stores/CommunityPrototypeStore";
* no option to cancel. * no option to cancel.
* *
* @param {string} roomId The ID of the room to invite to * @param {string} roomId The ID of the room to invite to
* @param {string[]} addrs Array of strings of addresses to invite. May be matrix IDs or 3pids. * @param {string[]} addresses Array of strings of addresses to invite. May be matrix IDs or 3pids.
* @returns {Promise} Promise * @returns {Promise} Promise
*/ */
export function inviteMultipleToRoom(roomId, addrs) { export function inviteMultipleToRoom(roomId: string, addresses: string[]): Promise<IInviteResult> {
const inviter = new MultiInviter(roomId); const inviter = new MultiInviter(roomId);
return inviter.invite(addrs).then(states => Promise.resolve({states, inviter})); return inviter.invite(addresses).then(states => Promise.resolve({ states, inviter }));
} }
export function showStartChatInviteDialog(initialText) { export function showStartChatInviteDialog(initialText = ""): void {
// This dialog handles the room creation internally - we don't need to worry about it. // This dialog handles the room creation internally - we don't need to worry about it.
const InviteDialog = sdk.getComponent("dialogs.InviteDialog"); const InviteDialog = sdk.getComponent("dialogs.InviteDialog");
Modal.createTrackedDialog( Modal.createTrackedDialog(
@ -49,7 +58,7 @@ export function showStartChatInviteDialog(initialText) {
); );
} }
export function showRoomInviteDialog(roomId, initialText = "") { export function showRoomInviteDialog(roomId: string, initialText = ""): void {
// This dialog handles the room creation internally - we don't need to worry about it. // This dialog handles the room creation internally - we don't need to worry about it.
Modal.createTrackedDialog( Modal.createTrackedDialog(
"Invite Users", "", InviteDialog, { "Invite Users", "", InviteDialog, {
@ -61,14 +70,14 @@ export function showRoomInviteDialog(roomId, initialText = "") {
); );
} }
export function showCommunityRoomInviteDialog(roomId, communityName) { export function showCommunityRoomInviteDialog(roomId: string, communityName: string): void {
Modal.createTrackedDialog( Modal.createTrackedDialog(
'Invite Users to Community', '', CommunityPrototypeInviteDialog, {communityName, roomId}, 'Invite Users to Community', '', CommunityPrototypeInviteDialog, {communityName, roomId},
/*className=*/null, /*isPriority=*/false, /*isStatic=*/true, /*className=*/null, /*isPriority=*/false, /*isStatic=*/true,
); );
} }
export function showCommunityInviteDialog(communityId) { export function showCommunityInviteDialog(communityId: string): void {
const chat = CommunityPrototypeStore.instance.getGeneralChat(communityId); const chat = CommunityPrototypeStore.instance.getGeneralChat(communityId);
if (chat) { if (chat) {
const name = CommunityPrototypeStore.instance.getCommunityName(communityId); const name = CommunityPrototypeStore.instance.getCommunityName(communityId);
@ -83,7 +92,7 @@ export function showCommunityInviteDialog(communityId) {
* @param {MatrixEvent} event The event to check * @param {MatrixEvent} event The event to check
* @returns {boolean} True if valid, false otherwise * @returns {boolean} True if valid, false otherwise
*/ */
export function isValid3pidInvite(event) { export function isValid3pidInvite(event: MatrixEvent): boolean {
if (!event || event.getType() !== "m.room.third_party_invite") return false; if (!event || event.getType() !== "m.room.third_party_invite") return false;
// any events without these keys are not valid 3pid invites, so we ignore them // any events without these keys are not valid 3pid invites, so we ignore them
@ -96,7 +105,7 @@ export function isValid3pidInvite(event) {
return true; return true;
} }
export function inviteUsersToRoom(roomId, userIds) { export function inviteUsersToRoom(roomId: string, userIds: string[]): Promise<void> {
return inviteMultipleToRoom(roomId, userIds).then((result) => { return inviteMultipleToRoom(roomId, userIds).then((result) => {
const room = MatrixClientPeg.get().getRoom(roomId); const room = MatrixClientPeg.get().getRoom(roomId);
showAnyInviteErrors(result.states, room, result.inviter); showAnyInviteErrors(result.states, room, result.inviter);
@ -110,9 +119,14 @@ export function inviteUsersToRoom(roomId, userIds) {
}); });
} }
export function showAnyInviteErrors(addrs, room, inviter) { export function showAnyInviteErrors(
states: CompletionStates,
room: Room,
inviter: MultiInviter,
userMap?: Map<string, Member>,
): boolean {
// Show user any errors // Show user any errors
const failedUsers = Object.keys(addrs).filter(a => addrs[a] === 'error'); const failedUsers = Object.keys(states).filter(a => states[a] === 'error');
if (failedUsers.length === 1 && inviter.fatal) { if (failedUsers.length === 1 && inviter.fatal) {
// Just get the first message because there was a fatal problem on the first // Just get the first message because there was a fatal problem on the first
// user. This usually means that no other users were attempted, making it // user. This usually means that no other users were attempted, making it
@ -126,19 +140,47 @@ export function showAnyInviteErrors(addrs, room, inviter) {
} else { } else {
const errorList = []; const errorList = [];
for (const addr of failedUsers) { for (const addr of failedUsers) {
if (addrs[addr] === "error") { if (states[addr] === "error") {
const reason = inviter.getErrorText(addr); const reason = inviter.getErrorText(addr);
errorList.push(addr + ": " + reason); errorList.push(addr + ": " + reason);
} }
} }
const cli = MatrixClientPeg.get();
if (errorList.length > 0) { if (errorList.length > 0) {
// React 16 doesn't let us use `errorList.join(<br />)` anymore, so this is our solution // React 16 doesn't let us use `errorList.join(<br />)` anymore, so this is our solution
const description = <div>{errorList.map(e => <div key={e}>{e}</div>)}</div>; const description = <div className="mx_InviteDialog_multiInviterError">
<h4>{ _t("We sent the others, but the below people couldn't be invited to <RoomName/>", {}, {
RoomName: () => <b>{ room.name }</b>,
}) }</h4>
<div>
{ failedUsers.map(addr => {
const user = userMap?.get(addr) || cli.getUser(addr);
const name = (user as Member).name || (user as User).rawDisplayName;
const avatarUrl = (user as Member).getMxcAvatarUrl?.() || (user as User).avatarUrl;
return <div key={addr} className="mx_InviteDialog_multiInviterError_entry">
<div className="mx_InviteDialog_multiInviterError_entry_userProfile">
<BaseAvatar
url={avatarUrl ? mediaFromMxc(avatarUrl).getSquareThumbnailHttp(24) : null}
name={name}
idName={user.userId}
width={24}
height={24}
/>
<span className="mx_InviteDialog_multiInviterError_entry_name">{ name }</span>
<span className="mx_InviteDialog_multiInviterError_entry_userId">{ user.userId }</span>
</div>
<div className="mx_InviteDialog_multiInviterError_entry_error">
{ inviter.getErrorText(addr) }
</div>
</div>;
}) }
</div>
</div>;
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Failed to invite the following users to the room', '', ErrorDialog, { Modal.createTrackedDialog("Some invites could not be sent", "", ErrorDialog, {
title: _t("Failed to invite the following users to the %(roomName)s room:", {roomName: room.name}), title: _t("Some invites couldn't be sent"),
description, description,
}); });
return false; return false;

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2015, 2016 OpenMarket Ltd 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.
@ -14,6 +14,8 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { Room } from "matrix-js-sdk/src/models/room";
import { MatrixClientPeg } from './MatrixClientPeg'; import { MatrixClientPeg } from './MatrixClientPeg';
/** /**
@ -25,11 +27,11 @@ import {MatrixClientPeg} from './MatrixClientPeg';
* @param {Object} room The room object * @param {Object} room The room object
* @returns {string} A display alias for the given room * @returns {string} A display alias for the given room
*/ */
export function getDisplayAliasForRoom(room) { export function getDisplayAliasForRoom(room: Room): string {
return room.getCanonicalAlias() || room.getAltAliases()[0]; return room.getCanonicalAlias() || room.getAltAliases()[0];
} }
export function looksLikeDirectMessageRoom(room, myUserId) { export function looksLikeDirectMessageRoom(room: Room, myUserId: string): boolean {
const myMembership = room.getMyMembership(); const myMembership = room.getMyMembership();
const me = room.getMember(myUserId); const me = room.getMember(myUserId);
@ -48,7 +50,7 @@ export function looksLikeDirectMessageRoom(room, myUserId) {
return false; return false;
} }
export function guessAndSetDMRoom(room, isDirect) { export function guessAndSetDMRoom(room: Room, isDirect: boolean): Promise<void> {
let newTarget; let newTarget;
if (isDirect) { if (isDirect) {
const guessedUserId = guessDMRoomTargetId( const guessedUserId = guessDMRoomTargetId(
@ -70,7 +72,7 @@ export function guessAndSetDMRoom(room, isDirect) {
this room as a DM room this room as a DM room
* @returns {object} A promise * @returns {object} A promise
*/ */
export function setDMRoom(roomId, userId) { export function setDMRoom(roomId: string, userId: string): Promise<void> {
if (MatrixClientPeg.get().isGuest()) { if (MatrixClientPeg.get().isGuest()) {
return Promise.resolve(); return Promise.resolve();
} }
@ -114,7 +116,7 @@ export function setDMRoom(roomId, userId) {
* @param {string} myUserId User ID of the current user * @param {string} myUserId User ID of the current user
* @returns {string} User ID of the user that the room is probably a DM with * @returns {string} User ID of the user that the room is probably a DM with
*/ */
function guessDMRoomTargetId(room, myUserId) { function guessDMRoomTargetId(room: Room, myUserId: string): string {
let oldestTs; let oldestTs;
let oldestUser; let oldestUser;

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { ICryptoCallbacks, IDeviceTrustLevel, ISecretStorageKeyInfo } from 'matrix-js-sdk/src/matrix'; import { ICryptoCallbacks, ISecretStorageKeyInfo } from 'matrix-js-sdk/src/matrix';
import { MatrixClient } from 'matrix-js-sdk/src/client'; import { MatrixClient } from 'matrix-js-sdk/src/client';
import Modal from './Modal'; import Modal from './Modal';
import * as sdk from './index'; import * as sdk from './index';
@ -28,6 +28,7 @@ import AccessSecretStorageDialog from './components/views/dialogs/security/Acces
import RestoreKeyBackupDialog from './components/views/dialogs/security/RestoreKeyBackupDialog'; import RestoreKeyBackupDialog from './components/views/dialogs/security/RestoreKeyBackupDialog';
import SettingsStore from "./settings/SettingsStore"; import SettingsStore from "./settings/SettingsStore";
import SecurityCustomisations from "./customisations/Security"; import SecurityCustomisations from "./customisations/Security";
import { DeviceTrustLevel } from 'matrix-js-sdk/src/crypto/CrossSigning';
// This stores the secret storage private keys in memory for the JS SDK. This is // This stores the secret storage private keys in memory for the JS SDK. This is
// only meant to act as a cache to avoid prompting the user multiple times // only meant to act as a cache to avoid prompting the user multiple times
@ -244,7 +245,7 @@ async function onSecretRequested(
deviceId: string, deviceId: string,
requestId: string, requestId: string,
name: string, name: string,
deviceTrust: IDeviceTrustLevel, deviceTrust: DeviceTrustLevel,
): Promise<string> { ): Promise<string> {
console.log("onSecretRequested", userId, deviceId, requestId, name, deviceTrust); console.log("onSecretRequested", userId, deviceId, requestId, name, deviceTrust);
const client = MatrixClientPeg.get(); const client = MatrixClientPeg.get();

View file

@ -17,8 +17,8 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import * as React from 'react'; import * as React from 'react';
import { User } from "matrix-js-sdk/src/models/user";
import * as ContentHelpers from 'matrix-js-sdk/src/content-helpers'; import * as ContentHelpers from 'matrix-js-sdk/src/content-helpers';
import {MatrixClientPeg} from './MatrixClientPeg'; import {MatrixClientPeg} from './MatrixClientPeg';
@ -1019,9 +1019,8 @@ export const Commands = [
const member = MatrixClientPeg.get().getRoom(roomId).getMember(userId); const member = MatrixClientPeg.get().getRoom(roomId).getMember(userId);
dis.dispatch<ViewUserPayload>({ dis.dispatch<ViewUserPayload>({
action: Action.ViewUser, action: Action.ViewUser,
// XXX: We should be using a real member object and not assuming what the // XXX: We should be using a real member object and not assuming what the receiver wants.
// receiver wants. member: member || { userId } as User,
member: member || {userId},
}); });
return success(); return success();
}, },

View file

@ -33,76 +33,89 @@ function textForMemberEvent(ev: MatrixEvent, showHiddenEvents?: boolean): () =>
const targetName = ev.target ? ev.target.name : ev.getStateKey(); const targetName = ev.target ? ev.target.name : ev.getStateKey();
const prevContent = ev.getPrevContent(); const prevContent = ev.getPrevContent();
const content = ev.getContent(); const content = ev.getContent();
const reason = content.reason;
const getReason = () => content.reason ? (_t('Reason') + ': ' + content.reason) : '';
switch (content.membership) { switch (content.membership) {
case 'invite': { case 'invite': {
const threePidContent = content.third_party_invite; const threePidContent = content.third_party_invite;
if (threePidContent) { if (threePidContent) {
if (threePidContent.display_name) { if (threePidContent.display_name) {
return () => _t('%(targetName)s accepted the invitation for %(displayName)s.', { return () => _t('%(targetName)s accepted the invitation for %(displayName)s', {
targetName, targetName,
displayName: threePidContent.display_name, displayName: threePidContent.display_name,
}); });
} else { } else {
return () => _t('%(targetName)s accepted an invitation.', {targetName}); return () => _t('%(targetName)s accepted an invitation', { targetName });
} }
} else { } else {
return () => _t('%(senderName)s invited %(targetName)s.', {senderName, targetName}); return () => _t('%(senderName)s invited %(targetName)s', { senderName, targetName });
} }
} }
case 'ban': case 'ban':
return () => _t('%(senderName)s banned %(targetName)s.', {senderName, targetName}) + ' ' + getReason(); return () => reason
? _t('%(senderName)s banned %(targetName)s: %(reason)s', { senderName, targetName, reason })
: _t('%(senderName)s banned %(targetName)s', { senderName, targetName });
case 'join': case 'join':
if (prevContent && prevContent.membership === 'join') { if (prevContent && prevContent.membership === 'join') {
if (prevContent.displayname && content.displayname && prevContent.displayname !== content.displayname) { if (prevContent.displayname && content.displayname && prevContent.displayname !== content.displayname) {
return () => _t('%(oldDisplayName)s changed their display name to %(displayName)s.', { return () => _t('%(oldDisplayName)s changed their display name to %(displayName)s', {
oldDisplayName: prevContent.displayname, oldDisplayName: prevContent.displayname,
displayName: content.displayname, displayName: content.displayname,
}); });
} else if (!prevContent.displayname && content.displayname) { } else if (!prevContent.displayname && content.displayname) {
return () => _t('%(senderName)s set their display name to %(displayName)s.', { return () => _t('%(senderName)s set their display name to %(displayName)s', {
senderName: ev.getSender(), senderName: ev.getSender(),
displayName: content.displayname, displayName: content.displayname,
}); });
} else if (prevContent.displayname && !content.displayname) { } else if (prevContent.displayname && !content.displayname) {
return () => _t('%(senderName)s removed their display name (%(oldDisplayName)s).', { return () => _t('%(senderName)s removed their display name (%(oldDisplayName)s)', {
senderName, senderName,
oldDisplayName: prevContent.displayname, oldDisplayName: prevContent.displayname,
}); });
} else if (prevContent.avatar_url && !content.avatar_url) { } else if (prevContent.avatar_url && !content.avatar_url) {
return () => _t('%(senderName)s removed their profile picture.', {senderName}); return () => _t('%(senderName)s removed their profile picture', { senderName });
} else if (prevContent.avatar_url && content.avatar_url && } else if (prevContent.avatar_url && content.avatar_url &&
prevContent.avatar_url !== content.avatar_url) { prevContent.avatar_url !== content.avatar_url) {
return () => _t('%(senderName)s changed their profile picture.', {senderName}); return () => _t('%(senderName)s changed their profile picture', { senderName });
} else if (!prevContent.avatar_url && content.avatar_url) { } else if (!prevContent.avatar_url && content.avatar_url) {
return () => _t('%(senderName)s set a profile picture.', {senderName}); return () => _t('%(senderName)s set a profile picture', { senderName });
} else if (showHiddenEvents ?? SettingsStore.getValue("showHiddenEventsInTimeline")) { } else if (showHiddenEvents ?? SettingsStore.getValue("showHiddenEventsInTimeline")) {
// This is a null rejoin, it will only be visible if the Labs option is enabled // This is a null rejoin, it will only be visible if using 'show hidden events' (labs)
return () => _t("%(senderName)s made no change.", {senderName}); return () => _t("%(senderName)s made no change", { senderName });
} else { } else {
return null; return null;
} }
} else { } else {
if (!ev.target) console.warn("Join message has no target! -- " + ev.getContent().state_key); if (!ev.target) console.warn("Join message has no target! -- " + ev.getContent().state_key);
return () => _t('%(targetName)s joined the room.', {targetName}); return () => _t('%(targetName)s joined the room', { targetName });
} }
case 'leave': case 'leave':
if (ev.getSender() === ev.getStateKey()) { if (ev.getSender() === ev.getStateKey()) {
if (prevContent.membership === "invite") { if (prevContent.membership === "invite") {
return () => _t('%(targetName)s rejected the invitation.', {targetName}); return () => _t('%(targetName)s rejected the invitation', { targetName });
} else { } else {
return () => _t('%(targetName)s left the room.', {targetName}); return () => reason
? _t('%(targetName)s left the room: %(reason)s', { targetName, reason })
: _t('%(targetName)s left the room', { targetName });
} }
} else if (prevContent.membership === "ban") { } else if (prevContent.membership === "ban") {
return () => _t('%(senderName)s unbanned %(targetName)s.', {senderName, targetName}); return () => _t('%(senderName)s unbanned %(targetName)s', { senderName, targetName });
} else if (prevContent.membership === "invite") { } else if (prevContent.membership === "invite") {
return () => _t('%(senderName)s withdrew %(targetName)s\'s invitation.', { return () => reason
? _t('%(senderName)s withdrew %(targetName)s\'s invitation: %(reason)s', {
senderName, senderName,
targetName, targetName,
}) + ' ' + getReason(); reason,
})
: _t('%(senderName)s withdrew %(targetName)s\'s invitation', { senderName, targetName })
} else if (prevContent.membership === "join") { } else if (prevContent.membership === "join") {
return () => _t('%(senderName)s kicked %(targetName)s.', {senderName, targetName}) + ' ' + getReason(); return () => reason
? _t('%(senderName)s kicked %(targetName)s: %(reason)s', {
senderName,
targetName,
reason,
})
: _t('%(senderName)s kicked %(targetName)s', { senderName, targetName });
} else { } else {
return null; return null;
} }

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2015, 2016 OpenMarket Ltd 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.
@ -14,6 +14,10 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { Room } from "matrix-js-sdk/src/models/room";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { EventType, MsgType } from "matrix-js-sdk/src/@types/event";
import { MatrixClientPeg } from "./MatrixClientPeg"; import { MatrixClientPeg } from "./MatrixClientPeg";
import shouldHideEvent from './shouldHideEvent'; import shouldHideEvent from './shouldHideEvent';
import { haveTileForEvent } from "./components/views/rooms/EventTile"; import { haveTileForEvent } from "./components/views/rooms/EventTile";
@ -25,28 +29,33 @@ import {haveTileForEvent} from "./components/views/rooms/EventTile";
* @param {Object} ev The event * @param {Object} ev The event
* @returns {boolean} True if the given event should affect the unread message count * @returns {boolean} True if the given event should affect the unread message count
*/ */
export function eventTriggersUnreadCount(ev) { export function eventTriggersUnreadCount(ev: MatrixEvent): boolean {
if (ev.sender && ev.sender.userId == MatrixClientPeg.get().credentials.userId) { if (ev.sender && ev.sender.userId == MatrixClientPeg.get().credentials.userId) {
return false; return false;
} else if (ev.getType() == 'm.room.member') { }
switch (ev.getType()) {
case EventType.RoomMember:
case EventType.RoomThirdPartyInvite:
case EventType.CallAnswer:
case EventType.CallHangup:
case EventType.RoomAliases:
case EventType.RoomCanonicalAlias:
case EventType.RoomServerAcl:
return false; return false;
} else if (ev.getType() == 'm.room.third_party_invite') {
return false; case EventType.RoomMessage:
} else if (ev.getType() == 'm.call.answer' || ev.getType() == 'm.call.hangup') { if (ev.getContent().msgtype === MsgType.Notice) {
return false;
} else if (ev.getType() == 'm.room.message' && ev.getContent().msgtype == 'm.notify') {
return false;
} else if (ev.getType() == 'm.room.aliases' || ev.getType() == 'm.room.canonical_alias') {
return false;
} else if (ev.getType() == 'm.room.server_acl') {
return false;
} else if (ev.isRedacted()) {
return false; return false;
} }
break;
}
if (ev.isRedacted()) return false;
return haveTileForEvent(ev); return haveTileForEvent(ev);
} }
export function doesRoomHaveUnreadMessages(room) { export function doesRoomHaveUnreadMessages(room: Room): boolean {
const myUserId = MatrixClientPeg.get().getUserId(); const myUserId = MatrixClientPeg.get().getUserId();
// get the most recent read receipt sent by our account. // get the most recent read receipt sent by our account.

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2017 New Vector Ltd Copyright 2017 - 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -14,15 +14,19 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
const emailRegex = /^\S+@\S+\.\S+$/; import PropTypes from "prop-types";
const emailRegex = /^\S+@\S+\.\S+$/;
const mxUserIdRegex = /^@\S+:\S+$/; const mxUserIdRegex = /^@\S+:\S+$/;
const mxRoomIdRegex = /^!\S+:\S+$/; const mxRoomIdRegex = /^!\S+:\S+$/;
import PropTypes from 'prop-types'; export const addressTypes = ['mx-user-id', 'mx-room-id', 'email'];
export const addressTypes = [
'mx-user-id', 'mx-room-id', 'email', export enum AddressType {
]; Email = "email",
MatrixUserId = "mx-user-id",
MatrixRoomId = "mx-room-id",
}
// PropType definition for an object describing // PropType definition for an object describing
// an address that can be invited to a room (which // an address that can be invited to a room (which
@ -40,18 +44,13 @@ export const UserAddressType = PropTypes.shape({
isKnown: PropTypes.bool, isKnown: PropTypes.bool,
}); });
export function getAddressType(inputText) { export function getAddressType(inputText: string): AddressType | null {
const isEmailAddress = emailRegex.test(inputText); if (emailRegex.test(inputText)) {
const isUserId = mxUserIdRegex.test(inputText); return AddressType.Email;
const isRoomId = mxRoomIdRegex.test(inputText); } else if (mxUserIdRegex.test(inputText)) {
return AddressType.MatrixUserId;
// sanity check the input for user IDs } else if (mxRoomIdRegex.test(inputText)) {
if (isEmailAddress) { return AddressType.MatrixRoomId;
return 'email';
} else if (isUserId) {
return 'mx-user-id';
} else if (isRoomId) {
return 'mx-room-id';
} else { } else {
return null; return null;
} }

View file

@ -57,6 +57,8 @@ export enum Modifiers {
// Meta-modifier: isMac ? CMD : CONTROL // Meta-modifier: isMac ? CMD : CONTROL
export const CMD_OR_CTRL = isMac ? Modifiers.COMMAND : Modifiers.CONTROL; export const CMD_OR_CTRL = isMac ? Modifiers.COMMAND : Modifiers.CONTROL;
// Meta-key representing the digits [0-9] often found at the top of standard keyboard layouts
export const DIGITS = "digits";
interface IKeybind { interface IKeybind {
modifiers?: Modifiers[]; modifiers?: Modifiers[];
@ -319,6 +321,7 @@ const alternateKeyName: Record<string, string> = {
[Key.SPACE]: _td("Space"), [Key.SPACE]: _td("Space"),
[Key.HOME]: _td("Home"), [Key.HOME]: _td("Home"),
[Key.END]: _td("End"), [Key.END]: _td("End"),
[DIGITS]: _td("[number]"),
}; };
const keyIcon: Record<string, string> = { const keyIcon: Record<string, string> = {
[Key.ARROW_UP]: "↑", [Key.ARROW_UP]: "↑",

View file

@ -16,7 +16,8 @@ limitations under the License.
*/ */
import { ReactElement } from 'react'; import { ReactElement } from 'react';
import Room from 'matrix-js-sdk/src/models/room'; import { Room } from 'matrix-js-sdk/src/models/room';
import CommandProvider from './CommandProvider'; import CommandProvider from './CommandProvider';
import CommunityProvider from './CommunityProvider'; import CommunityProvider from './CommunityProvider';
import DuckDuckGoProvider from './DuckDuckGoProvider'; import DuckDuckGoProvider from './DuckDuckGoProvider';
@ -54,13 +55,14 @@ const PROVIDERS = [
EmojiProvider, EmojiProvider,
NotifProvider, NotifProvider,
CommandProvider, CommandProvider,
CommunityProvider,
DuckDuckGoProvider, DuckDuckGoProvider,
]; ];
// as the spaces feature is device configurable only, and toggling it refreshes the page, we can do this here // as the spaces feature is device configurable only, and toggling it refreshes the page, we can do this here
if (SettingsStore.getValue("feature_spaces")) { if (SettingsStore.getValue("feature_spaces")) {
PROVIDERS.push(SpaceProvider); PROVIDERS.push(SpaceProvider);
} else {
PROVIDERS.push(CommunityProvider);
} }
// Providers will get rejected if they take longer than this. // Providers will get rejected if they take longer than this.

View file

@ -15,7 +15,8 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import Room from "matrix-js-sdk/src/models/room"; import { Room } from "matrix-js-sdk/src/models/room";
import AutocompleteProvider from './AutocompleteProvider'; import AutocompleteProvider from './AutocompleteProvider';
import { _t } from '../languageHandler'; import { _t } from '../languageHandler';
import {MatrixClientPeg} from '../MatrixClientPeg'; import {MatrixClientPeg} from '../MatrixClientPeg';

View file

@ -18,7 +18,7 @@ limitations under the License.
import React from "react"; import React from "react";
import { uniqBy, sortBy } from "lodash"; import { uniqBy, sortBy } from "lodash";
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 AutocompleteProvider from './AutocompleteProvider'; import AutocompleteProvider from './AutocompleteProvider';
@ -32,13 +32,9 @@ import SettingsStore from "../settings/SettingsStore";
const ROOM_REGEX = /\B#\S*/g; const ROOM_REGEX = /\B#\S*/g;
function score(query: string, space: string) { // Prefer canonical aliases over non-canonical ones
const index = space.indexOf(query); function canonicalScore(displayedAlias: string, room: Room): number {
if (index === -1) { return displayedAlias === room.getCanonicalAlias() ? 0 : 1;
return Infinity;
} else {
return index;
}
} }
function matcherObject(room: Room, displayedAlias: string, matchName = "") { function matcherObject(room: Room, displayedAlias: string, matchName = "") {
@ -106,7 +102,7 @@ export default class RoomProvider extends AutocompleteProvider {
const matchedString = command[0]; const matchedString = command[0];
completions = this.matcher.match(matchedString, limit); completions = this.matcher.match(matchedString, limit);
completions = sortBy(completions, [ completions = sortBy(completions, [
(c) => score(matchedString, c.displayedAlias), (c) => canonicalScore(c.displayedAlias, c.room),
(c) => c.displayedAlias.length, (c) => c.displayedAlias.length,
]); ]);
completions = uniqBy(completions, (match) => match.room); completions = uniqBy(completions, (match) => match.room);

View file

@ -15,12 +15,12 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React from "react"; import React, { HTMLAttributes, WheelEvent } from "react";
interface IProps { interface IProps extends Omit<HTMLAttributes<HTMLDivElement>, "onScroll"> {
className?: string; className?: string;
onScroll?: () => void; onScroll?: (event: Event) => void;
onWheel?: () => void; onWheel?: (event: WheelEvent) => void;
style?: React.CSSProperties style?: React.CSSProperties
tabIndex?: number, tabIndex?: number,
wrappedRef?: (ref: HTMLDivElement) => void; wrappedRef?: (ref: HTMLDivElement) => void;
@ -52,14 +52,18 @@ export default class AutoHideScrollbar extends React.Component<IProps> {
} }
public render() { public render() {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { className, onScroll, onWheel, style, tabIndex, wrappedRef, children, ...otherProps } = this.props;
return (<div return (<div
{...otherProps}
ref={this.containerRef} ref={this.containerRef}
style={this.props.style} style={style}
className={["mx_AutoHideScrollbar", this.props.className].join(" ")} className={["mx_AutoHideScrollbar", className].join(" ")}
onWheel={this.props.onWheel} onWheel={onWheel}
tabIndex={this.props.tabIndex} tabIndex={tabIndex}
> >
{ this.props.children } { children }
</div>); </div>);
} }
} }

View file

@ -24,7 +24,6 @@ import * as sdk from '../../index';
import dis from '../../dispatcher/dispatcher'; import dis from '../../dispatcher/dispatcher';
import { _t } from '../../languageHandler'; import { _t } from '../../languageHandler';
import { Droppable } from 'react-beautiful-dnd';
import classNames from 'classnames'; import classNames from 'classnames';
import MatrixClientContext from "../../contexts/MatrixClientContext"; import MatrixClientContext from "../../contexts/MatrixClientContext";
import AutoHideScrollbar from "./AutoHideScrollbar"; import AutoHideScrollbar from "./AutoHideScrollbar";
@ -83,7 +82,7 @@ class GroupFilterPanel extends React.Component {
} }
}; };
onMouseDown = e => { onClick = e => {
// only dispatch if its not a no-op // only dispatch if its not a no-op
if (this.state.selectedTags.length > 0) { if (this.state.selectedTags.length > 0) {
dis.dispatch({action: 'deselect_tags'}); dis.dispatch({action: 'deselect_tags'});
@ -151,28 +150,15 @@ class GroupFilterPanel extends React.Component {
return <div className={classes} onClick={this.onClearFilterClick}> return <div className={classes} onClick={this.onClearFilterClick}>
<AutoHideScrollbar <AutoHideScrollbar
className="mx_GroupFilterPanel_scroller" className="mx_GroupFilterPanel_scroller"
// XXX: Use onMouseDown as a workaround for https://github.com/atlassian/react-beautiful-dnd/issues/273 onClick={this.onClick}
// instead of onClick. Otherwise we experience https://github.com/vector-im/element-web/issues/6253
onMouseDown={this.onMouseDown}
>
<Droppable
droppableId="tag-panel-droppable"
type="draggable-TagTile"
>
{ (provided, snapshot) => (
<div
className="mx_GroupFilterPanel_tagTileContainer"
ref={provided.innerRef}
> >
<div className="mx_GroupFilterPanel_tagTileContainer">
{ this.renderGlobalIcon() } { this.renderGlobalIcon() }
{ tags } { tags }
<div> <div>
{ createButton } { createButton }
</div> </div>
{ provided.placeholder }
</div> </div>
) }
</Droppable>
</AutoHideScrollbar> </AutoHideScrollbar>
</div>; </div>;
} }

View file

@ -185,21 +185,24 @@ export default class IndicatorScrollbar extends React.Component {
}; };
render() { render() {
// eslint-disable-next-line no-unused-vars
const { children, trackHorizontalOverflow, verticalScrollsHorizontally, ...otherProps } = this.props;
const leftIndicatorStyle = {left: this.state.leftIndicatorOffset}; const leftIndicatorStyle = {left: this.state.leftIndicatorOffset};
const rightIndicatorStyle = {right: this.state.rightIndicatorOffset}; const rightIndicatorStyle = {right: this.state.rightIndicatorOffset};
const leftOverflowIndicator = this.props.trackHorizontalOverflow const leftOverflowIndicator = trackHorizontalOverflow
? <div className="mx_IndicatorScrollbar_leftOverflowIndicator" style={leftIndicatorStyle} /> : null; ? <div className="mx_IndicatorScrollbar_leftOverflowIndicator" style={leftIndicatorStyle} /> : null;
const rightOverflowIndicator = this.props.trackHorizontalOverflow const rightOverflowIndicator = trackHorizontalOverflow
? <div className="mx_IndicatorScrollbar_rightOverflowIndicator" style={rightIndicatorStyle} /> : null; ? <div className="mx_IndicatorScrollbar_rightOverflowIndicator" style={rightIndicatorStyle} /> : null;
return (<AutoHideScrollbar return (<AutoHideScrollbar
ref={this._collectScrollerComponent} ref={this._collectScrollerComponent}
wrappedRef={this._collectScroller} wrappedRef={this._collectScroller}
onWheel={this.onMouseWheel} onWheel={this.onMouseWheel}
{...this.props} {...otherProps}
> >
{ leftOverflowIndicator } { leftOverflowIndicator }
{ this.props.children } { children }
{ rightOverflowIndicator } { rightOverflowIndicator }
</AutoHideScrollbar>); </AutoHideScrollbar>);
} }

View file

@ -19,19 +19,16 @@ limitations under the License.
import * as React from 'react'; import * as React from 'react';
import * as PropTypes from 'prop-types'; import * as PropTypes from 'prop-types';
import { MatrixClient } from 'matrix-js-sdk/src/client'; import { MatrixClient } from 'matrix-js-sdk/src/client';
import { DragDropContext } from 'react-beautiful-dnd';
import {Key} from '../../Keyboard'; import {Key} from '../../Keyboard';
import PageTypes from '../../PageTypes'; import PageTypes from '../../PageTypes';
import CallMediaHandler from '../../CallMediaHandler'; import MediaDeviceHandler from '../../MediaDeviceHandler';
import { fixupColorFonts } from '../../utils/FontManager'; import { fixupColorFonts } from '../../utils/FontManager';
import * as sdk from '../../index'; import * as sdk from '../../index';
import dis from '../../dispatcher/dispatcher'; import dis from '../../dispatcher/dispatcher';
import { IMatrixClientCreds } from '../../MatrixClientPeg'; import { IMatrixClientCreds } from '../../MatrixClientPeg';
import SettingsStore from "../../settings/SettingsStore"; import SettingsStore from "../../settings/SettingsStore";
import TagOrderActions from '../../actions/TagOrderActions';
import RoomListActions from '../../actions/RoomListActions';
import ResizeHandle from '../views/elements/ResizeHandle'; import ResizeHandle from '../views/elements/ResizeHandle';
import {Resizer, CollapseDistributor} from '../../resizer'; import {Resizer, CollapseDistributor} from '../../resizer';
import MatrixClientContext from "../../contexts/MatrixClientContext"; import MatrixClientContext from "../../contexts/MatrixClientContext";
@ -170,7 +167,7 @@ class LoggedInView extends React.Component<IProps, IState> {
// stash the MatrixClient in case we log out before we are unmounted // stash the MatrixClient in case we log out before we are unmounted
this._matrixClient = this.props.matrixClient; this._matrixClient = this.props.matrixClient;
CallMediaHandler.loadDevices(); MediaDeviceHandler.loadDevices();
fixupColorFonts(); fixupColorFonts();
@ -569,50 +566,6 @@ class LoggedInView extends React.Component<IProps, IState> {
} }
}; };
_onDragEnd = (result) => {
// Dragged to an invalid destination, not onto a droppable
if (!result.destination) {
return;
}
const dest = result.destination.droppableId;
if (dest === 'tag-panel-droppable') {
// Could be "GroupTile +groupId:domain"
const draggableId = result.draggableId.split(' ').pop();
// Dispatch synchronously so that the GroupFilterPanel receives an
// optimistic update from GroupFilterOrderStore before the previous
// state is shown.
dis.dispatch(TagOrderActions.moveTag(
this._matrixClient,
draggableId,
result.destination.index,
), true);
} else if (dest.startsWith('room-sub-list-droppable_')) {
this._onRoomTileEndDrag(result);
}
};
_onRoomTileEndDrag = (result) => {
let newTag = result.destination.droppableId.split('_')[1];
let prevTag = result.source.droppableId.split('_')[1];
if (newTag === 'undefined') newTag = undefined;
if (prevTag === 'undefined') prevTag = undefined;
const roomId = result.draggableId.split('_')[1];
const oldIndex = result.source.index;
const newIndex = result.destination.index;
dis.dispatch(RoomListActions.tagRoom(
this._matrixClient,
this._matrixClient.getRoom(roomId),
prevTag, newTag,
oldIndex, newIndex,
), true);
};
render() { render() {
const RoomView = sdk.getComponent('structures.RoomView'); const RoomView = sdk.getComponent('structures.RoomView');
const UserView = sdk.getComponent('structures.UserView'); const UserView = sdk.getComponent('structures.UserView');
@ -679,7 +632,6 @@ class LoggedInView extends React.Component<IProps, IState> {
aria-hidden={this.props.hideToSRUsers} aria-hidden={this.props.hideToSRUsers}
> >
<ToastContainer /> <ToastContainer />
<DragDropContext onDragEnd={this._onDragEnd}>
<div ref={this._resizeContainer} className={bodyClasses}> <div ref={this._resizeContainer} className={bodyClasses}>
{ SettingsStore.getValue("feature_spaces") ? <SpacePanel /> : null } { SettingsStore.getValue("feature_spaces") ? <SpacePanel /> : null }
<LeftPanel <LeftPanel
@ -689,7 +641,6 @@ class LoggedInView extends React.Component<IProps, IState> {
<ResizeHandle /> <ResizeHandle />
{ pageElement } { pageElement }
</div> </div>
</DragDropContext>
</div> </div>
<CallContainer /> <CallContainer />
<NonUrgentToastContainer /> <NonUrgentToastContainer />

View file

@ -48,7 +48,7 @@ import createRoom, {IOpts} from "../../createRoom";
import {_t, _td, getCurrentLanguage} from '../../languageHandler'; import {_t, _td, getCurrentLanguage} from '../../languageHandler';
import SettingsStore from "../../settings/SettingsStore"; import SettingsStore from "../../settings/SettingsStore";
import ThemeController from "../../settings/controllers/ThemeController"; import ThemeController from "../../settings/controllers/ThemeController";
import { startAnyRegistrationFlow } from "../../Registration.js"; import { startAnyRegistrationFlow } from "../../Registration";
import { messageForSyncError } from '../../utils/ErrorUtils'; import { messageForSyncError } from '../../utils/ErrorUtils';
import ResizeNotifier from "../../utils/ResizeNotifier"; import ResizeNotifier from "../../utils/ResizeNotifier";
import AutoDiscoveryUtils, { ValidatedServerConfig } from "../../utils/AutoDiscoveryUtils"; import AutoDiscoveryUtils, { ValidatedServerConfig } from "../../utils/AutoDiscoveryUtils";
@ -1461,7 +1461,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
}); });
const dft = new DecryptionFailureTracker((total, errorCode) => { const dft = new DecryptionFailureTracker((total, errorCode) => {
Analytics.trackEvent('E2E', 'Decryption failure', errorCode, total); Analytics.trackEvent('E2E', 'Decryption failure', errorCode, String(total));
CountlyAnalytics.instance.track("decryption_failure", { errorCode }, null, { sum: total }); CountlyAnalytics.instance.track("decryption_failure", { errorCode }, null, { sum: total });
}, (errorCode) => { }, (errorCode) => {
// Map JS-SDK error codes to tracker codes for aggregation // Map JS-SDK error codes to tracker codes for aggregation

View file

@ -82,8 +82,7 @@ export default class MyGroups extends React.Component {
</p> </p>
<p> <p>
{ _t( { _t(
"To set up a filter, drag a community avatar over to the filter panel on " + "You can click on an avatar in the " +
"the far left hand side of the screen. You can click on an avatar in the " +
"filter panel at any time to see only the rooms and people associated " + "filter panel at any time to see only the rooms and people associated " +
"with that community.", "with that community.",
) } ) }

View file

@ -22,6 +22,7 @@ import BaseCard from "../views/right_panel/BaseCard";
import { replaceableComponent } from "../../utils/replaceableComponent"; import { replaceableComponent } from "../../utils/replaceableComponent";
import TimelinePanel from "./TimelinePanel"; import TimelinePanel from "./TimelinePanel";
import Spinner from "../views/elements/Spinner"; import Spinner from "../views/elements/Spinner";
import { TileShape } from "../views/rooms/EventTile";
interface IProps { interface IProps {
onClose(): void; onClose(): void;
@ -48,7 +49,7 @@ export default class NotificationPanel extends React.PureComponent<IProps> {
manageReadMarkers={false} manageReadMarkers={false}
timelineSet={timelineSet} timelineSet={timelineSet}
showUrlPreview={false} showUrlPreview={false}
tileShape="notif" tileShape={TileShape.Notif}
empty={emptyState} empty={emptyState}
alwaysShowTimestamps={true} alwaysShowTimestamps={true}
/> />

View file

@ -207,9 +207,9 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
this.getMoreRooms(); this.getMoreRooms();
}; };
private getMoreRooms() { private getMoreRooms(): Promise<boolean> {
if (this.state.selectedCommunityId) return Promise.resolve(); // no more rooms if (this.state.selectedCommunityId) return Promise.resolve(false); // no more rooms
if (!MatrixClientPeg.get()) return Promise.resolve(); if (!MatrixClientPeg.get()) return Promise.resolve(false);
this.setState({ this.setState({
loading: true, loading: true,
@ -239,12 +239,12 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
// if the filter or server has changed since this request was sent, // if the filter or server has changed since this request was sent,
// throw away the result (don't even clear the busy flag // throw away the result (don't even clear the busy flag
// since we must still have a request in flight) // since we must still have a request in flight)
return; return false;
} }
if (this.unmounted) { if (this.unmounted) {
// if we've been unmounted, we don't care either. // if we've been unmounted, we don't care either.
return; return false;
} }
if (this.state.filterString) { if (this.state.filterString) {
@ -264,14 +264,13 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
filterString != this.state.filterString || filterString != this.state.filterString ||
roomServer != this.state.roomServer || roomServer != this.state.roomServer ||
nextBatch != this.nextBatch) { nextBatch != this.nextBatch) {
// as above: we don't care about errors for old // as above: we don't care about errors for old requests either
// requests either return false;
return;
} }
if (this.unmounted) { if (this.unmounted) {
// if we've been unmounted, we don't care either. // if we've been unmounted, we don't care either.
return; return false;
} }
console.error("Failed to get publicRooms: %s", JSON.stringify(err)); console.error("Failed to get publicRooms: %s", JSON.stringify(err));
@ -337,11 +336,10 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
} }
private onRoomClicked = (room: IRoom, ev: ButtonEvent) => { private onRoomClicked = (room: IRoom, ev: ButtonEvent) => {
// If room was shift-clicked, remove it from the room directory
if (ev.shiftKey && !this.state.selectedCommunityId) { if (ev.shiftKey && !this.state.selectedCommunityId) {
ev.preventDefault(); ev.preventDefault();
this.removeFromDirectory(room); this.removeFromDirectory(room);
} else {
this.showRoom(room);
} }
}; };
@ -568,11 +566,11 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
let avatarUrl = null; let avatarUrl = null;
if (room.avatar_url) avatarUrl = mediaFromMxc(room.avatar_url).getSquareThumbnailHttp(32); if (room.avatar_url) avatarUrl = mediaFromMxc(room.avatar_url).getSquareThumbnailHttp(32);
// We use onMouseDown instead of onClick, so that we can avoid text getting selected
return [ return [
<div key={ `${room.room_id}_avatar` } <div
onClick={(ev) => this.onRoomClicked(room, ev)} key={ `${room.room_id}_avatar` }
// cancel onMouseDown otherwise shift-clicking highlights text onMouseDown={(ev) => this.onRoomClicked(room, ev)}
onMouseDown={(ev) => {ev.preventDefault();}}
className="mx_RoomDirectory_roomAvatar" className="mx_RoomDirectory_roomAvatar"
> >
<BaseAvatar <BaseAvatar
@ -584,39 +582,47 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
url={avatarUrl} url={avatarUrl}
/> />
</div>, </div>,
<div key={ `${room.room_id}_description` } <div
onClick={(ev) => this.onRoomClicked(room, ev)} key={ `${room.room_id}_description` }
// cancel onMouseDown otherwise shift-clicking highlights text onMouseDown={(ev) => this.onRoomClicked(room, ev)}
onMouseDown={(ev) => {ev.preventDefault();}}
className="mx_RoomDirectory_roomDescription" className="mx_RoomDirectory_roomDescription"
> >
<div className="mx_RoomDirectory_name">{ name }</div>&nbsp; <div
<div className="mx_RoomDirectory_topic" className="mx_RoomDirectory_name"
onClick={ (ev) => { ev.stopPropagation(); } } onMouseDown={(ev) => this.onRoomClicked(room, ev)}
>
{ name }
</div>&nbsp;
<div
className="mx_RoomDirectory_topic"
onMouseDown={(ev) => this.onRoomClicked(room, ev)}
dangerouslySetInnerHTML={{ __html: topic }} dangerouslySetInnerHTML={{ __html: topic }}
/> />
<div className="mx_RoomDirectory_alias">{ getDisplayAliasForRoom(room) }</div> <div
className="mx_RoomDirectory_alias"
onMouseDown={(ev) => this.onRoomClicked(room, ev)}
>
{ getDisplayAliasForRoom(room) }
</div>
</div>, </div>,
<div key={ `${room.room_id}_memberCount` } <div
onClick={(ev) => this.onRoomClicked(room, ev)} key={ `${room.room_id}_memberCount` }
// cancel onMouseDown otherwise shift-clicking highlights text onMouseDown={(ev) => this.onRoomClicked(room, ev)}
onMouseDown={(ev) => {ev.preventDefault();}}
className="mx_RoomDirectory_roomMemberCount" className="mx_RoomDirectory_roomMemberCount"
> >
{ room.num_joined_members } { room.num_joined_members }
</div>, </div>,
<div key={ `${room.room_id}_preview` } <div
onClick={(ev) => this.onRoomClicked(room, ev)} key={ `${room.room_id}_preview` }
onMouseDown={(ev) => this.onRoomClicked(room, ev)}
// cancel onMouseDown otherwise shift-clicking highlights text // cancel onMouseDown otherwise shift-clicking highlights text
onMouseDown={(ev) => {ev.preventDefault();}}
className="mx_RoomDirectory_preview" className="mx_RoomDirectory_preview"
> >
{ previewButton } { previewButton }
</div>, </div>,
<div key={ `${room.room_id}_join` } <div
onClick={(ev) => this.onRoomClicked(room, ev)} key={ `${room.room_id}_join` }
// cancel onMouseDown otherwise shift-clicking highlights text onMouseDown={(ev) => this.onRoomClicked(room, ev)}
onMouseDown={(ev) => {ev.preventDefault();}}
className="mx_RoomDirectory_join" className="mx_RoomDirectory_join"
> >
{ joinOrViewButton } { joinOrViewButton }

View file

@ -23,7 +23,7 @@ limitations under the License.
import React, { createRef } from 'react'; import React, { createRef } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { Room } from "matrix-js-sdk/src/models/room"; import { IRecommendedVersion, NotificationCountType, Room } from "matrix-js-sdk/src/models/room";
import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { SearchResult } from "matrix-js-sdk/src/models/search-result"; import { SearchResult } from "matrix-js-sdk/src/models/search-result";
import { EventSubscription } from "fbemitter"; import { EventSubscription } from "fbemitter";
@ -60,7 +60,7 @@ import ScrollPanel from "./ScrollPanel";
import TimelinePanel from "./TimelinePanel"; import TimelinePanel from "./TimelinePanel";
import ErrorBoundary from "../views/elements/ErrorBoundary"; import ErrorBoundary from "../views/elements/ErrorBoundary";
import RoomPreviewBar from "../views/rooms/RoomPreviewBar"; import RoomPreviewBar from "../views/rooms/RoomPreviewBar";
import SearchBar from "../views/rooms/SearchBar"; import SearchBar, { SearchScope } from "../views/rooms/SearchBar";
import RoomUpgradeWarningBar from "../views/rooms/RoomUpgradeWarningBar"; import RoomUpgradeWarningBar from "../views/rooms/RoomUpgradeWarningBar";
import AuxPanel from "../views/rooms/AuxPanel"; import AuxPanel from "../views/rooms/AuxPanel";
import RoomHeader from "../views/rooms/RoomHeader"; import RoomHeader from "../views/rooms/RoomHeader";
@ -139,7 +139,7 @@ export interface IState {
draggingFile: boolean; draggingFile: boolean;
searching: boolean; searching: boolean;
searchTerm?: string; searchTerm?: string;
searchScope?: "All" | "Room"; searchScope?: SearchScope;
searchResults?: XOR<{}, { searchResults?: XOR<{}, {
count: number; count: number;
highlights: string[]; highlights: string[];
@ -172,11 +172,7 @@ export interface IState {
// We load this later by asking the js-sdk to suggest a version for us. // We load this later by asking the js-sdk to suggest a version for us.
// This object is the result of Room#getRecommendedVersion() // This object is the result of Room#getRecommendedVersion()
upgradeRecommendation?: { upgradeRecommendation?: IRecommendedVersion;
version: string;
needsUpgrade: boolean;
urgent: boolean;
};
canReact: boolean; canReact: boolean;
canReply: boolean; canReply: boolean;
layout: Layout; layout: Layout;
@ -1134,7 +1130,7 @@ export default class RoomView extends React.Component<IProps, IState> {
} }
} }
private onSearchResultsFillRequest = (backwards: boolean) => { private onSearchResultsFillRequest = (backwards: boolean): Promise<boolean> => {
if (!backwards) { if (!backwards) {
return Promise.resolve(false); return Promise.resolve(false);
} }
@ -1272,7 +1268,7 @@ export default class RoomView extends React.Component<IProps, IState> {
}); });
} }
private onSearch = (term: string, scope) => { private onSearch = (term: string, scope: SearchScope) => {
this.setState({ this.setState({
searchTerm: term, searchTerm: term,
searchScope: scope, searchScope: scope,
@ -1293,14 +1289,14 @@ export default class RoomView extends React.Component<IProps, IState> {
this.searchId = new Date().getTime(); this.searchId = new Date().getTime();
let roomId; let roomId;
if (scope === "Room") roomId = this.state.room.roomId; if (scope === SearchScope.Room) roomId = this.state.room.roomId;
debuglog("sending search request"); debuglog("sending search request");
const searchPromise = eventSearch(term, roomId); const searchPromise = eventSearch(term, roomId);
this.handleSearchResult(searchPromise); this.handleSearchResult(searchPromise);
}; };
private handleSearchResult(searchPromise: Promise<any>) { private handleSearchResult(searchPromise: Promise<any>): Promise<boolean> {
// keep a record of the current search id, so that if the search terms // keep a record of the current search id, so that if the search terms
// change before we get a response, we can ignore the results. // change before we get a response, we can ignore the results.
const localSearchId = this.searchId; const localSearchId = this.searchId;
@ -1313,7 +1309,7 @@ export default class RoomView extends React.Component<IProps, IState> {
debuglog("search complete"); debuglog("search complete");
if (this.unmounted || !this.state.searching || this.searchId != localSearchId) { if (this.unmounted || !this.state.searching || this.searchId != localSearchId) {
console.error("Discarding stale search results"); console.error("Discarding stale search results");
return; return false;
} }
// postgres on synapse returns us precise details of the strings // postgres on synapse returns us precise details of the strings
@ -1345,6 +1341,7 @@ export default class RoomView extends React.Component<IProps, IState> {
description: ((error && error.message) ? error.message : description: ((error && error.message) ? error.message :
_t("Server may be unavailable, overloaded, or search timed out :(")), _t("Server may be unavailable, overloaded, or search timed out :(")),
}); });
return false;
}).finally(() => { }).finally(() => {
this.setState({ this.setState({
searchInProgress: false, searchInProgress: false,
@ -2063,7 +2060,7 @@ export default class RoomView extends React.Component<IProps, IState> {
if (!this.state.atEndOfLiveTimeline && !this.state.searchResults) { if (!this.state.atEndOfLiveTimeline && !this.state.searchResults) {
const JumpToBottomButton = sdk.getComponent('rooms.JumpToBottomButton'); const JumpToBottomButton = sdk.getComponent('rooms.JumpToBottomButton');
jumpToBottom = (<JumpToBottomButton jumpToBottom = (<JumpToBottomButton
highlight={this.state.room.getUnreadNotificationCount('highlight') > 0} highlight={this.state.room.getUnreadNotificationCount(NotificationCountType.Highlight) > 0}
numUnreadMessages={this.state.numUnreadMessages} numUnreadMessages={this.state.numUnreadMessages}
onScrollToBottomClick={this.jumpToLiveTimeline} onScrollToBottomClick={this.jumpToLiveTimeline}
roomId={this.state.roomId} roomId={this.state.roomId}

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2015, 2016 OpenMarket Ltd 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.
@ -14,17 +14,18 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React, {createRef} from "react"; import React, { createRef, CSSProperties, ReactNode, SyntheticEvent, KeyboardEvent } from "react";
import PropTypes from 'prop-types';
import Timer from '../../utils/Timer'; import Timer from '../../utils/Timer';
import AutoHideScrollbar from "./AutoHideScrollbar"; import AutoHideScrollbar from "./AutoHideScrollbar";
import { replaceableComponent } from "../../utils/replaceableComponent"; import { replaceableComponent } from "../../utils/replaceableComponent";
import { getKeyBindingsManager, RoomAction } from "../../KeyBindingsManager"; import { getKeyBindingsManager, RoomAction } from "../../KeyBindingsManager";
import ResizeNotifier from "../../utils/ResizeNotifier";
const DEBUG_SCROLL = false; const DEBUG_SCROLL = false;
// The amount of extra scroll distance to allow prior to unfilling. // The amount of extra scroll distance to allow prior to unfilling.
// See _getExcessHeight. // See getExcessHeight.
const UNPAGINATION_PADDING = 6000; const UNPAGINATION_PADDING = 6000;
// The number of milliseconds to debounce calls to onUnfillRequest, to prevent // The number of milliseconds to debounce calls to onUnfillRequest, to prevent
// many scroll events causing many unfilling requests. // many scroll events causing many unfilling requests.
@ -43,6 +44,75 @@ if (DEBUG_SCROLL) {
debuglog = function() {}; debuglog = function() {};
} }
interface IProps {
/* stickyBottom: if set to true, then once the user hits the bottom of
* the list, any new children added to the list will cause the list to
* scroll down to show the new element, rather than preserving the
* existing view.
*/
stickyBottom?: boolean;
/* startAtBottom: if set to true, the view is assumed to start
* scrolled to the bottom.
* XXX: It's likely this is unnecessary and can be derived from
* stickyBottom, but I'm adding an extra parameter to ensure
* behaviour stays the same for other uses of ScrollPanel.
* If so, let's remove this parameter down the line.
*/
startAtBottom?: boolean;
/* className: classnames to add to the top-level div
*/
className?: string;
/* style: styles to add to the top-level div
*/
style?: CSSProperties;
/* resizeNotifier: ResizeNotifier to know when middle column has changed size
*/
resizeNotifier?: ResizeNotifier;
/* fixedChildren: allows for children to be passed which are rendered outside
* of the wrapper
*/
fixedChildren?: ReactNode;
/* onFillRequest(backwards): a callback which is called on scroll when
* the user nears the start (backwards = true) or end (backwards =
* false) of the list.
*
* This should return a promise; no more calls will be made until the
* promise completes.
*
* The promise should resolve to true if there is more data to be
* retrieved in this direction (in which case onFillRequest may be
* called again immediately), or false if there is no more data in this
* directon (at this time) - which will stop the pagination cycle until
* the user scrolls again.
*/
onFillRequest?(backwards: boolean): Promise<boolean>;
/* onUnfillRequest(backwards): a callback which is called on scroll when
* there are children elements that are far out of view and could be removed
* without causing pagination to occur.
*
* This function should accept a boolean, which is true to indicate the back/top
* of the panel and false otherwise, and a scroll token, which refers to the
* first element to remove if removing from the front/bottom, and last element
* to remove if removing from the back/top.
*/
onUnfillRequest?(backwards: boolean, scrollToken: string): void;
/* onScroll: a callback which is called whenever any scroll happens.
*/
onScroll?(event: Event): void;
/* onUserScroll: callback which is called when the user interacts with the room timeline
*/
onUserScroll?(event: SyntheticEvent): void;
}
/* This component implements an intelligent scrolling list. /* This component implements an intelligent scrolling list.
* *
* It wraps a list of <li> children; when items are added to the start or end * It wraps a list of <li> children; when items are added to the start or end
@ -84,97 +154,54 @@ if (DEBUG_SCROLL) {
* offset as normal. * offset as normal.
*/ */
export interface IScrollState {
stuckAtBottom: boolean;
trackedNode?: HTMLElement;
trackedScrollToken?: string;
bottomOffset?: number;
pixelOffset?: number;
}
interface IPreventShrinkingState {
offsetFromBottom: number;
offsetNode: HTMLElement;
}
@replaceableComponent("structures.ScrollPanel") @replaceableComponent("structures.ScrollPanel")
export default class ScrollPanel extends React.Component { export default class ScrollPanel extends React.Component<IProps> {
static propTypes = {
/* stickyBottom: if set to true, then once the user hits the bottom of
* the list, any new children added to the list will cause the list to
* scroll down to show the new element, rather than preserving the
* existing view.
*/
stickyBottom: PropTypes.bool,
/* startAtBottom: if set to true, the view is assumed to start
* scrolled to the bottom.
* XXX: It's likely this is unnecessary and can be derived from
* stickyBottom, but I'm adding an extra parameter to ensure
* behaviour stays the same for other uses of ScrollPanel.
* If so, let's remove this parameter down the line.
*/
startAtBottom: PropTypes.bool,
/* onFillRequest(backwards): a callback which is called on scroll when
* the user nears the start (backwards = true) or end (backwards =
* false) of the list.
*
* This should return a promise; no more calls will be made until the
* promise completes.
*
* The promise should resolve to true if there is more data to be
* retrieved in this direction (in which case onFillRequest may be
* called again immediately), or false if there is no more data in this
* directon (at this time) - which will stop the pagination cycle until
* the user scrolls again.
*/
onFillRequest: PropTypes.func,
/* onUnfillRequest(backwards): a callback which is called on scroll when
* there are children elements that are far out of view and could be removed
* without causing pagination to occur.
*
* This function should accept a boolean, which is true to indicate the back/top
* of the panel and false otherwise, and a scroll token, which refers to the
* first element to remove if removing from the front/bottom, and last element
* to remove if removing from the back/top.
*/
onUnfillRequest: PropTypes.func,
/* onScroll: a callback which is called whenever any scroll happens.
*/
onScroll: PropTypes.func,
/* onUserScroll: callback which is called when the user interacts with the room timeline
*/
onUserScroll: PropTypes.func,
/* className: classnames to add to the top-level div
*/
className: PropTypes.string,
/* style: styles to add to the top-level div
*/
style: PropTypes.object,
/* resizeNotifier: ResizeNotifier to know when middle column has changed size
*/
resizeNotifier: PropTypes.object,
/* fixedChildren: allows for children to be passed which are rendered outside
* of the wrapper
*/
fixedChildren: PropTypes.node,
};
static defaultProps = { static defaultProps = {
stickyBottom: true, stickyBottom: true,
startAtBottom: true, startAtBottom: true,
onFillRequest: function(backwards) { return Promise.resolve(false); }, onFillRequest: function(backwards: boolean) { return Promise.resolve(false); },
onUnfillRequest: function(backwards, scrollToken) {}, onUnfillRequest: function(backwards: boolean, scrollToken: string) {},
onScroll: function() {}, onScroll: function() {},
}; };
constructor(props) { private readonly pendingFillRequests: Record<"b" | "f", boolean> = {
super(props); b: null,
f: null,
};
private readonly itemlist = createRef<HTMLOListElement>();
private unmounted = false;
private scrollTimeout: Timer;
private isFilling: boolean;
private fillRequestWhileRunning: boolean;
private scrollState: IScrollState;
private preventShrinkingState: IPreventShrinkingState;
private unfillDebouncer: NodeJS.Timeout;
private bottomGrowth: number;
private pages: number;
private heightUpdateInProgress: boolean;
private divScroll: HTMLDivElement;
this._pendingFillRequests = {b: null, f: null}; constructor(props, context) {
super(props, context);
if (this.props.resizeNotifier) { if (this.props.resizeNotifier) {
this.props.resizeNotifier.on("middlePanelResizedNoisy", this.onResize); this.props.resizeNotifier.on("middlePanelResizedNoisy", this.onResize);
} }
this.resetScrollState(); this.resetScrollState();
this._itemlist = createRef();
} }
componentDidMount() { componentDidMount() {
@ -203,18 +230,18 @@ export default class ScrollPanel extends React.Component {
} }
} }
onScroll = ev => { private onScroll = ev => {
// skip scroll events caused by resizing // skip scroll events caused by resizing
if (this.props.resizeNotifier && this.props.resizeNotifier.isResizing) return; if (this.props.resizeNotifier && this.props.resizeNotifier.isResizing) return;
debuglog("onScroll", this._getScrollNode().scrollTop); debuglog("onScroll", this.getScrollNode().scrollTop);
this._scrollTimeout.restart(); this.scrollTimeout.restart();
this._saveScrollState(); this.saveScrollState();
this.updatePreventShrinking(); this.updatePreventShrinking();
this.props.onScroll(ev); this.props.onScroll(ev);
this.checkFillState(); this.checkFillState();
}; };
onResize = () => { private onResize = () => {
debuglog("onResize"); debuglog("onResize");
this.checkScroll(); this.checkScroll();
// update preventShrinkingState if present // update preventShrinkingState if present
@ -225,11 +252,11 @@ export default class ScrollPanel extends React.Component {
// after an update to the contents of the panel, check that the scroll is // after an update to the contents of the panel, check that the scroll is
// where it ought to be, and set off pagination requests if necessary. // where it ought to be, and set off pagination requests if necessary.
checkScroll = () => { public checkScroll = () => {
if (this.unmounted) { if (this.unmounted) {
return; return;
} }
this._restoreSavedScrollState(); this.restoreSavedScrollState();
this.checkFillState(); this.checkFillState();
}; };
@ -238,8 +265,8 @@ export default class ScrollPanel extends React.Component {
// note that this is independent of the 'stuckAtBottom' state - it is simply // note that this is independent of the 'stuckAtBottom' state - it is simply
// about whether the content is scrolled down right now, irrespective of // about whether the content is scrolled down right now, irrespective of
// whether it will stay that way when the children update. // whether it will stay that way when the children update.
isAtBottom = () => { public isAtBottom = () => {
const sn = this._getScrollNode(); const sn = this.getScrollNode();
// fractional values (both too big and too small) // fractional values (both too big and too small)
// for scrollTop happen on certain browsers/platforms // for scrollTop happen on certain browsers/platforms
// when scrolled all the way down. E.g. Chrome 72 on debian. // when scrolled all the way down. E.g. Chrome 72 on debian.
@ -278,10 +305,10 @@ export default class ScrollPanel extends React.Component {
// |#########| - | // |#########| - |
// |#########| | // |#########| |
// `---------' - // `---------' -
_getExcessHeight(backwards) { private getExcessHeight(backwards: boolean): number {
const sn = this._getScrollNode(); const sn = this.getScrollNode();
const contentHeight = this._getMessagesHeight(); const contentHeight = this.getMessagesHeight();
const listHeight = this._getListHeight(); const listHeight = this.getListHeight();
const clippedHeight = contentHeight - listHeight; const clippedHeight = contentHeight - listHeight;
const unclippedScrollTop = sn.scrollTop + clippedHeight; const unclippedScrollTop = sn.scrollTop + clippedHeight;
@ -293,13 +320,13 @@ export default class ScrollPanel extends React.Component {
} }
// check the scroll state and send out backfill requests if necessary. // check the scroll state and send out backfill requests if necessary.
checkFillState = async (depth=0) => { public checkFillState = async (depth = 0): Promise<void> => {
if (this.unmounted) { if (this.unmounted) {
return; return;
} }
const isFirstCall = depth === 0; const isFirstCall = depth === 0;
const sn = this._getScrollNode(); const sn = this.getScrollNode();
// if there is less than a screenful of messages above or below the // if there is less than a screenful of messages above or below the
// viewport, try to get some more messages. // viewport, try to get some more messages.
@ -330,17 +357,17 @@ export default class ScrollPanel extends React.Component {
// do make a note when a new request comes in while already running one, // do make a note when a new request comes in while already running one,
// so we can trigger a new chain of calls once done. // so we can trigger a new chain of calls once done.
if (isFirstCall) { if (isFirstCall) {
if (this._isFilling) { if (this.isFilling) {
debuglog("_isFilling: not entering while request is ongoing, marking for a subsequent request"); debuglog("isFilling: not entering while request is ongoing, marking for a subsequent request");
this._fillRequestWhileRunning = true; this.fillRequestWhileRunning = true;
return; return;
} }
debuglog("_isFilling: setting"); debuglog("isFilling: setting");
this._isFilling = true; this.isFilling = true;
} }
const itemlist = this._itemlist.current; const itemlist = this.itemlist.current;
const firstTile = itemlist && itemlist.firstElementChild; const firstTile = itemlist && itemlist.firstElementChild as HTMLElement;
const contentTop = firstTile && firstTile.offsetTop; const contentTop = firstTile && firstTile.offsetTop;
const fillPromises = []; const fillPromises = [];
@ -348,13 +375,13 @@ export default class ScrollPanel extends React.Component {
// try backward filling // try backward filling
if (!firstTile || (sn.scrollTop - contentTop) < sn.clientHeight) { if (!firstTile || (sn.scrollTop - contentTop) < sn.clientHeight) {
// need to back-fill // need to back-fill
fillPromises.push(this._maybeFill(depth, true)); fillPromises.push(this.maybeFill(depth, true));
} }
// if scrollTop gets to 2 screens from the end (so 1 screen below viewport), // if scrollTop gets to 2 screens from the end (so 1 screen below viewport),
// try forward filling // try forward filling
if ((sn.scrollHeight - sn.scrollTop) < sn.clientHeight * 2) { if ((sn.scrollHeight - sn.scrollTop) < sn.clientHeight * 2) {
// need to forward-fill // need to forward-fill
fillPromises.push(this._maybeFill(depth, false)); fillPromises.push(this.maybeFill(depth, false));
} }
if (fillPromises.length) { if (fillPromises.length) {
@ -365,26 +392,26 @@ export default class ScrollPanel extends React.Component {
} }
} }
if (isFirstCall) { if (isFirstCall) {
debuglog("_isFilling: clearing"); debuglog("isFilling: clearing");
this._isFilling = false; this.isFilling = false;
} }
if (this._fillRequestWhileRunning) { if (this.fillRequestWhileRunning) {
this._fillRequestWhileRunning = false; this.fillRequestWhileRunning = false;
this.checkFillState(); this.checkFillState();
} }
}; };
// check if unfilling is possible and send an unfill request if necessary // check if unfilling is possible and send an unfill request if necessary
_checkUnfillState(backwards) { private checkUnfillState(backwards: boolean): void {
let excessHeight = this._getExcessHeight(backwards); let excessHeight = this.getExcessHeight(backwards);
if (excessHeight <= 0) { if (excessHeight <= 0) {
return; return;
} }
const origExcessHeight = excessHeight; const origExcessHeight = excessHeight;
const tiles = this._itemlist.current.children; const tiles = this.itemlist.current.children;
// The scroll token of the first/last tile to be unpaginated // The scroll token of the first/last tile to be unpaginated
let markerScrollToken = null; let markerScrollToken = null;
@ -413,11 +440,11 @@ export default class ScrollPanel extends React.Component {
if (markerScrollToken) { if (markerScrollToken) {
// Use a debouncer to prevent multiple unfill calls in quick succession // Use a debouncer to prevent multiple unfill calls in quick succession
// This is to make the unfilling process less aggressive // This is to make the unfilling process less aggressive
if (this._unfillDebouncer) { if (this.unfillDebouncer) {
clearTimeout(this._unfillDebouncer); clearTimeout(this.unfillDebouncer);
} }
this._unfillDebouncer = setTimeout(() => { this.unfillDebouncer = setTimeout(() => {
this._unfillDebouncer = null; this.unfillDebouncer = null;
debuglog("unfilling now", backwards, origExcessHeight); debuglog("unfilling now", backwards, origExcessHeight);
this.props.onUnfillRequest(backwards, markerScrollToken); this.props.onUnfillRequest(backwards, markerScrollToken);
}, UNFILL_REQUEST_DEBOUNCE_MS); }, UNFILL_REQUEST_DEBOUNCE_MS);
@ -425,9 +452,9 @@ export default class ScrollPanel extends React.Component {
} }
// check if there is already a pending fill request. If not, set one off. // check if there is already a pending fill request. If not, set one off.
_maybeFill(depth, backwards) { private maybeFill(depth: number, backwards: boolean): Promise<void> {
const dir = backwards ? 'b' : 'f'; const dir = backwards ? 'b' : 'f';
if (this._pendingFillRequests[dir]) { if (this.pendingFillRequests[dir]) {
debuglog("Already a "+dir+" fill in progress - not starting another"); debuglog("Already a "+dir+" fill in progress - not starting another");
return; return;
} }
@ -436,7 +463,7 @@ export default class ScrollPanel extends React.Component {
// onFillRequest can end up calling us recursively (via onScroll // onFillRequest can end up calling us recursively (via onScroll
// events) so make sure we set this before firing off the call. // events) so make sure we set this before firing off the call.
this._pendingFillRequests[dir] = true; this.pendingFillRequests[dir] = true;
// wait 1ms before paginating, because otherwise // wait 1ms before paginating, because otherwise
// this will block the scroll event handler for +700ms // this will block the scroll event handler for +700ms
@ -445,13 +472,13 @@ export default class ScrollPanel extends React.Component {
return new Promise(resolve => setTimeout(resolve, 1)).then(() => { return new Promise(resolve => setTimeout(resolve, 1)).then(() => {
return this.props.onFillRequest(backwards); return this.props.onFillRequest(backwards);
}).finally(() => { }).finally(() => {
this._pendingFillRequests[dir] = false; this.pendingFillRequests[dir] = false;
}).then((hasMoreResults) => { }).then((hasMoreResults) => {
if (this.unmounted) { if (this.unmounted) {
return; return;
} }
// Unpaginate once filling is complete // Unpaginate once filling is complete
this._checkUnfillState(!backwards); this.checkUnfillState(!backwards);
debuglog(""+dir+" fill complete; hasMoreResults:"+hasMoreResults); debuglog(""+dir+" fill complete; hasMoreResults:"+hasMoreResults);
if (hasMoreResults) { if (hasMoreResults) {
@ -477,7 +504,7 @@ export default class ScrollPanel extends React.Component {
* the number of pixels the bottom of the tracked child is above the * the number of pixels the bottom of the tracked child is above the
* bottom of the scroll panel. * bottom of the scroll panel.
*/ */
getScrollState = () => this.scrollState; public getScrollState = (): IScrollState => this.scrollState;
/* reset the saved scroll state. /* reset the saved scroll state.
* *
@ -491,35 +518,35 @@ export default class ScrollPanel extends React.Component {
* no use if no children exist yet, or if you are about to replace the * no use if no children exist yet, or if you are about to replace the
* child list.) * child list.)
*/ */
resetScrollState = () => { public resetScrollState = (): void => {
this.scrollState = { this.scrollState = {
stuckAtBottom: this.props.startAtBottom, stuckAtBottom: this.props.startAtBottom,
}; };
this._bottomGrowth = 0; this.bottomGrowth = 0;
this._pages = 0; this.pages = 0;
this._scrollTimeout = new Timer(100); this.scrollTimeout = new Timer(100);
this._heightUpdateInProgress = false; this.heightUpdateInProgress = false;
}; };
/** /**
* jump to the top of the content. * jump to the top of the content.
*/ */
scrollToTop = () => { public scrollToTop = (): void => {
this._getScrollNode().scrollTop = 0; this.getScrollNode().scrollTop = 0;
this._saveScrollState(); this.saveScrollState();
}; };
/** /**
* jump to the bottom of the content. * jump to the bottom of the content.
*/ */
scrollToBottom = () => { public scrollToBottom = (): void => {
// the easiest way to make sure that the scroll state is correctly // the easiest way to make sure that the scroll state is correctly
// saved is to do the scroll, then save the updated state. (Calculating // saved is to do the scroll, then save the updated state. (Calculating
// it ourselves is hard, and we can't rely on an onScroll callback // it ourselves is hard, and we can't rely on an onScroll callback
// happening, since there may be no user-visible change here). // happening, since there may be no user-visible change here).
const sn = this._getScrollNode(); const sn = this.getScrollNode();
sn.scrollTop = sn.scrollHeight; sn.scrollTop = sn.scrollHeight;
this._saveScrollState(); this.saveScrollState();
}; };
/** /**
@ -527,18 +554,18 @@ export default class ScrollPanel extends React.Component {
* *
* @param {number} mult: -1 to page up, +1 to page down * @param {number} mult: -1 to page up, +1 to page down
*/ */
scrollRelative = mult => { public scrollRelative = (mult: number): void => {
const scrollNode = this._getScrollNode(); const scrollNode = this.getScrollNode();
const delta = mult * scrollNode.clientHeight * 0.9; const delta = mult * scrollNode.clientHeight * 0.9;
scrollNode.scrollBy(0, delta); scrollNode.scrollBy(0, delta);
this._saveScrollState(); this.saveScrollState();
}; };
/** /**
* Scroll up/down in response to a scroll key * Scroll up/down in response to a scroll key
* @param {object} ev the keyboard event * @param {object} ev the keyboard event
*/ */
handleScrollKey = ev => { public handleScrollKey = (ev: KeyboardEvent) => {
let isScrolling = false; let isScrolling = false;
const roomAction = getKeyBindingsManager().getRoomAction(ev); const roomAction = getKeyBindingsManager().getRoomAction(ev);
switch (roomAction) { switch (roomAction) {
@ -575,17 +602,17 @@ export default class ScrollPanel extends React.Component {
* node (specifically, the bottom of it) will be positioned. If omitted, it * node (specifically, the bottom of it) will be positioned. If omitted, it
* defaults to 0. * defaults to 0.
*/ */
scrollToToken = (scrollToken, pixelOffset, offsetBase) => { public scrollToToken = (scrollToken: string, pixelOffset: number, offsetBase: number): void => {
pixelOffset = pixelOffset || 0; pixelOffset = pixelOffset || 0;
offsetBase = offsetBase || 0; offsetBase = offsetBase || 0;
// set the trackedScrollToken so we can get the node through _getTrackedNode // set the trackedScrollToken so we can get the node through getTrackedNode
this.scrollState = { this.scrollState = {
stuckAtBottom: false, stuckAtBottom: false,
trackedScrollToken: scrollToken, trackedScrollToken: scrollToken,
}; };
const trackedNode = this._getTrackedNode(); const trackedNode = this.getTrackedNode();
const scrollNode = this._getScrollNode(); const scrollNode = this.getScrollNode();
if (trackedNode) { if (trackedNode) {
// set the scrollTop to the position we want. // set the scrollTop to the position we want.
// note though, that this might not succeed if the combination of offsetBase and pixelOffset // note though, that this might not succeed if the combination of offsetBase and pixelOffset
@ -595,34 +622,34 @@ export default class ScrollPanel extends React.Component {
// enough so it ends up in the top of the viewport. // enough so it ends up in the top of the viewport.
debuglog("scrollToken: setting scrollTop", {offsetBase, pixelOffset, offsetTop: trackedNode.offsetTop}); debuglog("scrollToken: setting scrollTop", {offsetBase, pixelOffset, offsetTop: trackedNode.offsetTop});
scrollNode.scrollTop = (trackedNode.offsetTop - (scrollNode.clientHeight * offsetBase)) + pixelOffset; scrollNode.scrollTop = (trackedNode.offsetTop - (scrollNode.clientHeight * offsetBase)) + pixelOffset;
this._saveScrollState(); this.saveScrollState();
} }
}; };
_saveScrollState() { private saveScrollState(): void {
if (this.props.stickyBottom && this.isAtBottom()) { if (this.props.stickyBottom && this.isAtBottom()) {
this.scrollState = { stuckAtBottom: true }; this.scrollState = { stuckAtBottom: true };
debuglog("saved stuckAtBottom state"); debuglog("saved stuckAtBottom state");
return; return;
} }
const scrollNode = this._getScrollNode(); const scrollNode = this.getScrollNode();
const viewportBottom = scrollNode.scrollHeight - (scrollNode.scrollTop + scrollNode.clientHeight); const viewportBottom = scrollNode.scrollHeight - (scrollNode.scrollTop + scrollNode.clientHeight);
const itemlist = this._itemlist.current; const itemlist = this.itemlist.current;
const messages = itemlist.children; const messages = itemlist.children;
let node = null; let node = null;
// TODO: do a binary search here, as items are sorted by offsetTop // TODO: do a binary search here, as items are sorted by offsetTop
// loop backwards, from bottom-most message (as that is the most common case) // loop backwards, from bottom-most message (as that is the most common case)
for (let i = messages.length - 1; i >= 0; --i) { for (let i = messages.length - 1; i >= 0; --i) {
if (!messages[i].dataset.scrollTokens) { if (!(messages[i] as HTMLElement).dataset.scrollTokens) {
continue; continue;
} }
node = messages[i]; node = messages[i];
// break at the first message (coming from the bottom) // break at the first message (coming from the bottom)
// that has it's offsetTop above the bottom of the viewport. // that has it's offsetTop above the bottom of the viewport.
if (this._topFromBottom(node) > viewportBottom) { if (this.topFromBottom(node) > viewportBottom) {
// Use this node as the scrollToken // Use this node as the scrollToken
break; break;
} }
@ -634,7 +661,7 @@ export default class ScrollPanel extends React.Component {
} }
const scrollToken = node.dataset.scrollTokens.split(',')[0]; const scrollToken = node.dataset.scrollTokens.split(',')[0];
debuglog("saving anchored scroll state to message", node && node.innerText, scrollToken); debuglog("saving anchored scroll state to message", node && node.innerText, scrollToken);
const bottomOffset = this._topFromBottom(node); const bottomOffset = this.topFromBottom(node);
this.scrollState = { this.scrollState = {
stuckAtBottom: false, stuckAtBottom: false,
trackedNode: node, trackedNode: node,
@ -644,35 +671,35 @@ export default class ScrollPanel extends React.Component {
}; };
} }
async _restoreSavedScrollState() { private async restoreSavedScrollState(): Promise<void> {
const scrollState = this.scrollState; const scrollState = this.scrollState;
if (scrollState.stuckAtBottom) { if (scrollState.stuckAtBottom) {
const sn = this._getScrollNode(); const sn = this.getScrollNode();
if (sn.scrollTop !== sn.scrollHeight) { if (sn.scrollTop !== sn.scrollHeight) {
sn.scrollTop = sn.scrollHeight; sn.scrollTop = sn.scrollHeight;
} }
} else if (scrollState.trackedScrollToken) { } else if (scrollState.trackedScrollToken) {
const itemlist = this._itemlist.current; const itemlist = this.itemlist.current;
const trackedNode = this._getTrackedNode(); const trackedNode = this.getTrackedNode();
if (trackedNode) { if (trackedNode) {
const newBottomOffset = this._topFromBottom(trackedNode); const newBottomOffset = this.topFromBottom(trackedNode);
const bottomDiff = newBottomOffset - scrollState.bottomOffset; const bottomDiff = newBottomOffset - scrollState.bottomOffset;
this._bottomGrowth += bottomDiff; this.bottomGrowth += bottomDiff;
scrollState.bottomOffset = newBottomOffset; scrollState.bottomOffset = newBottomOffset;
const newHeight = `${this._getListHeight()}px`; const newHeight = `${this.getListHeight()}px`;
if (itemlist.style.height !== newHeight) { if (itemlist.style.height !== newHeight) {
itemlist.style.height = newHeight; itemlist.style.height = newHeight;
} }
debuglog("balancing height because messages below viewport grew by", bottomDiff); debuglog("balancing height because messages below viewport grew by", bottomDiff);
} }
} }
if (!this._heightUpdateInProgress) { if (!this.heightUpdateInProgress) {
this._heightUpdateInProgress = true; this.heightUpdateInProgress = true;
try { try {
await this._updateHeight(); await this.updateHeight();
} finally { } finally {
this._heightUpdateInProgress = false; this.heightUpdateInProgress = false;
} }
} else { } else {
debuglog("not updating height because request already in progress"); debuglog("not updating height because request already in progress");
@ -680,11 +707,11 @@ export default class ScrollPanel extends React.Component {
} }
// need a better name that also indicates this will change scrollTop? Rebalance height? Reveal content? // need a better name that also indicates this will change scrollTop? Rebalance height? Reveal content?
async _updateHeight() { private async updateHeight(): Promise<void> {
// wait until user has stopped scrolling // wait until user has stopped scrolling
if (this._scrollTimeout.isRunning()) { if (this.scrollTimeout.isRunning()) {
debuglog("updateHeight waiting for scrolling to end ... "); debuglog("updateHeight waiting for scrolling to end ... ");
await this._scrollTimeout.finished(); await this.scrollTimeout.finished();
} else { } else {
debuglog("updateHeight getting straight to business, no scrolling going on."); debuglog("updateHeight getting straight to business, no scrolling going on.");
} }
@ -694,14 +721,14 @@ export default class ScrollPanel extends React.Component {
return; return;
} }
const sn = this._getScrollNode(); const sn = this.getScrollNode();
const itemlist = this._itemlist.current; const itemlist = this.itemlist.current;
const contentHeight = this._getMessagesHeight(); const contentHeight = this.getMessagesHeight();
const minHeight = sn.clientHeight; const minHeight = sn.clientHeight;
const height = Math.max(minHeight, contentHeight); const height = Math.max(minHeight, contentHeight);
this._pages = Math.ceil(height / PAGE_SIZE); this.pages = Math.ceil(height / PAGE_SIZE);
this._bottomGrowth = 0; this.bottomGrowth = 0;
const newHeight = `${this._getListHeight()}px`; const newHeight = `${this.getListHeight()}px`;
const scrollState = this.scrollState; const scrollState = this.scrollState;
if (scrollState.stuckAtBottom) { if (scrollState.stuckAtBottom) {
@ -713,7 +740,7 @@ export default class ScrollPanel extends React.Component {
} }
debuglog("updateHeight to", newHeight); debuglog("updateHeight to", newHeight);
} else if (scrollState.trackedScrollToken) { } else if (scrollState.trackedScrollToken) {
const trackedNode = this._getTrackedNode(); const trackedNode = this.getTrackedNode();
// if the timeline has been reloaded // if the timeline has been reloaded
// this can be called before scrollToBottom or whatever has been called // this can be called before scrollToBottom or whatever has been called
// so don't do anything if the node has disappeared from // so don't do anything if the node has disappeared from
@ -735,17 +762,17 @@ export default class ScrollPanel extends React.Component {
} }
} }
_getTrackedNode() { private getTrackedNode(): HTMLElement {
const scrollState = this.scrollState; const scrollState = this.scrollState;
const trackedNode = scrollState.trackedNode; const trackedNode = scrollState.trackedNode;
if (!trackedNode || !trackedNode.parentElement) { if (!trackedNode || !trackedNode.parentElement) {
let node; let node;
const messages = this._itemlist.current.children; const messages = this.itemlist.current.children;
const scrollToken = scrollState.trackedScrollToken; const scrollToken = scrollState.trackedScrollToken;
for (let i = messages.length-1; i >= 0; --i) { for (let i = messages.length-1; i >= 0; --i) {
const m = messages[i]; const m = messages[i] as HTMLElement;
// 'data-scroll-tokens' is a DOMString of comma-separated scroll tokens // 'data-scroll-tokens' is a DOMString of comma-separated scroll tokens
// There might only be one scroll token // There might only be one scroll token
if (m.dataset.scrollTokens && if (m.dataset.scrollTokens &&
@ -768,45 +795,45 @@ export default class ScrollPanel extends React.Component {
return scrollState.trackedNode; return scrollState.trackedNode;
} }
_getListHeight() { private getListHeight(): number {
return this._bottomGrowth + (this._pages * PAGE_SIZE); return this.bottomGrowth + (this.pages * PAGE_SIZE);
} }
_getMessagesHeight() { private getMessagesHeight(): number {
const itemlist = this._itemlist.current; const itemlist = this.itemlist.current;
const lastNode = itemlist.lastElementChild; const lastNode = itemlist.lastElementChild as HTMLElement;
const lastNodeBottom = lastNode ? lastNode.offsetTop + lastNode.clientHeight : 0; const lastNodeBottom = lastNode ? lastNode.offsetTop + lastNode.clientHeight : 0;
const firstNodeTop = itemlist.firstElementChild ? itemlist.firstElementChild.offsetTop : 0; const firstNodeTop = itemlist.firstElementChild ? (itemlist.firstElementChild as HTMLElement).offsetTop : 0;
// 18 is itemlist padding // 18 is itemlist padding
return lastNodeBottom - firstNodeTop + (18 * 2); return lastNodeBottom - firstNodeTop + (18 * 2);
} }
_topFromBottom(node) { private topFromBottom(node: HTMLElement): number {
// current capped height - distance from top = distance from bottom of container to top of tracked element // current capped height - distance from top = distance from bottom of container to top of tracked element
return this._itemlist.current.clientHeight - node.offsetTop; return this.itemlist.current.clientHeight - node.offsetTop;
} }
/* get the DOM node which has the scrollTop property we care about for our /* get the DOM node which has the scrollTop property we care about for our
* message panel. * message panel.
*/ */
_getScrollNode() { private getScrollNode(): HTMLDivElement {
if (this.unmounted) { if (this.unmounted) {
// this shouldn't happen, but when it does, turn the NPE into // this shouldn't happen, but when it does, turn the NPE into
// something more meaningful. // something more meaningful.
throw new Error("ScrollPanel._getScrollNode called when unmounted"); throw new Error("ScrollPanel.getScrollNode called when unmounted");
} }
if (!this._divScroll) { if (!this.divScroll) {
// Likewise, we should have the ref by this point, but if not // Likewise, we should have the ref by this point, but if not
// turn the NPE into something meaningful. // turn the NPE into something meaningful.
throw new Error("ScrollPanel._getScrollNode called before AutoHideScrollbar ref collected"); throw new Error("ScrollPanel.getScrollNode called before AutoHideScrollbar ref collected");
} }
return this._divScroll; return this.divScroll;
} }
_collectScroll = divScroll => { private collectScroll = (divScroll: HTMLDivElement) => {
this._divScroll = divScroll; this.divScroll = divScroll;
}; };
/** /**
@ -814,15 +841,15 @@ export default class ScrollPanel extends React.Component {
anything below it changes, by calling updatePreventShrinking, to keep anything below it changes, by calling updatePreventShrinking, to keep
the same minimum bottom offset, effectively preventing the timeline to shrink. the same minimum bottom offset, effectively preventing the timeline to shrink.
*/ */
preventShrinking = () => { public preventShrinking = (): void => {
const messageList = this._itemlist.current; const messageList = this.itemlist.current;
const tiles = messageList && messageList.children; const tiles = messageList && messageList.children;
if (!messageList) { if (!messageList) {
return; return;
} }
let lastTileNode; let lastTileNode;
for (let i = tiles.length - 1; i >= 0; i--) { for (let i = tiles.length - 1; i >= 0; i--) {
const node = tiles[i]; const node = tiles[i] as HTMLElement;
if (node.dataset.scrollTokens) { if (node.dataset.scrollTokens) {
lastTileNode = node; lastTileNode = node;
break; break;
@ -841,8 +868,8 @@ export default class ScrollPanel extends React.Component {
}; };
/** Clear shrinking prevention. Used internally, and when the timeline is reloaded. */ /** Clear shrinking prevention. Used internally, and when the timeline is reloaded. */
clearPreventShrinking = () => { public clearPreventShrinking = (): void => {
const messageList = this._itemlist.current; const messageList = this.itemlist.current;
const balanceElement = messageList && messageList.parentElement; const balanceElement = messageList && messageList.parentElement;
if (balanceElement) balanceElement.style.paddingBottom = null; if (balanceElement) balanceElement.style.paddingBottom = null;
this.preventShrinkingState = null; this.preventShrinkingState = null;
@ -857,11 +884,11 @@ export default class ScrollPanel extends React.Component {
from the bottom of the marked tile grows larger than from the bottom of the marked tile grows larger than
what it was when marking. what it was when marking.
*/ */
updatePreventShrinking = () => { public updatePreventShrinking = (): void => {
if (this.preventShrinkingState) { if (this.preventShrinkingState) {
const sn = this._getScrollNode(); const sn = this.getScrollNode();
const scrollState = this.scrollState; const scrollState = this.scrollState;
const messageList = this._itemlist.current; const messageList = this.itemlist.current;
const {offsetNode, offsetFromBottom} = this.preventShrinkingState; const {offsetNode, offsetFromBottom} = this.preventShrinkingState;
// element used to set paddingBottom to balance the typing notifs disappearing // element used to set paddingBottom to balance the typing notifs disappearing
const balanceElement = messageList.parentElement; const balanceElement = messageList.parentElement;
@ -898,13 +925,15 @@ export default class ScrollPanel extends React.Component {
// list-style-type: none; is no longer a list // list-style-type: none; is no longer a list
return ( return (
<AutoHideScrollbar <AutoHideScrollbar
wrappedRef={this._collectScroll} wrappedRef={this.collectScroll}
onScroll={this.onScroll} onScroll={this.onScroll}
onWheel={this.props.onUserScroll} onWheel={this.props.onUserScroll}
className={`mx_ScrollPanel ${this.props.className}`} style={this.props.style}> className={`mx_ScrollPanel ${this.props.className}`}
style={this.props.style}
>
{ this.props.fixedChildren } { this.props.fixedChildren }
<div className="mx_RoomView_messageListWrapper"> <div className="mx_RoomView_messageListWrapper">
<ol ref={this._itemlist} className="mx_RoomView_MessageList" aria-live="polite" role="list"> <ol ref={this.itemlist} className="mx_RoomView_MessageList" aria-live="polite" role="list">
{ this.props.children } { this.props.children }
</ol> </ol>
</div> </div>

View file

@ -39,7 +39,7 @@ import {mediaFromMxc} from "../../customisations/Media";
import InfoTooltip from "../views/elements/InfoTooltip"; import InfoTooltip from "../views/elements/InfoTooltip";
import TextWithTooltip from "../views/elements/TextWithTooltip"; import TextWithTooltip from "../views/elements/TextWithTooltip";
import { useStateToggle } from "../../hooks/useStateToggle"; import { useStateToggle } from "../../hooks/useStateToggle";
import {getOrder} from "../../stores/SpaceStore"; import { getChildOrder } from "../../stores/SpaceStore";
import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton"; import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton";
import { linkifyElement } from "../../HtmlUtils"; import { linkifyElement } from "../../HtmlUtils";
@ -286,7 +286,7 @@ export const HierarchyLevel = ({
const children = Array.from(relations.get(spaceId)?.values() || []); const children = Array.from(relations.get(spaceId)?.values() || []);
const sortedChildren = sortBy(children, ev => { const sortedChildren = sortBy(children, ev => {
// XXX: Space Summary API doesn't give the child origin_server_ts but once it does we should use it for sorting // XXX: Space Summary API doesn't give the child origin_server_ts but once it does we should use it for sorting
return getOrder(ev.content.order, null, ev.state_key); return getChildOrder(ev.content.order, null, ev.state_key);
}); });
const [subspaces, childRooms] = sortedChildren.reduce((result, ev: ISpaceSummaryEvent) => { const [subspaces, childRooms] = sortedChildren.reduce((result, ev: ISpaceSummaryEvent) => {
const roomId = ev.state_key; const roomId = ev.state_key;

View file

@ -55,7 +55,7 @@ export default class ViewSource extends React.Component {
viewSourceContent() { viewSourceContent() {
const mxEvent = this.props.mxEvent.replacingEvent() || this.props.mxEvent; // show the replacing event, not the original, if it is an edit const mxEvent = this.props.mxEvent.replacingEvent() || this.props.mxEvent; // show the replacing event, not the original, if it is an edit
const isEncrypted = mxEvent.isEncrypted(); const isEncrypted = mxEvent.isEncrypted();
const decryptedEventSource = mxEvent._clearEvent; // FIXME: _clearEvent is private const decryptedEventSource = mxEvent.clearEvent; // FIXME: clearEvent is private
const originalEventSource = mxEvent.event; const originalEventSource = mxEvent.event;
if (isEncrypted) { if (isEncrypted) {

View file

@ -19,6 +19,8 @@ limitations under the License.
import React, { useCallback, useContext, useEffect, useState } from 'react'; import React, { useCallback, useContext, useEffect, useState } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { ResizeMethod } from 'matrix-js-sdk/src/@types/partials';
import * as AvatarLogic from '../../../Avatar'; import * as AvatarLogic from '../../../Avatar';
import SettingsStore from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
import AccessibleButton from '../elements/AccessibleButton'; import AccessibleButton from '../elements/AccessibleButton';
@ -26,7 +28,6 @@ import RoomContext from "../../../contexts/RoomContext";
import MatrixClientContext from "../../../contexts/MatrixClientContext"; import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { useEventEmitter } from "../../../hooks/useEventEmitter"; import { useEventEmitter } from "../../../hooks/useEventEmitter";
import { toPx } from "../../../utils/units"; import { toPx } from "../../../utils/units";
import {ResizeMethod} from "../../../Avatar";
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
interface IProps { interface IProps {

View file

@ -15,10 +15,11 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import { ResizeMethod } from 'matrix-js-sdk/src/@types/partials';
import BaseAvatar from './BaseAvatar'; import BaseAvatar from './BaseAvatar';
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import { mediaFromMxc } from "../../../customisations/Media"; import { mediaFromMxc } from "../../../customisations/Media";
import {ResizeMethod} from "../../../Avatar";
export interface IProps { export interface IProps {
groupId?: string; groupId?: string;

View file

@ -17,13 +17,13 @@ limitations under the License.
import React from 'react'; import React from 'react';
import { RoomMember } from "matrix-js-sdk/src/models/room-member"; import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { ResizeMethod } from 'matrix-js-sdk/src/@types/partials';
import dis from "../../../dispatcher/dispatcher"; import dis from "../../../dispatcher/dispatcher";
import { Action } from "../../../dispatcher/actions"; import { Action } from "../../../dispatcher/actions";
import BaseAvatar from "./BaseAvatar"; import BaseAvatar from "./BaseAvatar";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import { mediaFromMxc } from "../../../customisations/Media"; import { mediaFromMxc } from "../../../customisations/Media";
import {ResizeMethod} from "../../../Avatar";
interface IProps extends Omit<React.ComponentProps<typeof BaseAvatar>, "name" | "idName" | "url"> { interface IProps extends Omit<React.ComponentProps<typeof BaseAvatar>, "name" | "idName" | "url"> {
member: RoomMember; member: RoomMember;

View file

@ -14,14 +14,14 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React, { ComponentProps } from 'react'; import React, { ComponentProps } from 'react';
import Room from 'matrix-js-sdk/src/models/room'; import { Room } from 'matrix-js-sdk/src/models/room';
import { ResizeMethod } from 'matrix-js-sdk/src/@types/partials';
import BaseAvatar from './BaseAvatar'; import BaseAvatar from './BaseAvatar';
import ImageView from '../elements/ImageView'; import ImageView from '../elements/ImageView';
import { MatrixClientPeg } from '../../../MatrixClientPeg'; import { MatrixClientPeg } from '../../../MatrixClientPeg';
import Modal from '../../../Modal'; import Modal from '../../../Modal';
import * as Avatar from '../../../Avatar'; import * as Avatar from '../../../Avatar';
import {ResizeMethod} from "../../../Avatar";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import { mediaFromMxc } from "../../../customisations/Media"; import { mediaFromMxc } from "../../../customisations/Media";

View file

@ -25,6 +25,7 @@ import TextWithTooltip from "../elements/TextWithTooltip";
import Modal from "../../../Modal"; import Modal from "../../../Modal";
import BetaFeedbackDialog from "../dialogs/BetaFeedbackDialog"; import BetaFeedbackDialog from "../dialogs/BetaFeedbackDialog";
import SdkConfig from "../../../SdkConfig"; import SdkConfig from "../../../SdkConfig";
import SettingsFlag from "../elements/SettingsFlag";
interface IProps { interface IProps {
title?: string; title?: string;
@ -66,7 +67,7 @@ const BetaCard = ({ title: titleOverride, featureId }: IProps) => {
const info = SettingsStore.getBetaInfo(featureId); const info = SettingsStore.getBetaInfo(featureId);
if (!info) return null; // Beta is invalid/disabled if (!info) return null; // Beta is invalid/disabled
const { title, caption, disclaimer, image, feedbackLabel, feedbackSubheading } = info; const { title, caption, disclaimer, image, feedbackLabel, feedbackSubheading, extraSettings } = info;
const value = SettingsStore.getValue(featureId); const value = SettingsStore.getValue(featureId);
let feedbackButton; let feedbackButton;
@ -82,13 +83,14 @@ const BetaCard = ({ title: titleOverride, featureId }: IProps) => {
} }
return <div className="mx_BetaCard"> return <div className="mx_BetaCard">
<div className="mx_BetaCard_columns">
<div> <div>
<h3 className="mx_BetaCard_title"> <h3 className="mx_BetaCard_title">
{ titleOverride || _t(title) } { titleOverride || _t(title) }
<BetaPill /> <BetaPill />
</h3> </h3>
<span className="mx_BetaCard_caption">{ _t(caption) }</span> <span className="mx_BetaCard_caption">{ _t(caption) }</span>
<div> <div className="mx_BetaCard_buttons">
{ feedbackButton } { feedbackButton }
<AccessibleButton <AccessibleButton
onClick={() => SettingsStore.setValue(featureId, null, SettingLevel.DEVICE, !value)} onClick={() => SettingsStore.setValue(featureId, null, SettingLevel.DEVICE, !value)}
@ -102,6 +104,12 @@ const BetaCard = ({ title: titleOverride, featureId }: IProps) => {
</div> } </div> }
</div> </div>
<img src={image} alt="" /> <img src={image} alt="" />
</div>
{ extraSettings && <div className="mx_BetaCard_relatedSettings">
{ extraSettings.map(key => (
<SettingsFlag key={key} name={key} level={SettingLevel.DEVICE} />
)) }
</div> }
</div>; </div>;
}; };

View file

@ -18,6 +18,7 @@ import React from 'react';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import { ContextMenu, IProps as IContextMenuProps } from '../../structures/ContextMenu'; import { ContextMenu, IProps as IContextMenuProps } from '../../structures/ContextMenu';
import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call'; import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
import Field from "../elements/Field";
import Dialpad from '../voip/DialPad'; import Dialpad from '../voip/DialPad';
import {replaceableComponent} from "../../../utils/replaceableComponent"; import {replaceableComponent} from "../../../utils/replaceableComponent";
@ -44,13 +45,21 @@ export default class DialpadContextMenu extends React.Component<IProps, IState>
this.setState({value: this.state.value + digit}); this.setState({value: this.state.value + digit});
} }
onChange = (ev) => {
this.setState({value: ev.target.value});
}
render() { render() {
return <ContextMenu {...this.props}> return <ContextMenu {...this.props}>
<div className="mx_DialPadContextMenu_header"> <div className="mx_DialPadContextMenu_header">
<div> <div>
<span className="mx_DialPadContextMenu_title">{_t("Dial pad")}</span> <span className="mx_DialPadContextMenu_title">{_t("Dial pad")}</span>
</div> </div>
<div className="mx_DialPadContextMenu_dialled">{this.state.value}</div> <Field className="mx_DialPadContextMenu_dialled"
value={this.state.value} autoFocus={true}
onChange={this.onChange}
/>
</div> </div>
<div className="mx_DialPadContextMenu_horizSep" /> <div className="mx_DialPadContextMenu_horizSep" />
<div className="mx_DialPadContextMenu_dialPad"> <div className="mx_DialPadContextMenu_dialPad">

View file

@ -33,7 +33,6 @@ import { EventType } from "matrix-js-sdk/src/@types/event";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import { ReadPinsEventId } from "../right_panel/PinnedMessagesCard"; import { ReadPinsEventId } from "../right_panel/PinnedMessagesCard";
import ForwardDialog from "../dialogs/ForwardDialog"; import ForwardDialog from "../dialogs/ForwardDialog";
import { ComposerInsertPayload } from "../../../dispatcher/payloads/ComposerInsertPayload";
import { Action } from "../../../dispatcher/actions"; import { Action } from "../../../dispatcher/actions";
export function canCancel(eventStatus) { export function canCancel(eventStatus) {
@ -180,7 +179,7 @@ export default class MessageContextMenu extends React.Component {
pinnedIds.push(eventId); pinnedIds.push(eventId);
cli.setRoomAccountData(room.roomId, ReadPinsEventId, { cli.setRoomAccountData(room.roomId, ReadPinsEventId, {
event_ids: [ event_ids: [
...room.getAccountData(ReadPinsEventId)?.getContent()?.event_ids, ...(room.getAccountData(ReadPinsEventId)?.getContent()?.event_ids || []),
eventId, eventId,
], ],
}); });
@ -201,7 +200,7 @@ export default class MessageContextMenu extends React.Component {
}; };
onQuoteClick = () => { onQuoteClick = () => {
dis.dispatch<ComposerInsertPayload>({ dis.dispatch({
action: Action.ComposerInsert, action: Action.ComposerInsert,
event: this.props.mxEvent, event: this.props.mxEvent,
}); });

View file

@ -23,45 +23,70 @@ import TagOrderActions from '../../../actions/TagOrderActions';
import {MenuItem} from "../../structures/ContextMenu"; import {MenuItem} from "../../structures/ContextMenu";
import MatrixClientContext from "../../../contexts/MatrixClientContext"; import MatrixClientContext from "../../../contexts/MatrixClientContext";
import {replaceableComponent} from "../../../utils/replaceableComponent"; import {replaceableComponent} from "../../../utils/replaceableComponent";
import GroupFilterOrderStore from "../../../stores/GroupFilterOrderStore";
@replaceableComponent("views.context_menus.TagTileContextMenu") @replaceableComponent("views.context_menus.TagTileContextMenu")
export default class TagTileContextMenu extends React.Component { export default class TagTileContextMenu extends React.Component {
static propTypes = { static propTypes = {
tag: PropTypes.string.isRequired, tag: PropTypes.string.isRequired,
index: PropTypes.number.isRequired,
/* callback called when the menu is dismissed */ /* callback called when the menu is dismissed */
onFinished: PropTypes.func.isRequired, onFinished: PropTypes.func.isRequired,
}; };
static contextType = MatrixClientContext; static contextType = MatrixClientContext;
constructor() { _onViewCommunityClick = () => {
super();
this._onViewCommunityClick = this._onViewCommunityClick.bind(this);
this._onRemoveClick = this._onRemoveClick.bind(this);
}
_onViewCommunityClick() {
dis.dispatch({ dis.dispatch({
action: 'view_group', action: 'view_group',
group_id: this.props.tag, group_id: this.props.tag,
}); });
this.props.onFinished(); this.props.onFinished();
} };
_onRemoveClick() { _onRemoveClick = () => {
dis.dispatch(TagOrderActions.removeTag(this.context, this.props.tag)); dis.dispatch(TagOrderActions.removeTag(this.context, this.props.tag));
this.props.onFinished(); this.props.onFinished();
} };
_onMoveUp = () => {
dis.dispatch(TagOrderActions.moveTag(this.context, this.props.tag, this.props.index - 1));
this.props.onFinished();
};
_onMoveDown = () => {
dis.dispatch(TagOrderActions.moveTag(this.context, this.props.tag, this.props.index + 1));
this.props.onFinished();
};
render() { render() {
let moveUp;
let moveDown;
if (this.props.index > 0) {
moveUp = (
<MenuItem className="mx_TagTileContextMenu_item mx_TagTileContextMenu_moveUp" onClick={this._onMoveUp}>
{ _t("Move up") }
</MenuItem>
);
}
if (this.props.index < (GroupFilterOrderStore.getOrderedTags() || []).length - 1) {
moveDown = (
<MenuItem className="mx_TagTileContextMenu_item mx_TagTileContextMenu_moveDown" onClick={this._onMoveDown}>
{ _t("Move down") }
</MenuItem>
);
}
return <div> return <div>
<MenuItem className="mx_TagTileContextMenu_item mx_TagTileContextMenu_viewCommunity" onClick={this._onViewCommunityClick}> <MenuItem className="mx_TagTileContextMenu_item mx_TagTileContextMenu_viewCommunity" onClick={this._onViewCommunityClick}>
{ _t('View Community') } { _t('View Community') }
</MenuItem> </MenuItem>
{ (moveUp || moveDown) ? <hr className="mx_TagTileContextMenu_separator" role="separator" /> : null }
{ moveUp }
{ moveDown }
<hr className="mx_TagTileContextMenu_separator" role="separator" /> <hr className="mx_TagTileContextMenu_separator" role="separator" />
<MenuItem className="mx_TagTileContextMenu_item mx_TagTileContextMenu_hideCommunity" onClick={this._onRemoveClick}> <MenuItem className="mx_TagTileContextMenu_item mx_TagTileContextMenu_hideCommunity" onClick={this._onRemoveClick}>
{ _t('Hide') } { _t("Unpin") }
</MenuItem> </MenuItem>
</div>; </div>;
} }

View file

@ -39,6 +39,9 @@ import ProgressBar from "../elements/ProgressBar";
import {SpaceFeedbackPrompt} from "../../structures/SpaceRoomView"; 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 EntityTile from "../rooms/EntityTile";
import BaseAvatar from "../avatars/BaseAvatar";
interface IProps extends IDialogProps { interface IProps extends IDialogProps {
matrixClient: MatrixClient; matrixClient: MatrixClient;
@ -204,6 +207,17 @@ export const AddExistingToSpace: React.FC<IAddExistingToSpaceProps> = ({
setSelectedToAdd(new Set(selectedToAdd)); setSelectedToAdd(new Set(selectedToAdd));
} : null; } : null;
const [truncateAt, setTruncateAt] = useState(20);
function overflowTile(overflowCount, totalCount) {
const text = _t("and %(count)s others...", { count: overflowCount });
return (
<EntityTile className="mx_EntityTile_ellipsis" avatarJsx={
<BaseAvatar url={require("../../../../res/img/ellipsis.svg")} name="..." width={36} height={36} />
} name={text} presenceState="online" suppressOnHover={true}
onClick={() => setTruncateAt(totalCount)} />
);
}
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"
@ -216,16 +230,21 @@ export const AddExistingToSpace: React.FC<IAddExistingToSpaceProps> = ({
{ rooms.length > 0 ? ( { rooms.length > 0 ? (
<div className="mx_AddExistingToSpace_section"> <div className="mx_AddExistingToSpace_section">
<h3>{ _t("Rooms") }</h3> <h3>{ _t("Rooms") }</h3>
{ rooms.map(room => { <TruncatedList
return <Entry truncateAt={truncateAt}
createOverflowElement={overflowTile}
getChildren={(start, end) => rooms.slice(start, end).map(room =>
<Entry
key={room.roomId} key={room.roomId}
room={room} room={room}
checked={selectedToAdd.has(room)} checked={selectedToAdd.has(room)}
onChange={onChange ? (checked) => { onChange={onChange ? (checked) => {
onChange(checked, room); onChange(checked, room);
} : null} } : null}
/>; />,
}) } )}
getChildCount={() => rooms.length}
/>
</div> </div>
) : undefined } ) : undefined }

View file

@ -24,7 +24,7 @@ import { _t, _td } from '../../../languageHandler';
import * as sdk from '../../../index'; import * as sdk from '../../../index';
import {MatrixClientPeg} from '../../../MatrixClientPeg'; import {MatrixClientPeg} from '../../../MatrixClientPeg';
import dis from '../../../dispatcher/dispatcher'; import dis from '../../../dispatcher/dispatcher';
import { addressTypes, getAddressType } from '../../../UserAddress.js'; import { addressTypes, getAddressType } from '../../../UserAddress';
import GroupStore from '../../../stores/GroupStore'; import GroupStore from '../../../stores/GroupStore';
import * as Email from '../../../email'; import * as Email from '../../../email';
import IdentityAuthClient from '../../../IdentityAuthClient'; import IdentityAuthClient from '../../../IdentityAuthClient';

View file

@ -44,7 +44,12 @@ const BetaFeedbackDialog: React.FC<IProps> = ({featureId, onFinished}) => {
const sendFeedback = async (ok: boolean) => { const sendFeedback = async (ok: boolean) => {
if (!ok) return onFinished(false); if (!ok) return onFinished(false);
submitFeedback(SdkConfig.get().bug_report_endpoint_url, info.feedbackLabel, comment, canContact); 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); onFinished(true);
Modal.createTrackedDialog("Beta Dialog Sent", featureId, InfoDialog, { Modal.createTrackedDialog("Beta Dialog Sent", featureId, InfoDialog, {

View file

@ -38,7 +38,7 @@ interface IProps {
// be the string entered. // be the string entered.
askReason?: boolean; askReason?: boolean;
danger?: boolean; danger?: boolean;
onFinished: (success: boolean, reason?: HTMLInputElement) => void; onFinished: (success: boolean, reason?: string) => void;
} }
/* /*
@ -59,11 +59,7 @@ export default class ConfirmUserActionDialog extends React.Component<IProps> {
}; };
public onOk = (): void => { public onOk = (): void => {
let reason; this.props.onFinished(true, this.reasonField.current?.value);
if (this.reasonField) {
reason = this.reasonField.current;
}
this.props.onFinished(true, reason);
}; };
public onCancel = (): void => { public onCancel = (): void => {

View file

@ -766,7 +766,7 @@ class VerificationExplorer extends React.PureComponent<IExplorerProps> {
render() { render() {
const cli = this.context; const cli = this.context;
const room = this.props.room; const room = this.props.room;
const inRoomChannel = cli.crypto._inRoomVerificationRequests; const inRoomChannel = cli.crypto.inRoomVerificationRequests;
const inRoomRequests = (inRoomChannel._requestsByRoomId || new Map()).get(room.roomId) || new Map(); const inRoomRequests = (inRoomChannel._requestsByRoomId || new Map()).get(room.roomId) || new Map();
return (<div> return (<div>

View file

@ -19,6 +19,7 @@ import classnames from "classnames";
import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { Room } from "matrix-js-sdk/src/models/room"; import { Room } from "matrix-js-sdk/src/models/room";
import { MatrixClient } from "matrix-js-sdk/src/client"; import { MatrixClient } from "matrix-js-sdk/src/client";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import dis from "../../../dispatcher/dispatcher"; import dis from "../../../dispatcher/dispatcher";
@ -39,6 +40,9 @@ import NotificationBadge from "../rooms/NotificationBadge";
import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks"; import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
import { sortRooms } from "../../../stores/room-list/algorithms/tag-sorting/RecentAlgorithm"; import { sortRooms } from "../../../stores/room-list/algorithms/tag-sorting/RecentAlgorithm";
import QueryMatcher from "../../../autocomplete/QueryMatcher"; import QueryMatcher from "../../../autocomplete/QueryMatcher";
import TruncatedList from "../elements/TruncatedList";
import EntityTile from "../rooms/EntityTile";
import BaseAvatar from "../avatars/BaseAvatar";
const AVATAR_SIZE = 30; const AVATAR_SIZE = 30;
@ -171,7 +175,7 @@ const ForwardDialog: React.FC<IProps> = ({ matrixClient: cli, event, permalinkCr
); );
}, },
getMxcAvatarUrl: () => profileInfo.avatar_url, getMxcAvatarUrl: () => profileInfo.avatar_url,
}; } as RoomMember;
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const lcQuery = query.toLowerCase(); const lcQuery = query.toLowerCase();
@ -195,6 +199,17 @@ const ForwardDialog: React.FC<IProps> = ({ matrixClient: cli, event, permalinkCr
}).match(lcQuery); }).match(lcQuery);
} }
const [truncateAt, setTruncateAt] = useState(20);
function overflowTile(overflowCount, totalCount) {
const text = _t("and %(count)s others...", { count: overflowCount });
return (
<EntityTile className="mx_EntityTile_ellipsis" avatarJsx={
<BaseAvatar url={require("../../../../res/img/ellipsis.svg")} name="..." width={36} height={36} />
} name={text} presenceState="online" suppressOnHover={true}
onClick={() => setTruncateAt(totalCount)} />
);
}
return <BaseDialog return <BaseDialog
title={_t("Forward message")} title={_t("Forward message")}
className="mx_ForwardDialog" className="mx_ForwardDialog"
@ -227,7 +242,10 @@ const ForwardDialog: React.FC<IProps> = ({ matrixClient: cli, event, permalinkCr
<AutoHideScrollbar className="mx_ForwardList_content"> <AutoHideScrollbar className="mx_ForwardList_content">
{ rooms.length > 0 ? ( { rooms.length > 0 ? (
<div className="mx_ForwardList_results"> <div className="mx_ForwardList_results">
{ rooms.map(room => <TruncatedList
truncateAt={truncateAt}
createOverflowElement={overflowTile}
getChildren={(start, end) => rooms.slice(start, end).map(room =>
<Entry <Entry
key={room.roomId} key={room.roomId}
room={room} room={room}
@ -236,6 +254,8 @@ const ForwardDialog: React.FC<IProps> = ({ matrixClient: cli, event, permalinkCr
onFinished={onFinished} onFinished={onFinished}
/>, />,
)} )}
getChildCount={() => rooms.length}
/>
</div> </div>
) : <span className="mx_ForwardList_noResults"> ) : <span className="mx_ForwardList_noResults">
{ _t("No results") } { _t("No results") }

View file

@ -32,9 +32,17 @@ import IdentityAuthClient from "../../../IdentityAuthClient";
import Modal from "../../../Modal"; import Modal from "../../../Modal";
import { humanizeTime } from "../../../utils/humanize"; import { humanizeTime } from "../../../utils/humanize";
import createRoom, { import createRoom, {
canEncryptToAllUsers, ensureDMExists, findDMForUser, privateShouldBeEncrypted, canEncryptToAllUsers,
ensureDMExists,
findDMForUser,
privateShouldBeEncrypted,
} from "../../../createRoom"; } from "../../../createRoom";
import {inviteMultipleToRoom, showCommunityInviteDialog} from "../../../RoomInvite"; import {
IInviteResult,
inviteMultipleToRoom,
showAnyInviteErrors,
showCommunityInviteDialog,
} from "../../../RoomInvite";
import { Key } from "../../../Keyboard"; import { Key } from "../../../Keyboard";
import { Action } from "../../../dispatcher/actions"; import { Action } from "../../../dispatcher/actions";
import { DefaultTagID } from "../../../stores/room-list/models"; import { DefaultTagID } from "../../../stores/room-list/models";
@ -74,10 +82,10 @@ export const KIND_CALL_TRANSFER = "call_transfer";
const INITIAL_ROOMS_SHOWN = 3; // Number of rooms to show at first const INITIAL_ROOMS_SHOWN = 3; // Number of rooms to show at first
const INCREMENT_ROOMS_SHOWN = 5; // Number of rooms to add when 'show more' is clicked const INCREMENT_ROOMS_SHOWN = 5; // Number of rooms to add when 'show more' is clicked
// This is the interface that is expected by various components in this file. It is a bit // This is the interface that is expected by various components in the Invite Dialog and RoomInvite.
// awkward because it also matches the RoomMember class from the js-sdk with some extra support // It is a bit awkward because it also matches the RoomMember class from the js-sdk with some extra support
// for 3PIDs/email addresses. // for 3PIDs/email addresses.
abstract class Member { export abstract class Member {
/** /**
* The display name of this Member. For users this should be their profile's display * The display name of this Member. For users this should be their profile's display
* name or user ID if none set. For 3PIDs this should be the 3PID address (email). * name or user ID if none set. For 3PIDs this should be the 3PID address (email).
@ -102,6 +110,7 @@ class DirectoryMember extends Member {
private readonly displayName: string; private readonly displayName: string;
private readonly avatarUrl: string; private readonly avatarUrl: string;
// eslint-disable-next-line camelcase
constructor(userDirResult: { user_id: string, display_name: string, avatar_url: string }) { constructor(userDirResult: { user_id: string, display_name: string, avatar_url: string }) {
super(); super();
this._userId = userDirResult.user_id; this._userId = userDirResult.user_id;
@ -601,19 +610,10 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
return members.map(m => ({userId: m.member.userId, user: m.member})); return members.map(m => ({userId: m.member.userId, user: m.member}));
} }
private shouldAbortAfterInviteError(result): boolean { private shouldAbortAfterInviteError(result: IInviteResult, room: Room): boolean {
const failedUsers = Object.keys(result.states).filter(a => result.states[a] === 'error'); this.setState({ busy: false });
if (failedUsers.length > 0) { const userMap = new Map<string, Member>(this.state.targets.map(member => [member.userId, member]));
console.log("Failed to invite users: ", result); return !showAnyInviteErrors(result.states, room, result.inviter, userMap);
this.setState({
busy: false,
errorText: _t("Failed to invite the following users to chat: %(csvUsers)s", {
csvUsers: failedUsers.join(", "),
}),
});
return true; // abort
}
return false;
} }
private convertFilter(): Member[] { private convertFilter(): Member[] {
@ -731,7 +731,7 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
try { try {
const result = await inviteMultipleToRoom(this.props.roomId, targetIds) const result = await inviteMultipleToRoom(this.props.roomId, targetIds)
CountlyAnalytics.instance.trackSendInvite(startTime, this.props.roomId, targetIds.length); CountlyAnalytics.instance.trackSendInvite(startTime, this.props.roomId, targetIds.length);
if (!this.shouldAbortAfterInviteError(result)) { // handles setting error message too if (!this.shouldAbortAfterInviteError(result, room)) { // handles setting error message too
this.props.onFinished(); this.props.onFinished();
} }

View file

@ -63,7 +63,8 @@ export default class ModalWidgetDialog extends React.PureComponent<IProps, IStat
private appFrame: React.RefObject<HTMLIFrameElement> = React.createRef(); private appFrame: React.RefObject<HTMLIFrameElement> = React.createRef();
state: IState = { state: IState = {
disabledButtonIds: [], disabledButtonIds: (this.props.widgetDefinition.buttons || []).filter(b => b.disabled)
.map(b => b.id),
}; };
constructor(props) { constructor(props) {

View file

@ -1,149 +0,0 @@
/*
Copyright 2019 Michael Telatynski <7t3chguy@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.
*/
import React, {PureComponent} from 'react';
import * as sdk from '../../../index';
import { _t } from '../../../languageHandler';
import PropTypes from "prop-types";
import {MatrixEvent} from "matrix-js-sdk/src/models/event";
import {MatrixClientPeg} from "../../../MatrixClientPeg";
import SdkConfig from '../../../SdkConfig';
import Markdown from '../../../Markdown';
import {replaceableComponent} from "../../../utils/replaceableComponent";
/*
* A dialog for reporting an event.
*/
@replaceableComponent("views.dialogs.ReportEventDialog")
export default class ReportEventDialog extends PureComponent {
static propTypes = {
mxEvent: PropTypes.instanceOf(MatrixEvent).isRequired,
onFinished: PropTypes.func.isRequired,
};
constructor(props) {
super(props);
this.state = {
reason: "",
busy: false,
err: null,
};
}
_onReasonChange = ({target: {value: reason}}) => {
this.setState({ reason });
};
_onCancel = () => {
this.props.onFinished(false);
};
_onSubmit = async () => {
if (!this.state.reason || !this.state.reason.trim()) {
this.setState({
err: _t("Please fill why you're reporting."),
});
return;
}
this.setState({
busy: true,
err: null,
});
try {
const ev = this.props.mxEvent;
await MatrixClientPeg.get().reportEvent(ev.getRoomId(), ev.getId(), -100, this.state.reason.trim());
this.props.onFinished(true);
} catch (e) {
this.setState({
busy: false,
err: e.message,
});
}
};
render() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
const Loader = sdk.getComponent('elements.Spinner');
const Field = sdk.getComponent('elements.Field');
let error = null;
if (this.state.err) {
error = <div className="error">
{this.state.err}
</div>;
}
let progress = null;
if (this.state.busy) {
progress = (
<div className="progress">
<Loader />
</div>
);
}
const adminMessageMD =
SdkConfig.get().reportEvent &&
SdkConfig.get().reportEvent.adminMessageMD;
let adminMessage;
if (adminMessageMD) {
const html = new Markdown(adminMessageMD).toHTML({ externalLinks: true });
adminMessage = <p dangerouslySetInnerHTML={{ __html: html }} />;
}
return (
<BaseDialog
className="mx_BugReportDialog"
onFinished={this.props.onFinished}
title={_t('Report Content to Your Homeserver Administrator')}
contentId='mx_ReportEventDialog'
>
<div className="mx_ReportEventDialog" id="mx_ReportEventDialog">
<p>
{
_t("Reporting this message will send its unique 'event ID' to the administrator of " +
"your homeserver. If messages in this room are encrypted, your homeserver " +
"administrator will not be able to read the message text or view any files or images.")
}
</p>
{adminMessage}
<Field
className="mx_ReportEventDialog_reason"
element="textarea"
label={_t("Reason")}
rows={5}
onChange={this._onReasonChange}
value={this.state.reason}
disabled={this.state.busy}
/>
{progress}
{error}
</div>
<DialogButtons
primaryButton={_t("Send report")}
onPrimaryButtonClick={this._onSubmit}
focus={true}
onCancel={this._onCancel}
disabled={this.state.busy}
/>
</BaseDialog>
);
}
}

View file

@ -0,0 +1,445 @@
/*
Copyright 2019 Michael Telatynski <7t3chguy@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.
*/
import React from 'react';
import * as sdk from '../../../index';
import { _t } from '../../../languageHandler';
import { ensureDMExists } from "../../../createRoom";
import { IDialogProps } from "./IDialogProps";
import {MatrixEvent} from "matrix-js-sdk/src/models/event";
import {MatrixClientPeg} from "../../../MatrixClientPeg";
import SdkConfig from '../../../SdkConfig';
import Markdown from '../../../Markdown';
import {replaceableComponent} from "../../../utils/replaceableComponent";
import SettingsStore from "../../../settings/SettingsStore";
import StyledRadioButton from "../elements/StyledRadioButton";
interface IProps extends IDialogProps {
mxEvent: MatrixEvent;
}
interface IState {
// A free-form text describing the abuse.
reason: string;
busy: boolean;
err?: string;
// If we know it, the nature of the abuse, as specified by MSC3215.
nature?: EXTENDED_NATURE;
}
const MODERATED_BY_STATE_EVENT_TYPE = [
"org.matrix.msc3215.room.moderation.moderated_by",
/**
* Unprefixed state event. Not ready for prime time.
*
* "m.room.moderation.moderated_by"
*/
];
const ABUSE_EVENT_TYPE = "org.matrix.msc3215.abuse.report";
// Standard abuse natures.
enum NATURE {
DISAGREEMENT = "org.matrix.msc3215.abuse.nature.disagreement",
TOXIC = "org.matrix.msc3215.abuse.nature.toxic",
ILLEGAL = "org.matrix.msc3215.abuse.nature.illegal",
SPAM = "org.matrix.msc3215.abuse.nature.spam",
OTHER = "org.matrix.msc3215.abuse.nature.other",
}
enum NON_STANDARD_NATURE {
// Non-standard abuse nature.
// It should never leave the client - we use it to fallback to
// server-wide abuse reporting.
ADMIN = "non-standard.abuse.nature.admin"
}
type EXTENDED_NATURE = NATURE | NON_STANDARD_NATURE;
type Moderation = {
// The id of the moderation room.
moderationRoomId: string;
// The id of the bot in charge of forwarding abuse reports to the moderation room.
moderationBotUserId: string;
}
/*
* A dialog for reporting an event.
*
* The actual content of the dialog will depend on two things:
*
* 1. Is `feature_report_to_moderators` enabled?
* 2. Does the room support moderation as per MSC3215, i.e. is there
* a well-formed state event `m.room.moderation.moderated_by`
* /`org.matrix.msc3215.room.moderation.moderated_by`?
*/
@replaceableComponent("views.dialogs.ReportEventDialog")
export default class ReportEventDialog extends React.Component<IProps, IState> {
// If the room supports moderation, the moderation information.
private moderation?: Moderation;
constructor(props: IProps) {
super(props);
let moderatedByRoomId = null;
let moderatedByUserId = null;
if (SettingsStore.getValue("feature_report_to_moderators")) {
// The client supports reporting to moderators.
// Does the room support it, too?
// Extract state events to determine whether we should display
const client = MatrixClientPeg.get();
const room = client.getRoom(props.mxEvent.getRoomId());
for (const stateEventType of MODERATED_BY_STATE_EVENT_TYPE) {
const stateEvent = room.currentState.getStateEvents(stateEventType, stateEventType);
if (!stateEvent) {
continue;
}
if (Array.isArray(stateEvent)) {
// Internal error.
throw new TypeError(`getStateEvents(${stateEventType}, ${stateEventType}) ` +
"should return at most one state event");
}
const event = stateEvent.event;
if (!("content" in event) || typeof event["content"] != "object") {
// The room is improperly configured.
// Display this debug message for the sake of moderators.
console.debug("Moderation error", "state event", stateEventType,
"should have an object field `content`, got", event);
continue;
}
const content = event["content"];
if (!("room_id" in content) || typeof content["room_id"] != "string") {
// The room is improperly configured.
// Display this debug message for the sake of moderators.
console.debug("Moderation error", "state event", stateEventType,
"should have a string field `content.room_id`, got", event);
continue;
}
if (!("user_id" in content) || typeof content["user_id"] != "string") {
// The room is improperly configured.
// Display this debug message for the sake of moderators.
console.debug("Moderation error", "state event", stateEventType,
"should have a string field `content.user_id`, got", event);
continue;
}
moderatedByRoomId = content["room_id"];
moderatedByUserId = content["user_id"];
}
if (moderatedByRoomId && moderatedByUserId) {
// The room supports moderation.
this.moderation = {
moderationRoomId: moderatedByRoomId,
moderationBotUserId: moderatedByUserId,
};
}
}
this.state = {
// A free-form text describing the abuse.
reason: "",
busy: false,
err: null,
// If specified, the nature of the abuse, as specified by MSC3215.
nature: null,
};
}
// The user has written down a freeform description of the abuse.
private onReasonChange = ({target: {value: reason}}): void => {
this.setState({ reason });
};
// The user has clicked on a nature.
private onNatureChosen = (e: React.FormEvent<HTMLInputElement>): void => {
this.setState({ nature: e.currentTarget.value as EXTENDED_NATURE});
};
// The user has clicked "cancel".
private onCancel = (): void => {
this.props.onFinished(false);
};
// The user has clicked "submit".
private onSubmit = async () => {
let reason = this.state.reason || "";
reason = reason.trim();
if (this.moderation) {
// This room supports moderation.
// We need a nature.
// If the nature is `NATURE.OTHER` or `NON_STANDARD_NATURE.ADMIN`, we also need a `reason`.
if (!this.state.nature ||
((this.state.nature == NATURE.OTHER || this.state.nature == NON_STANDARD_NATURE.ADMIN)
&& !reason)
) {
this.setState({
err: _t("Please fill why you're reporting."),
});
return;
}
} else {
// This room does not support moderation.
// We need a `reason`.
if (!reason) {
this.setState({
err: _t("Please fill why you're reporting."),
});
return;
}
}
this.setState({
busy: true,
err: null,
});
try {
const client = MatrixClientPeg.get();
const ev = this.props.mxEvent;
if (this.moderation && this.state.nature != NON_STANDARD_NATURE.ADMIN) {
const nature: NATURE = this.state.nature;
// Report to moderators through to the dedicated bot,
// as configured in the room's state events.
const dmRoomId = await ensureDMExists(client, this.moderation.moderationBotUserId);
await client.sendEvent(dmRoomId, ABUSE_EVENT_TYPE, {
event_id: ev.getId(),
room_id: ev.getRoomId(),
moderated_by_id: this.moderation.moderationRoomId,
nature,
reporter: client.getUserId(),
comment: this.state.reason.trim(),
});
} else {
// Report to homeserver admin through the dedicated Matrix API.
await client.reportEvent(ev.getRoomId(), ev.getId(), -100, this.state.reason.trim());
}
this.props.onFinished(true);
} catch (e) {
this.setState({
busy: false,
err: e.message,
});
}
};
render() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
const Loader = sdk.getComponent('elements.Spinner');
const Field = sdk.getComponent('elements.Field');
let error = null;
if (this.state.err) {
error = <div className="error">
{this.state.err}
</div>;
}
let progress = null;
if (this.state.busy) {
progress = (
<div className="progress">
<Loader />
</div>
);
}
const adminMessageMD =
SdkConfig.get().reportEvent &&
SdkConfig.get().reportEvent.adminMessageMD;
let adminMessage;
if (adminMessageMD) {
const html = new Markdown(adminMessageMD).toHTML({ externalLinks: true });
adminMessage = <p dangerouslySetInnerHTML={{ __html: html }} />;
}
if (this.moderation) {
// Display report-to-moderator dialog.
// We let the user pick a nature.
const client = MatrixClientPeg.get();
const homeServerName = SdkConfig.get()["validated_server_config"].hsName;
let subtitle;
switch (this.state.nature) {
case NATURE.DISAGREEMENT:
subtitle = _t("What this user is writing is wrong.\n" +
"This will be reported to the room moderators.");
break;
case NATURE.TOXIC:
subtitle = _t("This user is displaying toxic behaviour, " +
"for instance by insulting other users or sharing " +
" adult-only content in a family-friendly room " +
" or otherwise violating the rules of this room.\n" +
"This will be reported to the room moderators.");
break;
case NATURE.ILLEGAL:
subtitle = _t("This user is displaying illegal behaviour, " +
"for instance by doxing people or threatening violence.\n" +
"This will be reported to the room moderators who may escalate this to legal authorities.");
break;
case NATURE.SPAM:
subtitle = _t("This user is spamming the room with ads, links to ads or to propaganda.\n" +
"This will be reported to the room moderators.");
break;
case NON_STANDARD_NATURE.ADMIN:
if (client.isRoomEncrypted(this.props.mxEvent.getRoomId())) {
subtitle = _t("This room is dedicated to illegal or toxic content " +
"or the moderators fail to moderate illegal or toxic content.\n" +
"This will be reported to the administrators of %(homeserver)s. " +
"The administrators will NOT be able to read the encrypted content of this room.",
{ homeserver: homeServerName });
} else {
subtitle = _t("This room is dedicated to illegal or toxic content " +
"or the moderators fail to moderate illegal or toxic content.\n" +
" This will be reported to the administrators of %(homeserver)s.",
{ homeserver: homeServerName });
}
break;
case NATURE.OTHER:
subtitle = _t("Any other reason. Please describe the problem.\n" +
"This will be reported to the room moderators.");
break;
default:
subtitle = _t("Please pick a nature and describe what makes this message abusive.");
break;
}
return (
<BaseDialog
className="mx_ReportEventDialog"
onFinished={this.props.onFinished}
title={_t('Report Content')}
contentId='mx_ReportEventDialog'
>
<div>
<StyledRadioButton
name = "nature"
value = { NATURE.DISAGREEMENT }
checked = { this.state.nature == NATURE.DISAGREEMENT }
onChange = { this.onNatureChosen }
>
{_t('Disagree')}
</StyledRadioButton>
<StyledRadioButton
name = "nature"
value = { NATURE.TOXIC }
checked = { this.state.nature == NATURE.TOXIC }
onChange = { this.onNatureChosen }
>
{_t('Toxic Behaviour')}
</StyledRadioButton>
<StyledRadioButton
name = "nature"
value = { NATURE.ILLEGAL }
checked = { this.state.nature == NATURE.ILLEGAL }
onChange = { this.onNatureChosen }
>
{_t('Illegal Content')}
</StyledRadioButton>
<StyledRadioButton
name = "nature"
value = { NATURE.SPAM }
checked = { this.state.nature == NATURE.SPAM }
onChange = { this.onNatureChosen }
>
{_t('Spam or propaganda')}
</StyledRadioButton>
<StyledRadioButton
name = "nature"
value = { NON_STANDARD_NATURE.ADMIN }
checked = { this.state.nature == NON_STANDARD_NATURE.ADMIN }
onChange = { this.onNatureChosen }
>
{_t('Report the entire room')}
</StyledRadioButton>
<StyledRadioButton
name = "nature"
value = { NATURE.OTHER }
checked = { this.state.nature == NATURE.OTHER }
onChange = { this.onNatureChosen }
>
{_t('Other')}
</StyledRadioButton>
<p>
{subtitle}
</p>
<Field
className="mx_ReportEventDialog_reason"
element="textarea"
label={_t("Reason")}
rows={5}
onChange={this.onReasonChange}
value={this.state.reason}
disabled={this.state.busy}
/>
{progress}
{error}
</div>
<DialogButtons
primaryButton={_t("Send report")}
onPrimaryButtonClick={this.onSubmit}
focus={true}
onCancel={this.onCancel}
disabled={this.state.busy}
/>
</BaseDialog>
);
}
// Report to homeserver admin.
// Currently, the API does not support natures.
return (
<BaseDialog
className="mx_ReportEventDialog"
onFinished={this.props.onFinished}
title={_t('Report Content to Your Homeserver Administrator')}
contentId='mx_ReportEventDialog'
>
<div className="mx_ReportEventDialog" id="mx_ReportEventDialog">
<p>
{
_t("Reporting this message will send its unique 'event ID' to the administrator of " +
"your homeserver. If messages in this room are encrypted, your homeserver " +
"administrator will not be able to read the message text or view any files " +
"or images.")
}
</p>
{adminMessage}
<Field
className="mx_ReportEventDialog_reason"
element="textarea"
label={_t("Reason")}
rows={5}
onChange={this.onReasonChange}
value={this.state.reason}
disabled={this.state.busy}
/>
{progress}
{error}
</div>
<DialogButtons
primaryButton={_t("Send report")}
onPrimaryButtonClick={this.onSubmit}
focus={true}
onCancel={this.onCancel}
disabled={this.state.busy}
/>
</BaseDialog>
);
}
}

View file

@ -108,7 +108,10 @@ export default class RoomSettingsDialog extends React.Component<IProps> {
ROOM_ADVANCED_TAB, ROOM_ADVANCED_TAB,
_td("Advanced"), _td("Advanced"),
"mx_RoomSettingsDialog_warningIcon", "mx_RoomSettingsDialog_warningIcon",
<AdvancedRoomSettingsTab roomId={this.props.roomId} closeSettingsFn={this.props.onFinished} />, <AdvancedRoomSettingsTab
roomId={this.props.roomId}
closeSettingsFn={() => this.props.onFinished(true)}
/>,
)); ));
} }

View file

@ -14,24 +14,27 @@ 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, { useMemo } from 'react';
import { Room } from "matrix-js-sdk/src/models/room"; import { Room } from "matrix-js-sdk/src/models/room";
import { MatrixClient } from "matrix-js-sdk/src/client"; import { MatrixClient } from "matrix-js-sdk/src/client";
import {EventType} from "matrix-js-sdk/src/@types/event";
import {_t} from '../../../languageHandler'; import { _t, _td } from '../../../languageHandler';
import { IDialogProps } from "./IDialogProps"; import { IDialogProps } from "./IDialogProps";
import BaseDialog from "./BaseDialog"; import BaseDialog from "./BaseDialog";
import DevtoolsDialog from "./DevtoolsDialog";
import SpaceBasicSettings from '../spaces/SpaceBasicSettings';
import {getTopic} from "../elements/RoomTopic";
import {avatarUrlForRoom} from "../../../Avatar";
import ToggleSwitch from "../elements/ToggleSwitch";
import AccessibleButton from "../elements/AccessibleButton";
import Modal from "../../../Modal";
import defaultDispatcher from "../../../dispatcher/dispatcher"; import defaultDispatcher from "../../../dispatcher/dispatcher";
import { useDispatcher } from "../../../hooks/useDispatcher"; import { useDispatcher } from "../../../hooks/useDispatcher";
import {SpaceFeedbackPrompt} from "../../structures/SpaceRoomView"; import TabbedView, { Tab } from "../../structures/TabbedView";
import SpaceSettingsGeneralTab from '../spaces/SpaceSettingsGeneralTab';
import SpaceSettingsVisibilityTab from "../spaces/SpaceSettingsVisibilityTab";
import SettingsStore from "../../../settings/SettingsStore";
import { UIFeature } from "../../../settings/UIFeature";
import AdvancedRoomSettingsTab from "../settings/tabs/room/AdvancedRoomSettingsTab";
export enum SpaceSettingsTab {
General = "SPACE_GENERAL_TAB",
Visibility = "SPACE_VISIBILITY_TAB",
Advanced = "SPACE_ADVANCED_TAB",
}
interface IProps extends IDialogProps { interface IProps extends IDialogProps {
matrixClient: MatrixClient; matrixClient: MatrixClient;
@ -45,63 +48,30 @@ const SpaceSettingsDialog: React.FC<IProps> = ({ matrixClient: cli, space, onFin
} }
}); });
const [busy, setBusy] = useState(false); const tabs = useMemo(() => {
const [error, setError] = useState(""); return [
new Tab(
const userId = cli.getUserId(); SpaceSettingsTab.General,
_td("General"),
const [newAvatar, setNewAvatar] = useState<File>(null); // undefined means to remove avatar "mx_SpaceSettingsDialog_generalIcon",
const canSetAvatar = space.currentState.maySendStateEvent(EventType.RoomAvatar, userId); <SpaceSettingsGeneralTab matrixClient={cli} space={space} onFinished={onFinished} />,
const avatarChanged = newAvatar !== null; ),
new Tab(
const [name, setName] = useState<string>(space.name); SpaceSettingsTab.Visibility,
const canSetName = space.currentState.maySendStateEvent(EventType.RoomName, userId); _td("Visibility"),
const nameChanged = name !== space.name; "mx_SpaceSettingsDialog_visibilityIcon",
<SpaceSettingsVisibilityTab matrixClient={cli} space={space} />,
const currentTopic = getTopic(space); ),
const [topic, setTopic] = useState<string>(currentTopic); SettingsStore.getValue(UIFeature.AdvancedSettings)
const canSetTopic = space.currentState.maySendStateEvent(EventType.RoomTopic, userId); ? new Tab(
const topicChanged = topic !== currentTopic; SpaceSettingsTab.Advanced,
_td("Advanced"),
const currentJoinRule = space.getJoinRule(); "mx_RoomSettingsDialog_warningIcon",
const [joinRule, setJoinRule] = useState(currentJoinRule); <AdvancedRoomSettingsTab roomId={space.roomId} closeSettingsFn={onFinished} />,
const canSetJoinRule = space.currentState.maySendStateEvent(EventType.RoomJoinRules, userId); )
const joinRuleChanged = joinRule !== currentJoinRule; : null,
].filter(Boolean);
const onSave = async () => { }, [cli, space, onFinished]);
setBusy(true);
const promises = [];
if (avatarChanged) {
if (newAvatar) {
promises.push(cli.sendStateEvent(space.roomId, EventType.RoomAvatar, {
url: await cli.uploadContent(newAvatar),
}, ""));
} else {
promises.push(cli.sendStateEvent(space.roomId, EventType.RoomAvatar, {}, ""));
}
}
if (nameChanged) {
promises.push(cli.setRoomName(space.roomId, name));
}
if (topicChanged) {
promises.push(cli.setRoomTopic(space.roomId, topic));
}
if (joinRuleChanged) {
promises.push(cli.sendStateEvent(space.roomId, EventType.RoomJoinRules, { join_rule: joinRule }, ""));
}
const results = await Promise.allSettled(promises);
setBusy(false);
const failures = results.filter(r => r.status === "rejected");
if (failures.length > 0) {
console.error("Failed to save space settings: ", failures);
setError(_t("Failed to save space settings."));
}
};
return <BaseDialog return <BaseDialog
title={_t("Space settings")} title={_t("Space settings")}
@ -110,61 +80,14 @@ const SpaceSettingsDialog: React.FC<IProps> = ({ matrixClient: cli, space, onFin
onFinished={onFinished} onFinished={onFinished}
fixedWidth={false} fixedWidth={false}
> >
<div className="mx_SpaceSettingsDialog_content" id="mx_SpaceSettingsDialog"> <div
<div>{ _t("Edit settings relating to your space.") }</div> className="mx_SpaceSettingsDialog_content"
id="mx_SpaceSettingsDialog"
{ error && <div className="mx_SpaceRoomView_errorText">{ error }</div> } title={_t("Settings - %(spaceName)s", { spaceName: space.name })}
<SpaceFeedbackPrompt onClick={() => onFinished(false)} />
<SpaceBasicSettings
avatarUrl={avatarUrlForRoom(space, 80, 80, "crop")}
avatarDisabled={busy || !canSetAvatar}
setAvatar={setNewAvatar}
name={name}
nameDisabled={busy || !canSetName}
setName={setName}
topic={topic}
topicDisabled={busy || !canSetTopic}
setTopic={setTopic}
/>
<div>
{ _t("Make this space private") }
<ToggleSwitch
checked={joinRule !== "public"}
onChange={checked => setJoinRule(checked ? "invite" : "public")}
disabled={!canSetJoinRule}
aria-label={_t("Make this space private")}
/>
</div>
<AccessibleButton
kind="danger"
onClick={() => {
defaultDispatcher.dispatch({
action: "leave_room",
room_id: space.roomId,
});
}}
> >
{ _t("Leave Space") } <TabbedView tabs={tabs} />
</AccessibleButton>
<div className="mx_SpaceSettingsDialog_buttons">
<AccessibleButton onClick={() => Modal.createDialog(DevtoolsDialog, {roomId: space.roomId})}>
{ _t("View dev tools") }
</AccessibleButton>
<AccessibleButton onClick={onFinished} disabled={busy} kind="link">
{ _t("Cancel") }
</AccessibleButton>
<AccessibleButton onClick={onSave} disabled={busy} kind="primary">
{ busy ? _t("Saving...") : _t("Save Changes") }
</AccessibleButton>
</div>
</div> </div>
</BaseDialog>; </BaseDialog>;
}; };
export default SpaceSettingsDialog; export default SpaceSettingsDialog;

View file

@ -62,6 +62,8 @@ export default function AccessibleButton({
disabled, disabled,
inputRef, inputRef,
className, className,
onKeyDown,
onKeyUp,
...restProps ...restProps
}: IProps) { }: IProps) {
const newProps: IAccessibleButtonProps = restProps; const newProps: IAccessibleButtonProps = restProps;
@ -83,6 +85,8 @@ export default function AccessibleButton({
if (e.key === Key.SPACE) { if (e.key === Key.SPACE) {
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
} else {
onKeyDown?.(e);
} }
}; };
newProps.onKeyUp = (e) => { newProps.onKeyUp = (e) => {
@ -94,6 +98,8 @@ export default function AccessibleButton({
if (e.key === Key.ENTER) { if (e.key === Key.ENTER) {
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
} else {
onKeyUp?.(e);
} }
}; };
} }

View file

@ -20,7 +20,7 @@ import PropTypes from 'prop-types';
import classNames from 'classnames'; import classNames from 'classnames';
import * as sdk from "../../../index"; import * as sdk from "../../../index";
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import { UserAddressType } from '../../../UserAddress.js'; import { UserAddressType } from '../../../UserAddress';
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import { mediaFromMxc } from "../../../customisations/Media"; import { mediaFromMxc } from "../../../customisations/Media";

View file

@ -18,7 +18,6 @@ limitations under the License.
import TagTile from './TagTile'; import TagTile from './TagTile';
import React from 'react'; import React from 'react';
import { Draggable } from 'react-beautiful-dnd';
import { ContextMenu, toRightOf, useContextMenu } from "../../structures/ContextMenu"; import { ContextMenu, toRightOf, useContextMenu } from "../../structures/ContextMenu";
import * as sdk from '../../../index'; import * as sdk from '../../../index';
@ -31,32 +30,17 @@ export default function DNDTagTile(props) {
const TagTileContextMenu = sdk.getComponent('context_menus.TagTileContextMenu'); const TagTileContextMenu = sdk.getComponent('context_menus.TagTileContextMenu');
contextMenu = ( contextMenu = (
<ContextMenu {...toRightOf(elementRect)} onFinished={closeMenu}> <ContextMenu {...toRightOf(elementRect)} onFinished={closeMenu}>
<TagTileContextMenu tag={props.tag} onFinished={closeMenu} /> <TagTileContextMenu tag={props.tag} onFinished={closeMenu} index={props.index} />
</ContextMenu> </ContextMenu>
); );
} }
return <div> return <>
<Draggable
key={props.tag}
draggableId={props.tag}
index={props.index}
type="draggable-TagTile"
>
{(provided, snapshot) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
>
<TagTile <TagTile
{...props} {...props}
contextMenuButtonRef={handle} contextMenuButtonRef={handle}
menuDisplayed={menuDisplayed} menuDisplayed={menuDisplayed}
openMenu={openMenu} openMenu={openMenu}
/> />
</div>
)}
</Draggable>
{contextMenu} {contextMenu}
</div>; </>;
} }

View file

@ -14,10 +14,14 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React from "react";
import EventIndexPeg from "../../../indexing/EventIndexPeg"; import EventIndexPeg from "../../../indexing/EventIndexPeg";
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import SdkConfig from "../../../SdkConfig"; import SdkConfig from "../../../SdkConfig";
import React from "react"; import dis from "../../../dispatcher/dispatcher";
import { Action } from "../../../dispatcher/actions";
import { UserTab } from "../dialogs/UserSettingsDialog";
export enum WarningKind { export enum WarningKind {
Files, Files,
@ -33,6 +37,22 @@ export default function DesktopBuildsNotice({isRoomEncrypted, kind}: IProps) {
if (!isRoomEncrypted) return null; if (!isRoomEncrypted) return null;
if (EventIndexPeg.get()) return null; if (EventIndexPeg.get()) return null;
if (EventIndexPeg.error) {
return <>
{_t("Message search initialisation failed, check <a>your settings</a> for more information", {}, {
a: sub => (<a onClick={(evt) => {
evt.preventDefault();
dis.dispatch({
action: Action.ViewUserSettings,
initialTabId: UserTab.Security,
});
}}>
{sub}
</a>),
})}
</>;
}
const {desktopBuilds, brand} = SdkConfig.get(); const {desktopBuilds, brand} = SdkConfig.get();
let text = null; let text = null;

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2017, 2019 New Vector Ltd. Copyright 2017-2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -14,43 +14,43 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React from 'react'; import React from "react";
import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import Field from "./Field"; import Field from "./Field";
import AccessibleButton from "./AccessibleButton"; import AccessibleButton from "./AccessibleButton";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
export class EditableItem extends React.Component { interface IItemProps {
static propTypes = { index?: number;
index: PropTypes.number, value?: string;
value: PropTypes.string, onRemove?(index: number): void;
onRemove: PropTypes.func,
};
constructor() {
super();
this.state = {
verifyRemove: false,
};
} }
_onRemove = (e) => { interface IItemState {
verifyRemove: boolean;
}
export class EditableItem extends React.Component<IItemProps, IItemState> {
public state = {
verifyRemove: false,
};
private onRemove = (e) => {
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
this.setState({ verifyRemove: true }); this.setState({ verifyRemove: true });
}; };
_onDontRemove = (e) => { private onDontRemove = (e) => {
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
this.setState({ verifyRemove: false }); this.setState({ verifyRemove: false });
}; };
_onActuallyRemove = (e) => { private onActuallyRemove = (e) => {
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
@ -66,14 +66,14 @@ export class EditableItem extends React.Component {
{_t("Are you sure?")} {_t("Are you sure?")}
</span> </span>
<AccessibleButton <AccessibleButton
onClick={this._onActuallyRemove} onClick={this.onActuallyRemove}
kind="primary_sm" kind="primary_sm"
className="mx_EditableItem_confirmBtn" className="mx_EditableItem_confirmBtn"
> >
{_t("Yes")} {_t("Yes")}
</AccessibleButton> </AccessibleButton>
<AccessibleButton <AccessibleButton
onClick={this._onDontRemove} onClick={this.onDontRemove}
kind="danger_sm" kind="danger_sm"
className="mx_EditableItem_confirmBtn" className="mx_EditableItem_confirmBtn"
> >
@ -85,58 +85,67 @@ export class EditableItem extends React.Component {
return ( return (
<div className="mx_EditableItem"> <div className="mx_EditableItem">
<div onClick={this._onRemove} className="mx_EditableItem_delete" title={_t("Remove")} role="button" /> <div onClick={this.onRemove} className="mx_EditableItem_delete" title={_t("Remove")} role="button" />
<span className="mx_EditableItem_item">{this.props.value}</span> <span className="mx_EditableItem_item">{this.props.value}</span>
</div> </div>
); );
} }
} }
interface IProps {
id: string;
items: string[];
itemsLabel?: string;
noItemsLabel?: string;
placeholder?: string;
newItem?: string;
canEdit?: boolean;
canRemove?: boolean;
suggestionsListId?: string;
onItemAdded?(item: string): void;
onItemRemoved?(index: number): void;
onNewItemChanged?(item: string): void;
}
@replaceableComponent("views.elements.EditableItemList") @replaceableComponent("views.elements.EditableItemList")
export default class EditableItemList extends React.Component { export default class EditableItemList<P = {}> extends React.PureComponent<IProps & P> {
static propTypes = { protected onItemAdded = (e) => {
id: PropTypes.string.isRequired,
items: PropTypes.arrayOf(PropTypes.string).isRequired,
itemsLabel: PropTypes.string,
noItemsLabel: PropTypes.string,
placeholder: PropTypes.string,
newItem: PropTypes.string,
onItemAdded: PropTypes.func,
onItemRemoved: PropTypes.func,
onNewItemChanged: PropTypes.func,
canEdit: PropTypes.bool,
canRemove: PropTypes.bool,
};
_onItemAdded = (e) => {
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
if (this.props.onItemAdded) this.props.onItemAdded(this.props.newItem); if (this.props.onItemAdded) this.props.onItemAdded(this.props.newItem);
}; };
_onItemRemoved = (index) => { protected onItemRemoved = (index) => {
if (this.props.onItemRemoved) this.props.onItemRemoved(index); if (this.props.onItemRemoved) this.props.onItemRemoved(index);
}; };
_onNewItemChanged = (e) => { protected onNewItemChanged = (e) => {
if (this.props.onNewItemChanged) this.props.onNewItemChanged(e.target.value); if (this.props.onNewItemChanged) this.props.onNewItemChanged(e.target.value);
}; };
_renderNewItemField() { protected renderNewItemField() {
return ( return (
<form <form
onSubmit={this._onItemAdded} onSubmit={this.onItemAdded}
autoComplete="off" autoComplete="off"
noValidate={true} noValidate={true}
className="mx_EditableItemList_newItem" className="mx_EditableItemList_newItem"
> >
<Field label={this.props.placeholder} type="text" <Field
autoComplete="off" value={this.props.newItem || ""} onChange={this._onNewItemChanged} label={this.props.placeholder}
list={this.props.suggestionsListId} /> type="text"
<AccessibleButton onClick={this._onItemAdded} kind="primary" type="submit" disabled={!this.props.newItem}> autoComplete="off"
value={this.props.newItem || ""}
onChange={this.onNewItemChanged}
list={this.props.suggestionsListId}
/>
<AccessibleButton
onClick={this.onItemAdded}
kind="primary"
type="submit"
disabled={!this.props.newItem}
>
{ _t("Add") } { _t("Add") }
</AccessibleButton> </AccessibleButton>
</form> </form>
@ -153,19 +162,21 @@ export default class EditableItemList extends React.Component {
key={item} key={item}
index={index} index={index}
value={item} value={item}
onRemove={this._onItemRemoved} onRemove={this.onItemRemoved}
/>; />;
}); });
const editableItemsSection = this.props.canRemove ? editableItems : <ul>{editableItems}</ul>; const editableItemsSection = this.props.canRemove ? editableItems : <ul>{editableItems}</ul>;
const label = this.props.items.length > 0 ? this.props.itemsLabel : this.props.noItemsLabel; const label = this.props.items.length > 0 ? this.props.itemsLabel : this.props.noItemsLabel;
return (<div className="mx_EditableItemList"> return (
<div className="mx_EditableItemList">
<div className="mx_EditableItemList_label"> <div className="mx_EditableItemList_label">
{ label } { label }
</div> </div>
{ editableItemsSection } { editableItemsSection }
{ this.props.canEdit ? this._renderNewItemField() : <div /> } { this.props.canEdit ? this.renderNewItemField() : <div /> }
</div>); </div>
);
} }
} }

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2019 The Matrix.org Foundation C.I.C. Copyright 2019 - 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -14,21 +14,27 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React from 'react'; import React, { ErrorInfo } from 'react';
import * as sdk from '../../../index';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import { MatrixClientPeg } from '../../../MatrixClientPeg'; import { MatrixClientPeg } from '../../../MatrixClientPeg';
import PlatformPeg from '../../../PlatformPeg'; import PlatformPeg from '../../../PlatformPeg';
import Modal from '../../../Modal'; import Modal from '../../../Modal';
import SdkConfig from "../../../SdkConfig"; import SdkConfig from "../../../SdkConfig";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import BugReportDialog from '../dialogs/BugReportDialog';
import AccessibleButton from './AccessibleButton';
interface IState {
error: Error;
}
/** /**
* This error boundary component can be used to wrap large content areas and * This error boundary component can be used to wrap large content areas and
* catch exceptions during rendering in the component tree below them. * catch exceptions during rendering in the component tree below them.
*/ */
@replaceableComponent("views.elements.ErrorBoundary") @replaceableComponent("views.elements.ErrorBoundary")
export default class ErrorBoundary extends React.PureComponent { export default class ErrorBoundary extends React.PureComponent<{}, IState> {
constructor(props) { constructor(props) {
super(props); super(props);
@ -37,13 +43,13 @@ export default class ErrorBoundary extends React.PureComponent {
}; };
} }
static getDerivedStateFromError(error) { static getDerivedStateFromError(error: Error): Partial<IState> {
// Side effects are not permitted here, so we only update the state so // Side effects are not permitted here, so we only update the state so
// that the next render shows an error message. // that the next render shows an error message.
return { error }; return { error };
} }
componentDidCatch(error, { componentStack }) { componentDidCatch(error: Error, { componentStack }: ErrorInfo): void {
// Browser consoles are better at formatting output when native errors are passed // Browser consoles are better at formatting output when native errors are passed
// in their own `console.error` invocation. // in their own `console.error` invocation.
console.error(error); console.error(error);
@ -53,7 +59,7 @@ export default class ErrorBoundary extends React.PureComponent {
); );
} }
_onClearCacheAndReload = () => { private onClearCacheAndReload = (): void => {
if (!PlatformPeg.get()) return; if (!PlatformPeg.get()) return;
MatrixClientPeg.get().stopClient(); MatrixClientPeg.get().stopClient();
@ -62,11 +68,7 @@ export default class ErrorBoundary extends React.PureComponent {
}); });
}; };
_onBugReport = () => { private onBugReport = (): void => {
const BugReportDialog = sdk.getComponent("dialogs.BugReportDialog");
if (!BugReportDialog) {
return;
}
Modal.createTrackedDialog('Bug Report Dialog', '', BugReportDialog, { Modal.createTrackedDialog('Bug Report Dialog', '', BugReportDialog, {
label: 'react-soft-crash', label: 'react-soft-crash',
}); });
@ -74,7 +76,6 @@ export default class ErrorBoundary extends React.PureComponent {
render() { render() {
if (this.state.error) { if (this.state.error) {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
const newIssueUrl = "https://github.com/vector-im/element-web/issues/new"; const newIssueUrl = "https://github.com/vector-im/element-web/issues/new";
let bugReportSection; let bugReportSection;
@ -95,7 +96,7 @@ export default class ErrorBoundary extends React.PureComponent {
"the rooms or groups you have visited and the usernames of " + "the rooms or groups you have visited and the usernames of " +
"other users. They do not contain messages.", "other users. They do not contain messages.",
)}</p> )}</p>
<AccessibleButton onClick={this._onBugReport} kind='primary'> <AccessibleButton onClick={this.onBugReport} kind='primary'>
{_t("Submit debug logs")} {_t("Submit debug logs")}
</AccessibleButton> </AccessibleButton>
</React.Fragment>; </React.Fragment>;
@ -105,7 +106,7 @@ export default class ErrorBoundary extends React.PureComponent {
<div className="mx_ErrorBoundary_body"> <div className="mx_ErrorBoundary_body">
<h1>{_t("Something went wrong!")}</h1> <h1>{_t("Something went wrong!")}</h1>
{ bugReportSection } { bugReportSection }
<AccessibleButton onClick={this._onClearCacheAndReload} kind='danger'> <AccessibleButton onClick={this.onClearCacheAndReload} kind='danger'>
{_t("Clear cache and reload")} {_t("Clear cache and reload")}
</AccessibleButton> </AccessibleButton>
</div> </div>

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React, {ReactChildren, useEffect} from 'react'; import React, { ReactNode, useEffect } from 'react';
import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { RoomMember } from "matrix-js-sdk/src/models/room-member"; import { RoomMember } from "matrix-js-sdk/src/models/room-member";
@ -31,11 +31,11 @@ interface IProps {
// Whether or not to begin with state.expanded=true // Whether or not to begin with state.expanded=true
startExpanded?: boolean, startExpanded?: boolean,
// The list of room members for which to show avatars next to the summary // The list of room members for which to show avatars next to the summary
summaryMembers?: RoomMember[], summaryMembers?: RoomMember[];
// The text to show as the summary of this event list // The text to show as the summary of this event list
summaryText?: string, summaryText?: string;
// An array of EventTiles to render when expanded // An array of EventTiles to render when expanded
children: ReactChildren, children: ReactNode[];
// Called when the event list expansion is toggled // Called when the event list expansion is toggled
onToggle?(): void; onToggle?(): void;
} }

View file

@ -17,6 +17,7 @@ limitations under the License.
import React from 'react'; import React from 'react';
import classnames from 'classnames'; import classnames from 'classnames';
import { MatrixEvent } from 'matrix-js-sdk/src/models/event'; import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
import { RoomMember } from 'matrix-js-sdk/src/models/room-member';
import * as Avatar from '../../../Avatar'; import * as Avatar from '../../../Avatar';
import EventTile from '../rooms/EventTile'; import EventTile from '../rooms/EventTile';
@ -101,7 +102,8 @@ export default class EventTilePreview extends React.Component<IProps, IState> {
// Fake it more // Fake it more
event.sender = { event.sender = {
name: this.props.displayName, name: this.props.displayName || this.props.userId,
rawDisplayName: this.props.displayName,
userId: this.props.userId, userId: this.props.userId,
getAvatarUrl: (..._) => { getAvatarUrl: (..._) => {
return Avatar.avatarUrlForUser( return Avatar.avatarUrlForUser(
@ -110,7 +112,7 @@ export default class EventTilePreview extends React.Component<IProps, IState> {
); );
}, },
getMxcAvatarUrl: () => this.props.avatarUrl, getMxcAvatarUrl: () => this.props.avatarUrl,
}; } as RoomMember;
return event; return event;
} }

View file

@ -29,6 +29,11 @@ function getId() {
return `${BASE_ID}_${count++}`; return `${BASE_ID}_${count++}`;
} }
export interface IValidateOpts {
focused?: boolean;
allowEmpty?: boolean;
}
interface IProps { interface IProps {
// The field's ID, which binds the input and label together. Immutable. // The field's ID, which binds the input and label together. Immutable.
id?: string; id?: string;
@ -180,7 +185,7 @@ export default class Field extends React.PureComponent<PropShapes, IState> {
} }
}; };
public async validate({ focused, allowEmpty = true }: {focused?: boolean, allowEmpty?: boolean}) { public async validate({ focused, allowEmpty = true }: IValidateOpts) {
if (!this.props.onValidate) { if (!this.props.onValidate) {
return; return;
} }

View file

@ -1,28 +0,0 @@
/*
Copyright 2019 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 AccessibleButton from "./AccessibleButton";
export default function FormButton(props) {
const {className, label, kind, ...restProps} = props;
const newClassName = (className || "") + " mx_FormButton";
const allProps = Object.assign({}, restProps,
{className: newClassName, kind: kind || "primary", children: [label]});
return React.createElement(AccessibleButton, allProps);
}
FormButton.propTypes = AccessibleButton.propTypes;

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2019 New Vector Ltd Copyright 2019 - 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -14,34 +14,29 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React from 'react'; import React from "react";
import PropTypes from "prop-types";
import ToggleSwitch from "./ToggleSwitch"; import ToggleSwitch from "./ToggleSwitch";
import {replaceableComponent} from "../../../utils/replaceableComponent"; import {replaceableComponent} from "../../../utils/replaceableComponent";
@replaceableComponent("views.elements.LabelledToggleSwitch") interface IProps {
export default class LabelledToggleSwitch extends React.Component {
static propTypes = {
// The value for the toggle switch // The value for the toggle switch
value: PropTypes.bool.isRequired, value: boolean;
// The function to call when the value changes
onChange: PropTypes.func.isRequired,
// The translated label for the switch // The translated label for the switch
label: PropTypes.string.isRequired, label: string;
// Whether or not to disable the toggle switch // Whether or not to disable the toggle switch
disabled: PropTypes.bool, disabled?: boolean;
// True to put the toggle in front of the label // True to put the toggle in front of the label
// Default false. // Default false.
toggleInFront: PropTypes.bool, toggleInFront?: boolean;
// Additional class names to append to the switch. Optional. // Additional class names to append to the switch. Optional.
className: PropTypes.string, className?: string;
}; // The function to call when the value changes
onChange(checked: boolean): void;
}
@replaceableComponent("views.elements.LabelledToggleSwitch")
export default class LabelledToggleSwitch extends React.PureComponent<IProps> {
render() { render() {
// This is a minimal version of a SettingsFlag // This is a minimal version of a SettingsFlag

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, { ReactChildren } from 'react'; import React, { ComponentProps } from 'react';
import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { RoomMember } from "matrix-js-sdk/src/models/room-member"; import { RoomMember } from "matrix-js-sdk/src/models/room-member";
@ -26,21 +26,11 @@ import { isValid3pidInvite } from "../../../RoomInvite";
import EventListSummary from "./EventListSummary"; import EventListSummary from "./EventListSummary";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
interface IProps { interface IProps extends Omit<ComponentProps<typeof EventListSummary>, "summaryText" | "summaryMembers"> {
// An array of member events to summarise
events: MatrixEvent[];
// The maximum number of names to show in either each summary e.g. 2 would result "A, B and 234 others left" // The maximum number of names to show in either each summary e.g. 2 would result "A, B and 234 others left"
summaryLength?: number; summaryLength?: number;
// The maximum number of avatars to display in the summary // The maximum number of avatars to display in the summary
avatarsMaxLength?: number; avatarsMaxLength?: number;
// The minimum number of events needed to trigger summarisation
threshold?: number,
// Whether or not to begin with state.expanded=true
startExpanded?: boolean,
// An array of EventTiles to render when expanded
children: ReactChildren;
// Called when the MELS expansion is toggled
onToggle?(): void,
} }
interface IUserEvents { interface IUserEvents {

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2019 New Vector Ltd Copyright 2019 - 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -13,67 +13,78 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React, { createRef } from "react";
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import React from 'react';
import PropTypes from 'prop-types';
import * as sdk from '../../../index';
import withValidation from './Validation'; import withValidation from './Validation';
import { MatrixClientPeg } from '../../../MatrixClientPeg'; import { MatrixClientPeg } from '../../../MatrixClientPeg';
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import Field, { IValidateOpts } from "./Field";
interface IProps {
domain: string;
value: string;
label?: string;
placeholder?: string;
onChange?(value: string): void;
}
interface IState {
isValid: boolean;
}
// Controlled form component wrapping Field for inputting a room alias scoped to a given domain // Controlled form component wrapping Field for inputting a room alias scoped to a given domain
@replaceableComponent("views.elements.RoomAliasField") @replaceableComponent("views.elements.RoomAliasField")
export default class RoomAliasField extends React.PureComponent { export default class RoomAliasField extends React.PureComponent<IProps, IState> {
static propTypes = { private fieldRef = createRef<Field>();
domain: PropTypes.string.isRequired,
onChange: PropTypes.func,
value: PropTypes.string.isRequired,
};
constructor(props) { constructor(props, context) {
super(props); super(props, context);
this.state = {isValid: true};
this.state = {
isValid: true,
};
} }
_asFullAlias(localpart) { private asFullAlias(localpart: string): string {
return `#${localpart}:${this.props.domain}`; return `#${localpart}:${this.props.domain}`;
} }
render() { render() {
const Field = sdk.getComponent('views.elements.Field');
const poundSign = (<span>#</span>); const poundSign = (<span>#</span>);
const aliasPostfix = ":" + this.props.domain; const aliasPostfix = ":" + this.props.domain;
const domain = (<span title={aliasPostfix}>{aliasPostfix}</span>); const domain = (<span title={aliasPostfix}>{aliasPostfix}</span>);
const maxlength = 255 - this.props.domain.length - 2; // 2 for # and : const maxlength = 255 - this.props.domain.length - 2; // 2 for # and :
return ( return (
<Field <Field
label={_t("Room address")} label={this.props.label || _t("Room address")}
className="mx_RoomAliasField" className="mx_RoomAliasField"
prefixComponent={poundSign} prefixComponent={poundSign}
postfixComponent={domain} postfixComponent={domain}
ref={ref => this._fieldRef = ref} ref={this.fieldRef}
onValidate={this._onValidate} onValidate={this.onValidate}
placeholder={_t("e.g. my-room")} placeholder={this.props.placeholder || _t("e.g. my-room")}
onChange={this._onChange} onChange={this.onChange}
value={this.props.value.substring(1, this.props.value.length - this.props.domain.length - 1)} value={this.props.value.substring(1, this.props.value.length - this.props.domain.length - 1)}
maxLength={maxlength} maxLength={maxlength}
/> />
); );
} }
_onChange = (ev) => { private onChange = (ev) => {
if (this.props.onChange) { if (this.props.onChange) {
this.props.onChange(this._asFullAlias(ev.target.value)); this.props.onChange(this.asFullAlias(ev.target.value));
} }
}; };
_onValidate = async (fieldState) => { private onValidate = async (fieldState) => {
const result = await this._validationRules(fieldState); const result = await this.validationRules(fieldState);
this.setState({isValid: result.valid}); this.setState({isValid: result.valid});
return result; return result;
}; };
_validationRules = withValidation({ private validationRules = withValidation({
rules: [ rules: [
{ {
key: "safeLocalpart", key: "safeLocalpart",
@ -81,7 +92,7 @@ export default class RoomAliasField extends React.PureComponent {
if (!value) { if (!value) {
return true; return true;
} }
const fullAlias = this._asFullAlias(value); const fullAlias = this.asFullAlias(value);
// XXX: FIXME https://github.com/matrix-org/matrix-doc/issues/668 // XXX: FIXME https://github.com/matrix-org/matrix-doc/issues/668
return !value.includes("#") && !value.includes(":") && !value.includes(",") && return !value.includes("#") && !value.includes(":") && !value.includes(",") &&
encodeURI(fullAlias) === fullAlias; encodeURI(fullAlias) === fullAlias;
@ -90,7 +101,7 @@ export default class RoomAliasField extends React.PureComponent {
}, { }, {
key: "required", key: "required",
test: async ({ value, allowEmpty }) => allowEmpty || !!value, test: async ({ value, allowEmpty }) => allowEmpty || !!value,
invalid: () => _t("Please provide a room address"), invalid: () => _t("Please provide an address"),
}, { }, {
key: "taken", key: "taken",
final: true, final: true,
@ -100,7 +111,7 @@ export default class RoomAliasField extends React.PureComponent {
} }
const client = MatrixClientPeg.get(); const client = MatrixClientPeg.get();
try { try {
await client.getRoomIdForAlias(this._asFullAlias(value)); await client.getRoomIdForAlias(this.asFullAlias(value));
// we got a room id, so the alias is taken // we got a room id, so the alias is taken
return false; return false;
} catch (err) { } catch (err) {
@ -116,15 +127,15 @@ export default class RoomAliasField extends React.PureComponent {
], ],
}); });
get isValid() { public get isValid() {
return this.state.isValid; return this.state.isValid;
} }
validate(options) { public validate(options: IValidateOpts) {
return this._fieldRef.validate(options); return this.fieldRef.current?.validate(options);
} }
focus() { public focus() {
this._fieldRef.focus(); this.fieldRef.current?.focus();
} }
} }

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {useEffect, useState} from "react"; import React, { useEffect, useState } from "react";
import { Room } from "matrix-js-sdk/src/models/room"; import { Room } from "matrix-js-sdk/src/models/room";
import { useEventEmitter } from "../../../hooks/useEventEmitter"; import { useEventEmitter } from "../../../hooks/useEventEmitter";
@ -34,7 +34,7 @@ const RoomName = ({ room, children }: IProps): JSX.Element => {
}, [room]); }, [room]);
if (children) return children(name); if (children) return children(name);
return name || ""; return <>{ name || "" }</>;
}; };
export default RoomName; export default RoomName;

View file

@ -77,9 +77,10 @@ export default class SettingsFlag extends React.Component<IProps, IState> {
public render() { public render() {
const canChange = SettingsStore.canSetValue(this.props.name, this.props.roomId, this.props.level); const canChange = SettingsStore.canSetValue(this.props.name, this.props.roomId, this.props.level);
let label = this.props.label; const label = this.props.label
if (!label) label = SettingsStore.getDisplayName(this.props.name, this.props.level); ? _t(this.props.label)
else label = _t(label); : SettingsStore.getDisplayName(this.props.name, this.props.level);
const description = SettingsStore.getDescription(this.props.name);
if (this.props.useCheckbox) { if (this.props.useCheckbox) {
return <StyledCheckbox return <StyledCheckbox
@ -99,6 +100,9 @@ export default class SettingsFlag extends React.Component<IProps, IState> {
disabled={this.props.disabled || !canChange} disabled={this.props.disabled || !canChange}
aria-label={label} aria-label={label}
/> />
{ description && <div className="mx_SettingsFlag_microcopy">
{ description }
</div> }
</div> </div>
); );
} }

View file

@ -34,10 +34,19 @@ interface IProps<T extends string> {
definitions: IDefinition<T>[]; definitions: IDefinition<T>[];
value?: T; // if not provided no options will be selected value?: T; // if not provided no options will be selected
outlined?: boolean; outlined?: boolean;
disabled?: boolean;
onChange(newValue: T): void; onChange(newValue: T): void;
} }
function StyledRadioGroup<T extends string>({name, definitions, value, className, outlined, onChange}: IProps<T>) { function StyledRadioGroup<T extends string>({
name,
definitions,
value,
className,
outlined,
disabled,
onChange,
}: IProps<T>) {
const _onChange = e => { const _onChange = e => {
onChange(e.target.value); onChange(e.target.value);
}; };
@ -50,12 +59,12 @@ function StyledRadioGroup<T extends string>({name, definitions, value, className
checked={d.checked !== undefined ? d.checked : d.value === value} checked={d.checked !== undefined ? d.checked : d.value === value}
name={name} name={name}
value={d.value} value={d.value}
disabled={d.disabled} disabled={disabled || d.disabled}
outlined={outlined} outlined={outlined}
> >
{ d.label } { d.label }
</StyledRadioButton> </StyledRadioButton>
{d.description} { d.description ? <span>{ d.description }</span> : null }
</React.Fragment>)} </React.Fragment>)}
</React.Fragment>; </React.Fragment>;
} }

View file

@ -16,31 +16,29 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import {replaceableComponent} from "../../../utils/replaceableComponent"; import {replaceableComponent} from "../../../utils/replaceableComponent";
@replaceableComponent("views.elements.TruncatedList") interface IProps {
export default class TruncatedList extends React.Component {
static propTypes = {
// The number of elements to show before truncating. If negative, no truncation is done. // The number of elements to show before truncating. If negative, no truncation is done.
truncateAt: PropTypes.number, truncateAt?: number;
// The className to apply to the wrapping div // The className to apply to the wrapping div
className: PropTypes.string, className?: string;
// A function that returns the children to be rendered into the element. // A function that returns the children to be rendered into the element.
// function getChildren(start: number, end: number): Array<React.Node>
// The start element is included, the end is not (as in `slice`). // The start element is included, the end is not (as in `slice`).
// If omitted, the React child elements will be used. This parameter can be used // If omitted, the React child elements will be used. This parameter can be used
// to avoid creating unnecessary React elements. // to avoid creating unnecessary React elements.
getChildren: PropTypes.func, getChildren?: (start: number, end: number) => Array<React.ReactNode>;
// A function that should return the total number of child element available. // A function that should return the total number of child element available.
// Required if getChildren is supplied. // Required if getChildren is supplied.
getChildCount: PropTypes.func, getChildCount?: () => number;
// A function which will be invoked when an overflow element is required. // A function which will be invoked when an overflow element is required.
// This will be inserted after the children. // This will be inserted after the children.
createOverflowElement: PropTypes.func, createOverflowElement?: (overflowCount: number, totalCount: number) => React.ReactNode;
}; }
@replaceableComponent("views.elements.TruncatedList")
export default class TruncatedList extends React.Component<IProps> {
static defaultProps ={ static defaultProps ={
truncateAt: 2, truncateAt: 2,
createOverflowElement(overflowCount, totalCount) { createOverflowElement(overflowCount, totalCount) {
@ -50,7 +48,7 @@ export default class TruncatedList extends React.Component {
}, },
}; };
_getChildren(start, end) { private getChildren(start: number, end: number): Array<React.ReactNode> {
if (this.props.getChildren && this.props.getChildCount) { if (this.props.getChildren && this.props.getChildCount) {
return this.props.getChildren(start, end); return this.props.getChildren(start, end);
} else { } else {
@ -63,7 +61,7 @@ export default class TruncatedList extends React.Component {
} }
} }
_getChildCount() { private getChildCount(): number {
if (this.props.getChildren && this.props.getChildCount) { if (this.props.getChildren && this.props.getChildCount) {
return this.props.getChildCount(); return this.props.getChildCount();
} else { } else {
@ -73,10 +71,10 @@ export default class TruncatedList extends React.Component {
} }
} }
render() { public render() {
let overflowNode = null; let overflowNode = null;
const totalChildren = this._getChildCount(); const totalChildren = this.getChildCount();
let upperBound = totalChildren; let upperBound = totalChildren;
if (this.props.truncateAt >= 0) { if (this.props.truncateAt >= 0) {
const overflowCount = totalChildren - this.props.truncateAt; const overflowCount = totalChildren - this.props.truncateAt;
@ -87,7 +85,7 @@ export default class TruncatedList extends React.Component {
upperBound = this.props.truncateAt; upperBound = this.props.truncateAt;
} }
} }
const childNodes = this._getChildren(0, upperBound); const childNodes = this.getChildren(0, upperBound);
return ( return (
<div className={this.props.className}> <div className={this.props.className}>

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