Merge branch 'develop' into feed

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
This commit is contained in:
Šimon Brandner 2021-04-28 10:52:23 +02:00
commit 551e8ecb8d
No known key found for this signature in database
GPG key ID: 9760693FDD98A790
105 changed files with 3957 additions and 1723 deletions

View file

@ -1,3 +1,121 @@
Changes in [3.19.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.19.0) (2021-04-26)
=====================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.19.0-rc.1...v3.19.0)
* Upgrade to JS SDK 10.0.0
* [Release] Dynamic max and min zoom in the new ImageView
[\#5927](https://github.com/matrix-org/matrix-react-sdk/pull/5927)
* [Release] Add a WheelEvent normalization function
[\#5911](https://github.com/matrix-org/matrix-react-sdk/pull/5911)
* Add a WheelEvent normalization function
[\#5904](https://github.com/matrix-org/matrix-react-sdk/pull/5904)
* [Release] Use floats for image background opacity
[\#5907](https://github.com/matrix-org/matrix-react-sdk/pull/5907)
Changes in [3.19.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.19.0-rc.1) (2021-04-21)
===============================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.18.0...v3.19.0-rc.1)
* Upgrade to JS SDK 10.0.0-rc.1
* Translations update from Weblate
[\#5896](https://github.com/matrix-org/matrix-react-sdk/pull/5896)
* Fix sticky tags header in room list
[\#5895](https://github.com/matrix-org/matrix-react-sdk/pull/5895)
* Fix spaces filtering sometimes lagging behind or behaving oddly
[\#5893](https://github.com/matrix-org/matrix-react-sdk/pull/5893)
* Fix issue with spaces context switching looping and breaking
[\#5894](https://github.com/matrix-org/matrix-react-sdk/pull/5894)
* Improve RoomList render time when filtering
[\#5874](https://github.com/matrix-org/matrix-react-sdk/pull/5874)
* Avoid being stuck in a space
[\#5891](https://github.com/matrix-org/matrix-react-sdk/pull/5891)
* [Spaces] Context switching
[\#5795](https://github.com/matrix-org/matrix-react-sdk/pull/5795)
* Warn when you attempt to leave room that you are the only member of
[\#5415](https://github.com/matrix-org/matrix-react-sdk/pull/5415)
* Ensure PersistedElement are unmounted on application logout
[\#5884](https://github.com/matrix-org/matrix-react-sdk/pull/5884)
* Add missing space in seshat dialog and the corresponding string
[\#5866](https://github.com/matrix-org/matrix-react-sdk/pull/5866)
* A tiny change to make the Add existing rooms dialog a little nicer
[\#5885](https://github.com/matrix-org/matrix-react-sdk/pull/5885)
* Remove weird margin from the file panel
[\#5889](https://github.com/matrix-org/matrix-react-sdk/pull/5889)
* Trigger lazy loading when filtering using spaces
[\#5882](https://github.com/matrix-org/matrix-react-sdk/pull/5882)
* Fix typo in method call in add existing to space dialog
[\#5883](https://github.com/matrix-org/matrix-react-sdk/pull/5883)
* New Image View fixes/improvements
[\#5872](https://github.com/matrix-org/matrix-react-sdk/pull/5872)
* Limit voice recording length
[\#5871](https://github.com/matrix-org/matrix-react-sdk/pull/5871)
* Clean up add existing to space dialog and include DMs in it too
[\#5881](https://github.com/matrix-org/matrix-react-sdk/pull/5881)
* Fix unknown slash command error exploding
[\#5853](https://github.com/matrix-org/matrix-react-sdk/pull/5853)
* Switch to a spec conforming email validation Regexp
[\#5852](https://github.com/matrix-org/matrix-react-sdk/pull/5852)
* Cleanup unused state in MessageComposer
[\#5877](https://github.com/matrix-org/matrix-react-sdk/pull/5877)
* Pulse animation for voice messages recording state
[\#5869](https://github.com/matrix-org/matrix-react-sdk/pull/5869)
* Don't include invisible rooms in notify summary
[\#5875](https://github.com/matrix-org/matrix-react-sdk/pull/5875)
* Properly disable composer access when recording a voice message
[\#5870](https://github.com/matrix-org/matrix-react-sdk/pull/5870)
* Stabilise starting a DM with multiple people flow
[\#5862](https://github.com/matrix-org/matrix-react-sdk/pull/5862)
* Render msgOption only if showReadReceipts is enabled
[\#5864](https://github.com/matrix-org/matrix-react-sdk/pull/5864)
* Labs: Add quick/cheap "do not disturb" flag
[\#5873](https://github.com/matrix-org/matrix-react-sdk/pull/5873)
* Fix ReadReceipts animations
[\#5836](https://github.com/matrix-org/matrix-react-sdk/pull/5836)
* Add tooltips to message previews
[\#5859](https://github.com/matrix-org/matrix-react-sdk/pull/5859)
* IRC Layout fix layout spacing in replies
[\#5855](https://github.com/matrix-org/matrix-react-sdk/pull/5855)
* Move user to welcome_page if continuing with previous session
[\#5849](https://github.com/matrix-org/matrix-react-sdk/pull/5849)
* Improve image view
[\#5521](https://github.com/matrix-org/matrix-react-sdk/pull/5521)
* Add a button to reset personal encryption state during login
[\#5819](https://github.com/matrix-org/matrix-react-sdk/pull/5819)
* Fix js-sdk import in SlashCommands
[\#5850](https://github.com/matrix-org/matrix-react-sdk/pull/5850)
* Fix useRoomPowerLevels hook
[\#5854](https://github.com/matrix-org/matrix-react-sdk/pull/5854)
* Prevent state events being rendered with invalid state keys
[\#5851](https://github.com/matrix-org/matrix-react-sdk/pull/5851)
* Give server ACLs a name in 'roles & permissions' tab
[\#5838](https://github.com/matrix-org/matrix-react-sdk/pull/5838)
* Don't hide notification badge on the home space button as it has no menu
[\#5845](https://github.com/matrix-org/matrix-react-sdk/pull/5845)
* User Info hide disambiguation as we always show MXID anyway
[\#5843](https://github.com/matrix-org/matrix-react-sdk/pull/5843)
* Improve kick state to not show if the target was not joined to begin with
[\#5846](https://github.com/matrix-org/matrix-react-sdk/pull/5846)
* Fix space store wrongly switching to a non-space filter
[\#5844](https://github.com/matrix-org/matrix-react-sdk/pull/5844)
* Tweak appearance of invite reason
[\#5847](https://github.com/matrix-org/matrix-react-sdk/pull/5847)
* Update Inter font to v3.18
[\#5840](https://github.com/matrix-org/matrix-react-sdk/pull/5840)
* Enable sharing historical keys on invite
[\#5839](https://github.com/matrix-org/matrix-react-sdk/pull/5839)
* Add ability to hide post-login encryption setup with customisation point
[\#5834](https://github.com/matrix-org/matrix-react-sdk/pull/5834)
* Use LaTeX and TeX delimiters by default
[\#5515](https://github.com/matrix-org/matrix-react-sdk/pull/5515)
* Clone author's deps fork for Netlify previews
[\#5837](https://github.com/matrix-org/matrix-react-sdk/pull/5837)
* Show drop file UI only if dragging a file
[\#5827](https://github.com/matrix-org/matrix-react-sdk/pull/5827)
* Ignore punctuation when filtering rooms
[\#5824](https://github.com/matrix-org/matrix-react-sdk/pull/5824)
* Resizable CallView
[\#5710](https://github.com/matrix-org/matrix-react-sdk/pull/5710)
Changes in [3.18.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.18.0) (2021-04-12) Changes in [3.18.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.18.0) (2021-04-12)
===================================================================================================== =====================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.18.0-rc.1...v3.18.0) [Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.18.0-rc.1...v3.18.0)

View file

@ -1,6 +1,6 @@
{ {
"name": "matrix-react-sdk", "name": "matrix-react-sdk",
"version": "3.18.0", "version": "3.19.0",
"description": "SDK for matrix.org using React", "description": "SDK for matrix.org using React",
"author": "matrix.org", "author": "matrix.org",
"repository": { "repository": {
@ -23,9 +23,7 @@
"package.json" "package.json"
], ],
"bin": { "bin": {
"reskindex": "scripts/reskindex.js", "reskindex": "scripts/reskindex.js"
"matrix-gen-i18n": "scripts/gen-i18n.js",
"matrix-prune-i18n": "scripts/prune-i18n.js"
}, },
"main": "./src/index.js", "main": "./src/index.js",
"matrix_src_main": "./src/index.js", "matrix_src_main": "./src/index.js",
@ -35,7 +33,7 @@
"prepublishOnly": "yarn build", "prepublishOnly": "yarn build",
"i18n": "matrix-gen-i18n", "i18n": "matrix-gen-i18n",
"prunei18n": "matrix-prune-i18n", "prunei18n": "matrix-prune-i18n",
"diff-i18n": "cp src/i18n/strings/en_EN.json src/i18n/strings/en_EN_orig.json && ./scripts/gen-i18n.js && node scripts/compare-file.js src/i18n/strings/en_EN_orig.json src/i18n/strings/en_EN.json", "diff-i18n": "cp src/i18n/strings/en_EN.json src/i18n/strings/en_EN_orig.json && matrix-gen-i18n && matrix-compare-i18n-files src/i18n/strings/en_EN_orig.json src/i18n/strings/en_EN.json",
"reskindex": "node scripts/reskindex.js -h header", "reskindex": "node scripts/reskindex.js -h header",
"reskindex:watch": "node scripts/reskindex.js -h header -w", "reskindex:watch": "node scripts/reskindex.js -h header -w",
"rethemendex": "res/css/rethemendex.sh", "rethemendex": "res/css/rethemendex.sh",
@ -160,6 +158,7 @@
"jest-fetch-mock": "^3.0.3", "jest-fetch-mock": "^3.0.3",
"matrix-mock-request": "^1.2.3", "matrix-mock-request": "^1.2.3",
"matrix-react-test-utils": "^0.2.2", "matrix-react-test-utils": "^0.2.2",
"matrix-web-i18n": "github:matrix-org/matrix-web-i18n",
"olm": "https://packages.matrix.org/npm/olm/olm-3.2.1.tgz", "olm": "https://packages.matrix.org/npm/olm/olm-3.2.1.tgz",
"react-test-renderer": "^16.14.0", "react-test-renderer": "^16.14.0",
"rimraf": "^3.0.2", "rimraf": "^3.0.2",

View file

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

View file

@ -26,7 +26,10 @@ limitations under the License.
word-break: break-word; word-break: break-word;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
}
.mx_SpaceRoomDirectory,
.mx_SpaceRoomView_landing {
.mx_Dialog_title { .mx_Dialog_title {
display: flex; display: flex;
@ -56,7 +59,6 @@ limitations under the License.
} }
} }
.mx_Dialog_content {
.mx_AccessibleButton_kind_link { .mx_AccessibleButton_kind_link {
padding: 0; padding: 0;
} }
@ -116,7 +118,6 @@ limitations under the License.
background-image: url("$(res)/img/element-icons/warning-badge.svg"); background-image: url("$(res)/img/element-icons/warning-badge.svg");
} }
} }
}
} }
.mx_SpaceRoomDirectory_list { .mx_SpaceRoomDirectory_list {

View file

@ -214,12 +214,11 @@ $SpaceRoomViewInnerWidth: 428px;
.mx_SpaceRoomView_info { .mx_SpaceRoomView_info {
display: inline-block; display: inline-block;
margin: 0; margin: 0 auto 0 0;
} }
.mx_FacePile { .mx_FacePile {
display: inline-block; display: inline-block;
margin-left: auto;
margin-right: 12px; margin-right: 12px;
.mx_FacePile_faces { .mx_FacePile_faces {

View file

@ -148,12 +148,14 @@ limitations under the License.
font-size: $font-15px; font-size: $font-15px;
line-height: 30px; line-height: 30px;
flex-grow: 1; flex-grow: 1;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
margin-right: 12px;
} }
.mx_FormButton { .mx_Checkbox {
min-width: 92px; align-items: center;
font-weight: normal;
box-sizing: border-box;
} }
} }
} }
@ -192,8 +194,4 @@ limitations under the License.
padding: 0; padding: 0;
} }
} }
.mx_FormButton {
padding: 8px 22px;
}
} }

View file

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

View file

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

View file

@ -214,10 +214,6 @@ $left-gutter: 64px;
color: $accent-fg-color; color: $accent-fg-color;
} }
.mx_EventTile_notSent {
color: $event-notsent-color;
}
.mx_EventTile_receiptSent, .mx_EventTile_receiptSent,
.mx_EventTile_receiptSending { .mx_EventTile_receiptSending {
// We don't use `position: relative` on the element because then it won't line // We don't use `position: relative` on the element because then it won't line

View file

@ -17,7 +17,7 @@ limitations under the License.
.mx_CallView { .mx_CallView {
border-radius: 8px; border-radius: 8px;
background-color: $voipcall-plinth-color; background-color: $dark-panel-bg-color;
padding-left: 8px; padding-left: 8px;
padding-right: 8px; padding-right: 8px;
// XXX: CallContainer sets pointer-events: none - should probably be set back in a better place // XXX: CallContainer sets pointer-events: none - should probably be set back in a better place
@ -40,7 +40,8 @@ limitations under the License.
width: 320px; width: 320px;
padding-bottom: 8px; padding-bottom: 8px;
margin-top: 10px; margin-top: 10px;
box-shadow: 0px 14px 24px rgba(0, 0, 0, 0.08); background-color: $voipcall-plinth-color;
box-shadow: 0px 4px 20px rgba(0, 0, 0, 0.20);
border-radius: 8px; border-radius: 8px;
.mx_CallView_voice { .mx_CallView_voice {

View file

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

After

Width:  |  Height:  |  Size: 1.4 KiB

View file

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

After

Width:  |  Height:  |  Size: 679 B

View file

@ -63,6 +63,8 @@ $input-invalid-border-color: $warning-color;
$field-focused-label-bg-color: $bg-color; $field-focused-label-bg-color: $bg-color;
$resend-button-divider-color: #b9bec64a; // muted-text with a 4A opacity.
// scrollbars // scrollbars
$scrollbar-thumb-color: rgba(255, 255, 255, 0.2); $scrollbar-thumb-color: rgba(255, 255, 255, 0.2);
$scrollbar-track-color: transparent; $scrollbar-track-color: transparent;
@ -110,7 +112,7 @@ $header-divider-color: $header-panel-text-primary-color;
$composer-e2e-icon-color: $header-panel-text-primary-color; $composer-e2e-icon-color: $header-panel-text-primary-color;
// this probably shouldn't have it's own colour // this probably shouldn't have it's own colour
$voipcall-plinth-color: #21262c; $voipcall-plinth-color: #394049;
// ******************** // ********************

View file

@ -61,6 +61,8 @@ $input-invalid-border-color: $warning-color;
$field-focused-label-bg-color: $bg-color; $field-focused-label-bg-color: $bg-color;
$resend-button-divider-color: $muted-fg-color;
// scrollbars // scrollbars
$scrollbar-thumb-color: rgba(255, 255, 255, 0.2); $scrollbar-thumb-color: rgba(255, 255, 255, 0.2);
$scrollbar-track-color: transparent; $scrollbar-track-color: transparent;
@ -107,7 +109,7 @@ $header-divider-color: $header-panel-text-primary-color;
$composer-e2e-icon-color: $header-panel-text-primary-color; $composer-e2e-icon-color: $header-panel-text-primary-color;
// this probably shouldn't have it's own colour // this probably shouldn't have it's own colour
$voipcall-plinth-color: #f2f5f8; $voipcall-plinth-color: #394049;
// ******************** // ********************

View file

@ -97,6 +97,8 @@ $input-invalid-border-color: $warning-color;
$field-focused-label-bg-color: #ffffff; $field-focused-label-bg-color: #ffffff;
$resend-button-divider-color: $input-darker-bg-color;
$button-bg-color: $accent-color; $button-bg-color: $accent-color;
$button-fg-color: white; $button-fg-color: white;
@ -174,7 +176,7 @@ $composer-e2e-icon-color: #91a1c0;
$header-divider-color: #91a1c0; $header-divider-color: #91a1c0;
// this probably shouldn't have it's own colour // this probably shouldn't have it's own colour
$voipcall-plinth-color: #f2f5f8; $voipcall-plinth-color: #F4F6FA;
// ******************** // ********************

View file

@ -91,6 +91,8 @@ $field-focused-label-bg-color: #ffffff;
$button-bg-color: $accent-color; $button-bg-color: $accent-color;
$button-fg-color: white; $button-fg-color: white;
$resend-button-divider-color: $input-darker-bg-color;
// apart from login forms, which have stronger border // apart from login forms, which have stronger border
$strong-input-border-color: #c7c7c7; $strong-input-border-color: #c7c7c7;
@ -165,7 +167,7 @@ $composer-e2e-icon-color: #91A1C0;
$header-divider-color: #91A1C0; $header-divider-color: #91A1C0;
// this probably shouldn't have it's own colour // this probably shouldn't have it's own colour
$voipcall-plinth-color: #f2f5f8; $voipcall-plinth-color: #F4F6FA;
// ******************** // ********************

View file

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

View file

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

View file

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

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2020 The Matrix.org Foundation C.I.C. Copyright 2020-2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -40,6 +40,8 @@ import { WidgetLayoutStore } from "../stores/widgets/WidgetLayoutStore";
import VoipUserMapper from "../VoipUserMapper"; import VoipUserMapper from "../VoipUserMapper";
import {SpaceStoreClass} from "../stores/SpaceStore"; import {SpaceStoreClass} from "../stores/SpaceStore";
import {VoiceRecording} from "../voice/VoiceRecording"; import {VoiceRecording} from "../voice/VoiceRecording";
import TypingStore from "../stores/TypingStore";
import { EventIndexPeg } from "../indexing/EventIndexPeg";
declare global { declare global {
interface Window { interface Window {
@ -72,11 +74,15 @@ declare global {
mxVoipUserMapper: VoipUserMapper; mxVoipUserMapper: VoipUserMapper;
mxSpaceStore: SpaceStoreClass; mxSpaceStore: SpaceStoreClass;
mxVoiceRecorder: typeof VoiceRecording; mxVoiceRecorder: typeof VoiceRecording;
mxTypingStore: TypingStore;
mxEventIndexPeg: EventIndexPeg;
} }
interface Document { interface Document {
// https://developer.mozilla.org/en-US/docs/Web/API/Document/hasStorageAccess // https://developer.mozilla.org/en-US/docs/Web/API/Document/hasStorageAccess
hasStorageAccess?: () => Promise<boolean>; hasStorageAccess?: () => Promise<boolean>;
// https://developer.mozilla.org/en-US/docs/Web/API/Document/requestStorageAccess
requestStorageAccess?: () => Promise<undefined>;
// Safari & IE11 only have this prefixed: we used prefixed versions // Safari & IE11 only have this prefixed: we used prefixed versions
// previously so let's continue to support them for now // previously so let's continue to support them for now

View file

@ -27,11 +27,7 @@ export type ResizeMethod = "crop" | "scale";
export function avatarUrlForMember(member: RoomMember, width: number, height: number, resizeMethod: ResizeMethod) { export function avatarUrlForMember(member: RoomMember, width: number, height: number, resizeMethod: ResizeMethod) {
let url: string; let url: string;
if (member?.getMxcAvatarUrl()) { if (member?.getMxcAvatarUrl()) {
url = mediaFromMxc(member.getMxcAvatarUrl()).getThumbnailOfSourceHttp( url = mediaFromMxc(member.getMxcAvatarUrl()).getThumbnailOfSourceHttp(width, height, resizeMethod);
Math.floor(width * window.devicePixelRatio),
Math.floor(height * window.devicePixelRatio),
resizeMethod,
);
} }
if (!url) { if (!url) {
// member can be null here currently since on invites, the JS SDK // member can be null here currently since on invites, the JS SDK
@ -44,11 +40,7 @@ export function avatarUrlForMember(member: RoomMember, width: number, height: nu
export function avatarUrlForUser(user: User, width: number, height: number, resizeMethod?: ResizeMethod) { export function avatarUrlForUser(user: User, width: number, height: number, resizeMethod?: ResizeMethod) {
if (!user.avatarUrl) return null; if (!user.avatarUrl) return null;
return mediaFromMxc(user.avatarUrl).getThumbnailOfSourceHttp( return mediaFromMxc(user.avatarUrl).getThumbnailOfSourceHttp(width, height, resizeMethod);
Math.floor(width * window.devicePixelRatio),
Math.floor(height * window.devicePixelRatio),
resizeMethod,
);
} }
function isValidHexColor(color: string): boolean { function isValidHexColor(color: string): boolean {

View file

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

View file

@ -651,11 +651,12 @@ export default class CallHandler extends EventEmitter {
} }
call.placeScreenSharingCall( call.placeScreenSharingCall(
async () : Promise<DesktopCapturerSource> => { async (): Promise<DesktopCapturerSource> => {
const {finished} = Modal.createDialog(DesktopCapturerSourcePicker); const {finished} = Modal.createDialog(DesktopCapturerSourcePicker);
const [source] = await finished; const [source] = await finished;
return source; return source;
}); },
);
} else { } else {
console.error("Unknown conf call type: " + type); console.error("Unknown conf call type: " + type);
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -136,7 +136,7 @@ const Tile: React.FC<ITileProps> = ({
let url: string; let url: string;
if (room.avatar_url) { if (room.avatar_url) {
url = mediaFromMxc(room.avatar_url).getSquareThumbnailHttp(Math.floor(20 * window.devicePixelRatio)); url = mediaFromMxc(room.avatar_url).getSquareThumbnailHttp(20);
} }
let description = _t("%(count)s members", { count: room.num_joined_members }); let description = _t("%(count)s members", { count: room.num_joined_members });
@ -312,11 +312,12 @@ export const HierarchyLevel = ({
// mutate argument refreshToken to force a reload // mutate argument refreshToken to force a reload
export const useSpaceSummary = (cli: MatrixClient, space: Room, refreshToken?: any): [ export const useSpaceSummary = (cli: MatrixClient, space: Room, refreshToken?: any): [
null,
ISpaceSummaryRoom[], ISpaceSummaryRoom[],
Map<string, Map<string, ISpaceSummaryEvent>>, Map<string, Map<string, ISpaceSummaryEvent>>?,
Map<string, Set<string>>, Map<string, Set<string>>?,
Map<string, Set<string>>, Map<string, Set<string>>?,
] | [] => { ] | [Error] => {
// TODO pagination // TODO pagination
return useAsyncMemo(async () => { return useAsyncMemo(async () => {
try { try {
@ -336,13 +337,12 @@ export const useSpaceSummary = (cli: MatrixClient, space: Room, refreshToken?: a
} }
}); });
return [data.rooms as ISpaceSummaryRoom[], parentChildRelations, viaMap, childParentRelations]; return [null, data.rooms as ISpaceSummaryRoom[], parentChildRelations, viaMap, childParentRelations];
} catch (e) { } catch (e) {
console.error(e); // TODO console.error(e); // TODO
return [e];
} }
}, [space, refreshToken], [undefined]);
return [];
}, [space, refreshToken], []);
}; };
export const SpaceHierarchy: React.FC<IHierarchyProps> = ({ export const SpaceHierarchy: React.FC<IHierarchyProps> = ({
@ -358,7 +358,7 @@ export const SpaceHierarchy: React.FC<IHierarchyProps> = ({
const [selected, setSelected] = useState(new Map<string, Set<string>>()); // Map<parentId, Set<childId>> const [selected, setSelected] = useState(new Map<string, Set<string>>()); // Map<parentId, Set<childId>>
const [rooms, parentChildMap, viaMap, childParentMap] = useSpaceSummary(cli, space, refreshToken); const [summaryError, rooms, parentChildMap, viaMap, childParentMap] = useSpaceSummary(cli, space, refreshToken);
const roomsMap = useMemo(() => { const roomsMap = useMemo(() => {
if (!rooms) return null; if (!rooms) return null;
@ -397,6 +397,10 @@ export const SpaceHierarchy: React.FC<IHierarchyProps> = ({
const [removing, setRemoving] = useState(false); const [removing, setRemoving] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
if (summaryError) {
return <p>{_t("Your server does not support showing space hierarchies.")}</p>;
}
let content; let content;
if (roomsMap) { if (roomsMap) {
const numRooms = Array.from(roomsMap.values()).filter(r => r.room_type !== RoomType.Space).length; const numRooms = Array.from(roomsMap.values()).filter(r => r.room_type !== RoomType.Space).length;
@ -538,10 +542,8 @@ export const SpaceHierarchy: React.FC<IHierarchyProps> = ({
{ children } { children }
</AutoHideScrollbar> </AutoHideScrollbar>
</>; </>;
} else if (!rooms) {
content = <Spinner />;
} else { } else {
content = <p>{_t("Your server does not support showing space hierarchies.")}</p>; content = <Spinner />;
} }
// TODO loading state/error state // TODO loading state/error state

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,8 +1,6 @@
/* /*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2018 New Vector Ltd
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
Copyright 2019 The Matrix.org Foundation C.I.C. Copyright 2015, 2016, 2018, 2019, 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -34,7 +32,7 @@ import {MenuItem} from "../../structures/ContextMenu";
import {EventType} from "matrix-js-sdk/src/@types/event"; import {EventType} from "matrix-js-sdk/src/@types/event";
import {replaceableComponent} from "../../../utils/replaceableComponent"; import {replaceableComponent} from "../../../utils/replaceableComponent";
function canCancel(eventStatus) { export function canCancel(eventStatus) {
return eventStatus === EventStatus.QUEUED || eventStatus === EventStatus.NOT_SENT; return eventStatus === EventStatus.QUEUED || eventStatus === EventStatus.NOT_SENT;
} }
@ -98,21 +96,6 @@ export default class MessageContextMenu extends React.Component {
return content.pinned && Array.isArray(content.pinned) && content.pinned.includes(this.props.mxEvent.getId()); return content.pinned && Array.isArray(content.pinned) && content.pinned.includes(this.props.mxEvent.getId());
} }
onResendClick = () => {
Resend.resend(this.props.mxEvent);
this.closeMenu();
};
onResendEditClick = () => {
Resend.resend(this.props.mxEvent.replacingEvent());
this.closeMenu();
};
onResendRedactionClick = () => {
Resend.resend(this.props.mxEvent.localRedactionEvent());
this.closeMenu();
};
onResendReactionsClick = () => { onResendReactionsClick = () => {
for (const reaction of this._getUnsentReactions()) { for (const reaction of this._getUnsentReactions()) {
Resend.resend(reaction); Resend.resend(reaction);
@ -170,29 +153,6 @@ export default class MessageContextMenu extends React.Component {
this.closeMenu(); this.closeMenu();
}; };
onCancelSendClick = () => {
const mxEvent = this.props.mxEvent;
const editEvent = mxEvent.replacingEvent();
const redactEvent = mxEvent.localRedactionEvent();
const pendingReactions = this._getPendingReactions();
if (editEvent && canCancel(editEvent.status)) {
Resend.removeFromQueue(editEvent);
}
if (redactEvent && canCancel(redactEvent.status)) {
Resend.removeFromQueue(redactEvent);
}
if (pendingReactions.length) {
for (const reaction of pendingReactions) {
Resend.removeFromQueue(reaction);
}
}
if (canCancel(mxEvent.status)) {
Resend.removeFromQueue(this.props.mxEvent);
}
this.closeMenu();
};
onForwardClick = () => { onForwardClick = () => {
if (this.props.onCloseDialog) this.props.onCloseDialog(); if (this.props.onCloseDialog) this.props.onCloseDialog();
dis.dispatch({ dis.dispatch({
@ -285,20 +245,9 @@ export default class MessageContextMenu extends React.Component {
const me = cli.getUserId(); const me = cli.getUserId();
const mxEvent = this.props.mxEvent; const mxEvent = this.props.mxEvent;
const eventStatus = mxEvent.status; const eventStatus = mxEvent.status;
const editStatus = mxEvent.replacingEvent() && mxEvent.replacingEvent().status;
const redactStatus = mxEvent.localRedactionEvent() && mxEvent.localRedactionEvent().status;
const unsentReactionsCount = this._getUnsentReactions().length; const unsentReactionsCount = this._getUnsentReactions().length;
const pendingReactionsCount = this._getPendingReactions().length;
const allowCancel = canCancel(mxEvent.status) ||
canCancel(editStatus) ||
canCancel(redactStatus) ||
pendingReactionsCount !== 0;
let resendButton;
let resendEditButton;
let resendReactionsButton; let resendReactionsButton;
let resendRedactionButton;
let redactButton; let redactButton;
let cancelButton;
let forwardButton; let forwardButton;
let pinButton; let pinButton;
let unhidePreviewButton; let unhidePreviewButton;
@ -309,22 +258,6 @@ export default class MessageContextMenu extends React.Component {
// status is SENT before remote-echo, null after // status is SENT before remote-echo, null after
const isSent = !eventStatus || eventStatus === EventStatus.SENT; const isSent = !eventStatus || eventStatus === EventStatus.SENT;
if (!mxEvent.isRedacted()) { if (!mxEvent.isRedacted()) {
if (eventStatus === EventStatus.NOT_SENT) {
resendButton = (
<MenuItem className="mx_MessageContextMenu_field" onClick={this.onResendClick}>
{ _t('Resend') }
</MenuItem>
);
}
if (editStatus === EventStatus.NOT_SENT) {
resendEditButton = (
<MenuItem className="mx_MessageContextMenu_field" onClick={this.onResendEditClick}>
{ _t('Resend edit') }
</MenuItem>
);
}
if (unsentReactionsCount !== 0) { if (unsentReactionsCount !== 0) {
resendReactionsButton = ( resendReactionsButton = (
<MenuItem className="mx_MessageContextMenu_field" onClick={this.onResendReactionsClick}> <MenuItem className="mx_MessageContextMenu_field" onClick={this.onResendReactionsClick}>
@ -334,14 +267,6 @@ export default class MessageContextMenu extends React.Component {
} }
} }
if (redactStatus === EventStatus.NOT_SENT) {
resendRedactionButton = (
<MenuItem className="mx_MessageContextMenu_field" onClick={this.onResendRedactionClick}>
{ _t('Resend removal') }
</MenuItem>
);
}
if (isSent && this.state.canRedact) { if (isSent && this.state.canRedact) {
redactButton = ( redactButton = (
<MenuItem className="mx_MessageContextMenu_field" onClick={this.onRedactClick}> <MenuItem className="mx_MessageContextMenu_field" onClick={this.onRedactClick}>
@ -350,14 +275,6 @@ export default class MessageContextMenu extends React.Component {
); );
} }
if (allowCancel) {
cancelButton = (
<MenuItem className="mx_MessageContextMenu_field" onClick={this.onCancelSendClick}>
{ _t('Cancel Sending') }
</MenuItem>
);
}
if (isContentActionable(mxEvent)) { if (isContentActionable(mxEvent)) {
forwardButton = ( forwardButton = (
<MenuItem className="mx_MessageContextMenu_field" onClick={this.onForwardClick}> <MenuItem className="mx_MessageContextMenu_field" onClick={this.onForwardClick}>
@ -455,12 +372,8 @@ export default class MessageContextMenu extends React.Component {
return ( return (
<div className="mx_MessageContextMenu"> <div className="mx_MessageContextMenu">
{ resendButton }
{ resendEditButton }
{ resendReactionsButton } { resendReactionsButton }
{ resendRedactionButton }
{ redactButton } { redactButton }
{ cancelButton }
{ forwardButton } { forwardButton }
{ pinButton } { pinButton }
{ viewSourceButton } { viewSourceButton }

View file

@ -41,11 +41,11 @@ interface IProps extends IDialogProps {
} }
const Entry = ({ room, checked, onChange }) => { const Entry = ({ room, checked, onChange }) => {
return <div className="mx_AddExistingToSpaceDialog_entry"> return <label className="mx_AddExistingToSpaceDialog_entry">
<RoomAvatar room={room} height={32} width={32} /> <RoomAvatar room={room} height={32} width={32} />
<span className="mx_AddExistingToSpaceDialog_entry_name">{ room.name }</span> <span className="mx_AddExistingToSpaceDialog_entry_name">{ room.name }</span>
<StyledCheckbox onChange={(e) => onChange(e.target.checked)} checked={checked} /> <StyledCheckbox onChange={(e) => onChange(e.target.checked)} checked={checked} />
</div>; </label>;
}; };
const AddExistingToSpaceDialog: React.FC<IProps> = ({ matrixClient: cli, space, onCreateRoomClick, onFinished }) => { const AddExistingToSpaceDialog: React.FC<IProps> = ({ matrixClient: cli, space, onCreateRoomClick, onFinished }) => {
@ -68,9 +68,13 @@ const AddExistingToSpaceDialog: React.FC<IProps> = ({ matrixClient: cli, space,
if (room !== space && room !== selectedSpace && !existingSubspacesSet.has(room)) { if (room !== space && room !== selectedSpace && !existingSubspacesSet.has(room)) {
arr[0].push(room); arr[0].push(room);
} }
} else if (!existingRoomsSet.has(room) && joinRule !== "public") { } else if (!existingRoomsSet.has(room)) {
if (!DMRoomMap.shared().getUserIdForRoomId(room.roomId)) {
arr[1].push(room);
} else if (joinRule !== "public") {
// Only show DMs for non-public spaces as they make very little sense in spaces other than "Just Me" ones. // Only show DMs for non-public spaces as they make very little sense in spaces other than "Just Me" ones.
arr[DMRoomMap.shared().getUserIdForRoomId(room.roomId) ? 2 : 1].push(room); arr[2].push(room);
}
} }
return arr; return arr;
}, [[], [], []]); }, [[], [], []]);
@ -130,6 +134,7 @@ const AddExistingToSpaceDialog: React.FC<IProps> = ({ matrixClient: cli, space,
placeholder={ _t("Filter your rooms and spaces") } placeholder={ _t("Filter your rooms and spaces") }
onSearch={setQuery} onSearch={setQuery}
autoComplete={true} autoComplete={true}
autoFocus={true}
/> />
<AutoHideScrollbar className="mx_AddExistingToSpaceDialog_content" id="mx_AddExistingToSpaceDialog"> <AutoHideScrollbar className="mx_AddExistingToSpaceDialog_content" id="mx_AddExistingToSpaceDialog">
{ rooms.length > 0 ? ( { rooms.length > 0 ? (

View file

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

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2020 The Matrix.org Foundation C.I.C. Copyright 2020-2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -110,7 +110,7 @@ export default class ServerPickerDialog extends React.PureComponent<IProps, ISta
console.error(e); console.error(e);
const stateForError = AutoDiscoveryUtils.authComponentStateForError(e); const stateForError = AutoDiscoveryUtils.authComponentStateForError(e);
if (stateForError.isFatalError) { if (stateForError.serverErrorIsFatal) {
let error = _t("Unable to validate homeserver"); let error = _t("Unable to validate homeserver");
if (e.translatedMessage) { if (e.translatedMessage) {
error = e.translatedMessage; error = e.translatedMessage;
@ -168,7 +168,7 @@ export default class ServerPickerDialog extends React.PureComponent<IProps, ISta
text = _t("Matrix.org is the biggest public homeserver in the world, so its a good place for many."); text = _t("Matrix.org is the biggest public homeserver in the world, so its a good place for many.");
} }
let defaultServerName = this.defaultServer.hsName; let defaultServerName: React.ReactNode = this.defaultServer.hsName;
if (this.defaultServer.hsNameIsDifferent) { if (this.defaultServer.hsNameIsDifferent) {
defaultServerName = ( defaultServerName = (
<TextWithTooltip class="mx_Login_underlinedServerName" tooltip={this.defaultServer.hsUrl}> <TextWithTooltip class="mx_Login_underlinedServerName" tooltip={this.defaultServer.hsUrl}>

View file

@ -34,16 +34,15 @@ import {RoomPermalinkCreator} from "../../../utils/permalinks/Permalinks"
import {MatrixEvent} from "matrix-js-sdk/src/models/event"; import {MatrixEvent} from "matrix-js-sdk/src/models/event";
import {normalizeWheelEvent} from "../../../utils/Mouse"; import {normalizeWheelEvent} from "../../../utils/Mouse";
const MIN_ZOOM = 100; // Max scale to keep gaps around the image
const MAX_ZOOM = 300; const MAX_SCALE = 0.95;
// This is used for the buttons // This is used for the buttons
const ZOOM_STEP = 10; const ZOOM_STEP = 0.10;
// This is used for mouse wheel events // This is used for mouse wheel events
const ZOOM_COEFFICIENT = 0.5; const ZOOM_COEFFICIENT = 0.0025;
// If we have moved only this much we can zoom // If we have moved only this much we can zoom
const ZOOM_DISTANCE = 10; const ZOOM_DISTANCE = 10;
interface IProps { interface IProps {
src: string, // the source of the image being displayed src: string, // the source of the image being displayed
name?: string, // the main title ('name') for the image name?: string, // the main title ('name') for the image
@ -62,8 +61,10 @@ interface IProps {
} }
interface IState { interface IState {
rotation: number,
zoom: number, zoom: number,
minZoom: number,
maxZoom: number,
rotation: number,
translationX: number, translationX: number,
translationY: number, translationY: number,
moving: boolean, moving: boolean,
@ -75,8 +76,10 @@ export default class ImageView extends React.Component<IProps, IState> {
constructor(props) { constructor(props) {
super(props); super(props);
this.state = { this.state = {
zoom: 0,
minZoom: MAX_SCALE,
maxZoom: MAX_SCALE,
rotation: 0, rotation: 0,
zoom: MIN_ZOOM,
translationX: 0, translationX: 0,
translationY: 0, translationY: 0,
moving: false, moving: false,
@ -87,6 +90,8 @@ export default class ImageView extends React.Component<IProps, IState> {
// XXX: Refs to functional components // XXX: Refs to functional components
private contextMenuButton = createRef<any>(); private contextMenuButton = createRef<any>();
private focusLock = createRef<any>(); private focusLock = createRef<any>();
private imageWrapper = createRef<HTMLDivElement>();
private image = createRef<HTMLImageElement>();
private initX = 0; private initX = 0;
private initY = 0; private initY = 0;
@ -99,12 +104,87 @@ export default class ImageView extends React.Component<IProps, IState> {
// We have to use addEventListener() because the listener // We have to use addEventListener() because the listener
// needs to be passive in order to work with Chromium // needs to be passive in order to work with Chromium
this.focusLock.current.addEventListener('wheel', this.onWheel, { passive: false }); this.focusLock.current.addEventListener('wheel', this.onWheel, { passive: false });
// We want to recalculate zoom whenever the window's size changes
window.addEventListener("resize", this.calculateZoom);
// After the image loads for the first time we want to calculate the zoom
this.image.current.addEventListener("load", this.calculateZoom);
// Try to precalculate the zoom from width and height props
this.calculateZoom();
} }
componentWillUnmount() { componentWillUnmount() {
this.focusLock.current.removeEventListener('wheel', this.onWheel); this.focusLock.current.removeEventListener('wheel', this.onWheel);
} }
private calculateZoom = () => {
const image = this.image.current;
const imageWrapper = this.imageWrapper.current;
const width = this.props.width || image.naturalWidth;
const height = this.props.height || image.naturalHeight;
const zoomX = imageWrapper.clientWidth / width;
const zoomY = imageWrapper.clientHeight / height;
// If the image is smaller in both dimensions set its the zoom to 1 to
// display it in its original size
if (zoomX >= 1 && zoomY >= 1) {
this.setState({
zoom: 1,
minZoom: 1,
maxZoom: 1,
});
return;
}
// We set minZoom to the min of the zoomX and zoomY to avoid overflow in
// any direction. We also multiply by MAX_SCALE to get a gap around the
// image by default
const minZoom = Math.min(zoomX, zoomY) * MAX_SCALE;
if (this.state.zoom <= this.state.minZoom) this.setState({zoom: minZoom});
this.setState({
minZoom: minZoom,
maxZoom: 1,
});
}
private zoom(delta: number) {
const newZoom = this.state.zoom + delta;
if (newZoom <= this.state.minZoom) {
this.setState({
zoom: this.state.minZoom,
translationX: 0,
translationY: 0,
});
return;
}
if (newZoom >= this.state.maxZoom) {
this.setState({zoom: this.state.maxZoom});
return;
}
this.setState({
zoom: newZoom,
});
}
private onWheel = (ev: WheelEvent) => {
ev.stopPropagation();
ev.preventDefault();
const {deltaY} = normalizeWheelEvent(ev);
this.zoom(-(deltaY * ZOOM_COEFFICIENT));
};
private onZoomInClick = () => {
this.zoom(ZOOM_STEP);
};
private onZoomOutClick = () => {
this.zoom(-ZOOM_STEP);
};
private onKeyDown = (ev: KeyboardEvent) => { private onKeyDown = (ev: KeyboardEvent) => {
if (ev.key === Key.ESCAPE) { if (ev.key === Key.ESCAPE) {
ev.stopPropagation(); ev.stopPropagation();
@ -113,31 +193,6 @@ export default class ImageView extends React.Component<IProps, IState> {
} }
}; };
private onWheel = (ev: WheelEvent) => {
ev.stopPropagation();
ev.preventDefault();
const {deltaY} = normalizeWheelEvent(ev);
const newZoom = this.state.zoom - (deltaY * ZOOM_COEFFICIENT);
if (newZoom <= MIN_ZOOM) {
this.setState({
zoom: MIN_ZOOM,
translationX: 0,
translationY: 0,
});
return;
}
if (newZoom >= MAX_ZOOM) {
this.setState({zoom: MAX_ZOOM});
return;
}
this.setState({
zoom: newZoom,
});
};
private onRotateCounterClockwiseClick = () => { private onRotateCounterClockwiseClick = () => {
const cur = this.state.rotation; const cur = this.state.rotation;
const rotationDegrees = cur - 90; const rotationDegrees = cur - 90;
@ -150,31 +205,6 @@ export default class ImageView extends React.Component<IProps, IState> {
this.setState({ rotation: rotationDegrees }); this.setState({ rotation: rotationDegrees });
}; };
private onZoomInClick = () => {
if (this.state.zoom >= MAX_ZOOM) {
this.setState({zoom: MAX_ZOOM});
return;
}
this.setState({
zoom: this.state.zoom + ZOOM_STEP,
});
};
private onZoomOutClick = () => {
if (this.state.zoom <= MIN_ZOOM) {
this.setState({
zoom: MIN_ZOOM,
translationX: 0,
translationY: 0,
});
return;
}
this.setState({
zoom: this.state.zoom - ZOOM_STEP,
});
};
private onDownloadClick = () => { private onDownloadClick = () => {
const a = document.createElement("a"); const a = document.createElement("a");
a.href = this.props.src; a.href = this.props.src;
@ -217,8 +247,8 @@ export default class ImageView extends React.Component<IProps, IState> {
if (ev.button !== 0) return; if (ev.button !== 0) return;
// Zoom in if we are completely zoomed out // Zoom in if we are completely zoomed out
if (this.state.zoom === MIN_ZOOM) { if (this.state.zoom === this.state.minZoom) {
this.setState({zoom: MAX_ZOOM}); this.setState({zoom: this.state.maxZoom});
return; return;
} }
@ -251,7 +281,7 @@ export default class ImageView extends React.Component<IProps, IState> {
Math.abs(this.state.translationY - this.previousY) < ZOOM_DISTANCE Math.abs(this.state.translationY - this.previousY) < ZOOM_DISTANCE
) { ) {
this.setState({ this.setState({
zoom: MIN_ZOOM, zoom: this.state.minZoom,
translationX: 0, translationX: 0,
translationY: 0, translationY: 0,
}); });
@ -286,17 +316,20 @@ export default class ImageView extends React.Component<IProps, IState> {
render() { render() {
const showEventMeta = !!this.props.mxEvent; const showEventMeta = !!this.props.mxEvent;
const zoomingDisabled = this.state.maxZoom === this.state.minZoom;
let cursor; let cursor;
if (this.state.moving) { if (this.state.moving) {
cursor= "grabbing"; cursor= "grabbing";
} else if (this.state.zoom === MIN_ZOOM) { } else if (zoomingDisabled) {
cursor = "default";
} else if (this.state.zoom === this.state.minZoom) {
cursor = "zoom-in"; cursor = "zoom-in";
} else { } else {
cursor = "zoom-out"; cursor = "zoom-out";
} }
const rotationDegrees = this.state.rotation + "deg"; const rotationDegrees = this.state.rotation + "deg";
const zoomPercentage = this.state.zoom/100; const zoom = this.state.zoom;
const translatePixelsX = this.state.translationX + "px"; const translatePixelsX = this.state.translationX + "px";
const translatePixelsY = this.state.translationY + "px"; const translatePixelsY = this.state.translationY + "px";
// The order of the values is important! // The order of the values is important!
@ -308,7 +341,7 @@ export default class ImageView extends React.Component<IProps, IState> {
transition: this.state.moving ? null : "transform 200ms ease 0s", transition: this.state.moving ? null : "transform 200ms ease 0s",
transform: `translateX(${translatePixelsX}) transform: `translateX(${translatePixelsX})
translateY(${translatePixelsY}) translateY(${translatePixelsY})
scale(${zoomPercentage}) scale(${zoom})
rotate(${rotationDegrees})`, rotate(${rotationDegrees})`,
}; };
@ -380,6 +413,25 @@ export default class ImageView extends React.Component<IProps, IState> {
); );
} }
let zoomOutButton;
let zoomInButton;
if (!zoomingDisabled) {
zoomOutButton = (
<AccessibleTooltipButton
className="mx_ImageView_button mx_ImageView_button_zoomOut"
title={_t("Zoom out")}
onClick={this.onZoomOutClick}>
</AccessibleTooltipButton>
);
zoomInButton = (
<AccessibleTooltipButton
className="mx_ImageView_button mx_ImageView_button_zoomIn"
title={_t("Zoom in")}
onClick={ this.onZoomInClick }>
</AccessibleTooltipButton>
);
}
return ( return (
<FocusLock <FocusLock
returnFocus={true} returnFocus={true}
@ -403,16 +455,8 @@ export default class ImageView extends React.Component<IProps, IState> {
title={_t("Rotate Left")} title={_t("Rotate Left")}
onClick={ this.onRotateCounterClockwiseClick }> onClick={ this.onRotateCounterClockwiseClick }>
</AccessibleTooltipButton> </AccessibleTooltipButton>
<AccessibleTooltipButton {zoomOutButton}
className="mx_ImageView_button mx_ImageView_button_zoomOut" {zoomInButton}
title={_t("Zoom out")}
onClick={ this.onZoomOutClick }>
</AccessibleTooltipButton>
<AccessibleTooltipButton
className="mx_ImageView_button mx_ImageView_button_zoomIn"
title={_t("Zoom in")}
onClick={ this.onZoomInClick }>
</AccessibleTooltipButton>
<AccessibleTooltipButton <AccessibleTooltipButton
className="mx_ImageView_button mx_ImageView_button_download" className="mx_ImageView_button mx_ImageView_button_download"
title={_t("Download")} title={_t("Download")}
@ -427,11 +471,14 @@ export default class ImageView extends React.Component<IProps, IState> {
{this.renderContextMenu()} {this.renderContextMenu()}
</div> </div>
</div> </div>
<div className="mx_ImageView_image_wrapper"> <div
className="mx_ImageView_image_wrapper"
ref={this.imageWrapper}>
<img <img
src={this.props.src} src={this.props.src}
title={this.props.name} title={this.props.name}
style={style} style={style}
ref={this.image}
className="mx_ImageView_image" className="mx_ImageView_image"
draggable={true} draggable={true}
onMouseDown={this.onStartMoving} onMouseDown={this.onStartMoving}

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2020 The Matrix.org Foundation C.I.C. Copyright 2020-2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -67,7 +67,7 @@ const ServerPicker = ({ title, dialogTitle, serverConfig, onServerConfigChange }
</AccessibleButton>; </AccessibleButton>;
} }
let serverName = serverConfig.isNameResolvable ? serverConfig.hsName : serverConfig.hsUrl; let serverName: React.ReactNode = serverConfig.isNameResolvable ? serverConfig.hsName : serverConfig.hsUrl;
if (serverConfig.hsNameIsDifferent) { if (serverConfig.hsNameIsDifferent) {
serverName = <TextWithTooltip class="mx_Login_underlinedServerName" tooltip={serverConfig.hsUrl}> serverName = <TextWithTooltip class="mx_Login_underlinedServerName" tooltip={serverConfig.hsUrl}>
{serverConfig.hsName} {serverConfig.hsName}

View file

@ -160,7 +160,6 @@ export default class EditHistoryMessage extends React.PureComponent {
"mx_EventTile": true, "mx_EventTile": true,
// Note: we keep the `sending` state class for tests, not for our styles // Note: we keep the `sending` state class for tests, not for our styles
"mx_EventTile_sending": isSending, "mx_EventTile_sending": isSending,
"mx_EventTile_notSent": this.state.sendStatus === 'not_sent',
}); });
return ( return (
<li> <li>

View file

@ -185,9 +185,8 @@ export default class MImageBody extends React.Component {
// So either we need to support custom timeline widths here, or reimpose the cap, otherwise the // So either we need to support custom timeline widths here, or reimpose the cap, otherwise the
// thumbnail resolution will be unnecessarily reduced. // thumbnail resolution will be unnecessarily reduced.
// custom timeline widths seems preferable. // custom timeline widths seems preferable.
const pixelRatio = window.devicePixelRatio; const thumbWidth = 800;
const thumbWidth = Math.round(800 * pixelRatio); const thumbHeight = 600;
const thumbHeight = Math.round(600 * pixelRatio);
const content = this.props.mxEvent.getContent(); const content = this.props.mxEvent.getContent();
const media = mediaFromContent(content); const media = mediaFromContent(content);
@ -218,7 +217,7 @@ export default class MImageBody extends React.Component {
const info = content.info; const info = content.info;
if ( if (
this._isGif() || this._isGif() ||
pixelRatio === 1.0 || window.devicePixelRatio === 1.0 ||
(!info || !info.w || !info.h || !info.size) (!info || !info.w || !info.h || !info.size)
) { ) {
return media.getThumbnailOfSourceHttp(thumbWidth, thumbHeight); return media.getThumbnailOfSourceHttp(thumbWidth, thumbHeight);

View file

@ -29,6 +29,8 @@ import RoomContext from "../../../contexts/RoomContext";
import Toolbar from "../../../accessibility/Toolbar"; import Toolbar from "../../../accessibility/Toolbar";
import {RovingAccessibleTooltipButton, useRovingTabIndex} from "../../../accessibility/RovingTabIndex"; import {RovingAccessibleTooltipButton, useRovingTabIndex} from "../../../accessibility/RovingTabIndex";
import {replaceableComponent} from "../../../utils/replaceableComponent"; import {replaceableComponent} from "../../../utils/replaceableComponent";
import {canCancel} from "../context_menus/MessageContextMenu";
import Resend from "../../../Resend";
const OptionsButton = ({mxEvent, getTile, getReplyThread, permalinkCreator, onFocusChange}) => { const OptionsButton = ({mxEvent, getTile, getReplyThread, permalinkCreator, onFocusChange}) => {
const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu(); const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu();
@ -169,45 +171,118 @@ export default class MessageActionBar extends React.PureComponent {
}); });
}; };
render() { /**
let reactButton; * Runs a given fn on the set of possible events to test. The first event
let replyButton; * that passes the checkFn will have fn executed on it. Both functions take
let editButton; * a MatrixEvent object. If no particular conditions are needed, checkFn can
* be null/undefined. If no functions pass the checkFn, no action will be
* taken.
* @param {Function} fn The execution function.
* @param {Function} checkFn The test function.
*/
runActionOnFailedEv(fn, checkFn) {
if (!checkFn) checkFn = () => true;
if (isContentActionable(this.props.mxEvent)) { const mxEvent = this.props.mxEvent;
if (this.context.canReact) { const editEvent = mxEvent.replacingEvent();
reactButton = ( const redactEvent = mxEvent.localRedactionEvent();
<ReactButton mxEvent={this.props.mxEvent} reactions={this.props.reactions} onFocusChange={this.onFocusChange} /> const tryOrder = [redactEvent, editEvent, mxEvent];
for (const ev of tryOrder) {
if (ev && checkFn(ev)) {
fn(ev);
break;
}
}
}
onResendClick = (ev) => {
this.runActionOnFailedEv((tarEv) => Resend.resend(tarEv));
};
onCancelClick = (ev) => {
this.runActionOnFailedEv(
(tarEv) => Resend.removeFromQueue(tarEv),
(testEv) => canCancel(testEv.status),
); );
} };
if (this.context.canReply) {
replyButton = <RovingAccessibleTooltipButton render() {
className="mx_MessageActionBar_maskButton mx_MessageActionBar_replyButton" const toolbarOpts = [];
title={_t("Reply")}
onClick={this.onReplyClick}
/>;
}
}
if (canEditContent(this.props.mxEvent)) { if (canEditContent(this.props.mxEvent)) {
editButton = <RovingAccessibleTooltipButton toolbarOpts.push(<RovingAccessibleTooltipButton
className="mx_MessageActionBar_maskButton mx_MessageActionBar_editButton" className="mx_MessageActionBar_maskButton mx_MessageActionBar_editButton"
title={_t("Edit")} title={_t("Edit")}
onClick={this.onEditClick} onClick={this.onEditClick}
/>; key="edit"
/>);
} }
// aria-live=off to not have this read out automatically as navigating around timeline, gets repetitive. const cancelSendingButton = <RovingAccessibleTooltipButton
return <Toolbar className="mx_MessageActionBar" aria-label={_t("Message Actions")} aria-live="off"> className="mx_MessageActionBar_maskButton mx_MessageActionBar_cancelButton"
{reactButton} title={_t("Delete")}
{replyButton} onClick={this.onCancelClick}
{editButton} key="cancel"
<OptionsButton />;
// We show a different toolbar for failed events, so detect that first.
const mxEvent = this.props.mxEvent;
const editStatus = mxEvent.replacingEvent() && mxEvent.replacingEvent().status;
const redactStatus = mxEvent.localRedactionEvent() && mxEvent.localRedactionEvent().status;
const allowCancel = canCancel(mxEvent.status) || canCancel(editStatus) || canCancel(redactStatus);
const isFailed = [mxEvent.status, editStatus, redactStatus].includes("not_sent");
if (allowCancel && isFailed) {
// The resend button needs to appear ahead of the edit button, so insert to the
// start of the opts
toolbarOpts.splice(0, 0, <RovingAccessibleTooltipButton
className="mx_MessageActionBar_maskButton mx_MessageActionBar_resendButton"
title={_t("Retry")}
onClick={this.onResendClick}
key="resend"
/>);
// The delete button should appear last, so we can just drop it at the end
toolbarOpts.push(cancelSendingButton);
} else {
if (isContentActionable(this.props.mxEvent)) {
// Like the resend button, the react and reply buttons need to appear before the edit.
// The only catch is we do the reply button first so that we can make sure the react
// button is the very first button without having to do length checks for `splice()`.
if (this.context.canReply) {
toolbarOpts.splice(0, 0, <RovingAccessibleTooltipButton
className="mx_MessageActionBar_maskButton mx_MessageActionBar_replyButton"
title={_t("Reply")}
onClick={this.onReplyClick}
key="reply"
/>);
}
if (this.context.canReact) {
toolbarOpts.splice(0, 0, <ReactButton
mxEvent={this.props.mxEvent}
reactions={this.props.reactions}
onFocusChange={this.onFocusChange}
key="react"
/>);
}
}
if (allowCancel) {
toolbarOpts.push(cancelSendingButton);
}
// The menu button should be last, so dump it there.
toolbarOpts.push(<OptionsButton
mxEvent={this.props.mxEvent} mxEvent={this.props.mxEvent}
getReplyThread={this.props.getReplyThread} getReplyThread={this.props.getReplyThread}
getTile={this.props.getTile} getTile={this.props.getTile}
permalinkCreator={this.props.permalinkCreator} permalinkCreator={this.props.permalinkCreator}
onFocusChange={this.onFocusChange} onFocusChange={this.onFocusChange}
/> key="menu"
/>);
}
// aria-live=off to not have this read out automatically as navigating around timeline, gets repetitive.
return <Toolbar className="mx_MessageActionBar" aria-label={_t("Message Actions")} aria-live="off">
{toolbarOpts}
</Toolbar>; </Toolbar>;
} }
} }

View file

@ -1,6 +1,6 @@
/* /*
Copyright 2015-2021 The Matrix.org Foundation C.I.C.
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
Copyright 2019 - 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -15,11 +15,13 @@ 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 from 'react';
import PropTypes from 'prop-types';
import classNames from "classnames"; import classNames from "classnames";
import {EventType} from "matrix-js-sdk/src/@types/event";
import {EventStatus} from 'matrix-js-sdk/src/models/event'; import { EventType } from "matrix-js-sdk/src/@types/event";
import { EventStatus, MatrixEvent } from "matrix-js-sdk/src/models/event";
import { Relations } from "matrix-js-sdk/src/models/relations";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import ReplyThread from "../elements/ReplyThread"; import ReplyThread from "../elements/ReplyThread";
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
@ -27,7 +29,7 @@ import * as TextForEvent from "../../../TextForEvent";
import * as sdk from "../../../index"; import * as sdk from "../../../index";
import dis from '../../../dispatcher/dispatcher'; import dis from '../../../dispatcher/dispatcher';
import SettingsStore from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
import {Layout, LayoutPropType} from "../../../settings/Layout"; import {Layout} from "../../../settings/Layout";
import {formatTime} from "../../../DateUtils"; import {formatTime} from "../../../DateUtils";
import {MatrixClientPeg} from '../../../MatrixClientPeg'; import {MatrixClientPeg} from '../../../MatrixClientPeg';
import {ALL_RULE_TYPES} from "../../../mjolnir/BanList"; import {ALL_RULE_TYPES} from "../../../mjolnir/BanList";
@ -40,6 +42,10 @@ import {WIDGET_LAYOUT_EVENT_TYPE} from "../../../stores/widgets/WidgetLayoutStor
import {objectHasDiff} from "../../../utils/objects"; import {objectHasDiff} from "../../../utils/objects";
import {replaceableComponent} from "../../../utils/replaceableComponent"; import {replaceableComponent} from "../../../utils/replaceableComponent";
import Tooltip from "../elements/Tooltip"; import Tooltip from "../elements/Tooltip";
import { EditorStateTransfer } from "../../../utils/EditorStateTransfer";
import { RoomPermalinkCreator } from '../../../utils/permalinks/Permalinks';
import {StaticNotificationState} from "../../../stores/notifications/StaticNotificationState";
import NotificationBadge from "./NotificationBadge";
const eventTileTypes = { const eventTileTypes = {
[EventType.RoomMessage]: 'messages.MessageEvent', [EventType.RoomMessage]: 'messages.MessageEvent',
@ -169,101 +175,130 @@ const MAX_READ_AVATARS = 5;
// | '--------------------------------------' | // | '--------------------------------------' |
// '----------------------------------------------------------' // '----------------------------------------------------------'
@replaceableComponent("views.rooms.EventTile") interface IReadReceiptProps {
export default class EventTile extends React.Component { userId: string;
static propTypes = { roomMember: RoomMember;
/* the MatrixEvent to show */ ts: number;
mxEvent: PropTypes.object.isRequired, }
/* true if mxEvent is redacted. This is a prop because using mxEvent.isRedacted() interface IProps {
* might not be enough when deciding shouldComponentUpdate - prevProps.mxEvent // the MatrixEvent to show
* references the same this.props.mxEvent. mxEvent: MatrixEvent;
*/
isRedacted: PropTypes.bool,
/* true if this is a continuation of the previous event (which has the // true if mxEvent is redacted. This is a prop because using mxEvent.isRedacted()
* effect of not showing another avatar/displayname // might not be enough when deciding shouldComponentUpdate - prevProps.mxEvent
*/ // references the same this.props.mxEvent.
continuation: PropTypes.bool, isRedacted?: boolean;
/* true if this is the last event in the timeline (which has the effect // true if this is a continuation of the previous event (which has the
* of always showing the timestamp) // effect of not showing another avatar/displayname
*/ continuation?: boolean;
last: PropTypes.bool,
// true if this is the last event in the timeline (which has the effect
// of always showing the timestamp)
last?: boolean;
// true if the event is the last event in a section (adds a css class for // true if the event is the last event in a section (adds a css class for
// targeting) // targeting)
lastInSection: PropTypes.bool, lastInSection?: boolean;
// True if the event is the last successful (sent) event. // True if the event is the last successful (sent) event.
isLastSuccessful: PropTypes.bool, lastSuccessful?: boolean;
/* true if this is search context (which has the effect of greying out // true if this is search context (which has the effect of greying out
* the text // the text
*/ contextual?: boolean;
contextual: PropTypes.bool,
/* a list of words to highlight, ordered by longest first */ // a list of words to highlight, ordered by longest first
highlights: PropTypes.array, highlights?: string[];
/* link URL for the highlights */ // link URL for the highlights
highlightLink: PropTypes.string, highlightLink?: string;
/* should show URL previews for this event */ // should show URL previews for this event
showUrlPreview: PropTypes.bool, showUrlPreview?: boolean;
/* is this the focused event */ // is this the focused event
isSelectedEvent: PropTypes.bool, isSelectedEvent?: boolean;
/* callback called when dynamic content in events are loaded */ // callback called when dynamic content in events are loaded
onHeightChanged: PropTypes.func, onHeightChanged?: () => void;
/* a list of read-receipts we should show. Each object has a 'roomMember' and 'ts'. */ // a list of read-receipts we should show. Each object has a 'roomMember' and 'ts'.
readReceipts: PropTypes.arrayOf(PropTypes.object), readReceipts?: IReadReceiptProps[];
/* opaque readreceipt info for each userId; used by ReadReceiptMarker // opaque readreceipt info for each userId; used by ReadReceiptMarker
* to manage its animations. Should be an empty object when the room // to manage its animations. Should be an empty object when the room
* first loads // first loads
*/ readReceiptMap?: any;
readReceiptMap: PropTypes.object,
/* A function which is used to check if the parent panel is being // A function which is used to check if the parent panel is being
* unmounted, to avoid unnecessary work. Should return true if we // unmounted, to avoid unnecessary work. Should return true if we
* are being unmounted. // are being unmounted.
*/ checkUnmounting?: () => boolean;
checkUnmounting: PropTypes.func,
/* the status of this event - ie, mxEvent.status. Denormalised to here so // the status of this event - ie, mxEvent.status. Denormalised to here so
* that we can tell when it changes. */ // that we can tell when it changes.
eventSendStatus: PropTypes.string, eventSendStatus?: string;
/* the shape of the tile. by default, the layout is intended for the // the shape of the tile. by default, the layout is intended for the
* normal room timeline. alternative values are: "file_list", "file_grid" // normal room timeline. alternative values are: "file_list", "file_grid"
* and "notif". This could be done by CSS, but it'd be horribly inefficient. // and "notif". This could be done by CSS, but it'd be horribly inefficient.
* It could also be done by subclassing EventTile, but that'd be quite // It could also be done by subclassing EventTile, but that'd be quite
* boiilerplatey. So just make the necessary render decisions conditional // boiilerplatey. So just make the necessary render decisions conditional
* for now. // for now.
*/ tileShape?: 'notif' | 'file_grid' | 'reply' | 'reply_preview';
tileShape: PropTypes.string,
// show twelve hour timestamps // show twelve hour timestamps
isTwelveHour: PropTypes.bool, isTwelveHour?: boolean;
// helper function to access relations for this event // helper function to access relations for this event
getRelationsForEvent: PropTypes.func, getRelationsForEvent?: (eventId: string, relationType: string, eventType: string) => Relations;
// whether to show reactions for this event // whether to show reactions for this event
showReactions: PropTypes.bool, showReactions?: boolean;
// which layout to use // which layout to use
layout: LayoutPropType, layout: Layout;
// whether or not to show flair at all // whether or not to show flair at all
enableFlair: PropTypes.bool, enableFlair?: boolean;
// whether or not to show read receipts // whether or not to show read receipts
showReadReceipts: PropTypes.bool, showReadReceipts?: boolean;
};
// Used while editing, to pass the event, and to preserve editor state
// from one editor instance to another when remounting the editor
// upon receiving the remote echo for an unsent event.
editState?: EditorStateTransfer;
// Event ID of the event replacing the content of this event, if any
replacingEventId?: string;
// Helper to build permalinks for the room
permalinkCreator?: RoomPermalinkCreator;
}
interface IState {
// Whether the action bar is focused.
actionBarFocused: boolean;
// Whether all read receipts are being displayed. If not, only display
// a truncation of them.
allReadAvatars: boolean;
// Whether the event's sender has been verified.
verified: string;
// Whether onRequestKeysClick has been called since mounting.
previouslyRequestedKeys: boolean;
// The Relations model from the JS SDK for reactions to `mxEvent`
reactions: Relations;
}
@replaceableComponent("views.rooms.EventTile")
export default class EventTile extends React.Component<IProps, IState> {
private suppressReadReceiptAnimation: boolean;
private isListeningForReceipts: boolean;
private tile = React.createRef();
private replyThread = React.createRef();
static defaultProps = { static defaultProps = {
// no-op function because onHeightChanged is optional yet some sub-components assume its existence // no-op function because onHeightChanged is optional yet some sub-components assume its existence
@ -290,26 +325,22 @@ export default class EventTile extends React.Component {
}; };
// don't do RR animations until we are mounted // don't do RR animations until we are mounted
this._suppressReadReceiptAnimation = true; this.suppressReadReceiptAnimation = true;
this._tile = createRef();
this._replyThread = createRef();
// Throughout the component we manage a read receipt listener to see if our tile still // Throughout the component we manage a read receipt listener to see if our tile still
// qualifies for a "sent" or "sending" state (based on their relevant conditions). We // qualifies for a "sent" or "sending" state (based on their relevant conditions). We
// don't want to over-subscribe to the read receipt events being fired, so we use a flag // don't want to over-subscribe to the read receipt events being fired, so we use a flag
// to determine if we've already subscribed and use a combination of other flags to find // to determine if we've already subscribed and use a combination of other flags to find
// out if we should even be subscribed at all. // out if we should even be subscribed at all.
this._isListeningForReceipts = false; this.isListeningForReceipts = false;
} }
/** /**
* When true, the tile qualifies for some sort of special read receipt. This could be a 'sending' * When true, the tile qualifies for some sort of special read receipt. This could be a 'sending'
* or 'sent' receipt, for example. * or 'sent' receipt, for example.
* @returns {boolean} * @returns {boolean}
* @private
*/ */
get _isEligibleForSpecialReceipt() { private get isEligibleForSpecialReceipt() {
// First, if there are other read receipts then just short-circuit this. // First, if there are other read receipts then just short-circuit this.
if (this.props.readReceipts && this.props.readReceipts.length > 0) return false; if (this.props.readReceipts && this.props.readReceipts.length > 0) return false;
if (!this.props.mxEvent) return false; if (!this.props.mxEvent) return false;
@ -338,9 +369,9 @@ export default class EventTile extends React.Component {
return true; return true;
} }
get _shouldShowSentReceipt() { private get shouldShowSentReceipt() {
// If we're not even eligible, don't show the receipt. // If we're not even eligible, don't show the receipt.
if (!this._isEligibleForSpecialReceipt) return false; if (!this.isEligibleForSpecialReceipt) return false;
// We only show the 'sent' receipt on the last successful event. // We only show the 'sent' receipt on the last successful event.
if (!this.props.lastSuccessful) return false; if (!this.props.lastSuccessful) return false;
@ -358,9 +389,9 @@ export default class EventTile extends React.Component {
return true; return true;
} }
get _shouldShowSendingReceipt() { private get shouldShowSendingReceipt() {
// If we're not even eligible, don't show the receipt. // If we're not even eligible, don't show the receipt.
if (!this._isEligibleForSpecialReceipt) return false; if (!this.isEligibleForSpecialReceipt) return false;
// Check the event send status to see if we are pending. Null/undefined status means the // Check the event send status to see if we are pending. Null/undefined status means the
// message was sent, so check for that and 'sent' explicitly. // message was sent, so check for that and 'sent' explicitly.
@ -374,22 +405,22 @@ export default class EventTile extends React.Component {
// TODO: [REACT-WARNING] Move into constructor // TODO: [REACT-WARNING] Move into constructor
// eslint-disable-next-line camelcase // eslint-disable-next-line camelcase
UNSAFE_componentWillMount() { UNSAFE_componentWillMount() {
this._verifyEvent(this.props.mxEvent); this.verifyEvent(this.props.mxEvent);
} }
componentDidMount() { componentDidMount() {
this._suppressReadReceiptAnimation = false; this.suppressReadReceiptAnimation = false;
const client = this.context; const client = this.context;
client.on("deviceVerificationChanged", this.onDeviceVerificationChanged); client.on("deviceVerificationChanged", this.onDeviceVerificationChanged);
client.on("userTrustStatusChanged", this.onUserVerificationChanged); client.on("userTrustStatusChanged", this.onUserVerificationChanged);
this.props.mxEvent.on("Event.decrypted", this._onDecrypted); this.props.mxEvent.on("Event.decrypted", this.onDecrypted);
if (this.props.showReactions) { if (this.props.showReactions) {
this.props.mxEvent.on("Event.relationsCreated", this._onReactionsCreated); this.props.mxEvent.on("Event.relationsCreated", this.onReactionsCreated);
} }
if (this._shouldShowSentReceipt || this._shouldShowSendingReceipt) { if (this.shouldShowSentReceipt || this.shouldShowSendingReceipt) {
client.on("Room.receipt", this._onRoomReceipt); client.on("Room.receipt", this.onRoomReceipt);
this._isListeningForReceipts = true; this.isListeningForReceipts = true;
} }
} }
@ -399,7 +430,7 @@ export default class EventTile extends React.Component {
// re-check the sender verification as outgoing events progress through // re-check the sender verification as outgoing events progress through
// the send process. // the send process.
if (nextProps.eventSendStatus !== this.props.eventSendStatus) { if (nextProps.eventSendStatus !== this.props.eventSendStatus) {
this._verifyEvent(nextProps.mxEvent); this.verifyEvent(nextProps.mxEvent);
} }
} }
@ -408,35 +439,35 @@ export default class EventTile extends React.Component {
return true; return true;
} }
return !this._propsEqual(this.props, nextProps); return !this.propsEqual(this.props, nextProps);
} }
componentWillUnmount() { componentWillUnmount() {
const client = this.context; const client = this.context;
client.removeListener("deviceVerificationChanged", this.onDeviceVerificationChanged); client.removeListener("deviceVerificationChanged", this.onDeviceVerificationChanged);
client.removeListener("userTrustStatusChanged", this.onUserVerificationChanged); client.removeListener("userTrustStatusChanged", this.onUserVerificationChanged);
client.removeListener("Room.receipt", this._onRoomReceipt); client.removeListener("Room.receipt", this.onRoomReceipt);
this._isListeningForReceipts = false; this.isListeningForReceipts = false;
this.props.mxEvent.removeListener("Event.decrypted", this._onDecrypted); this.props.mxEvent.removeListener("Event.decrypted", this.onDecrypted);
if (this.props.showReactions) { if (this.props.showReactions) {
this.props.mxEvent.removeListener("Event.relationsCreated", this._onReactionsCreated); this.props.mxEvent.removeListener("Event.relationsCreated", this.onReactionsCreated);
} }
} }
componentDidUpdate(prevProps, prevState, snapshot) { componentDidUpdate(prevProps, prevState, snapshot) {
// If we're not listening for receipts and expect to be, register a listener. // If we're not listening for receipts and expect to be, register a listener.
if (!this._isListeningForReceipts && (this._shouldShowSentReceipt || this._shouldShowSendingReceipt)) { if (!this.isListeningForReceipts && (this.shouldShowSentReceipt || this.shouldShowSendingReceipt)) {
this.context.on("Room.receipt", this._onRoomReceipt); this.context.on("Room.receipt", this.onRoomReceipt);
this._isListeningForReceipts = true; this.isListeningForReceipts = true;
} }
} }
_onRoomReceipt = (ev, room) => { private onRoomReceipt = (ev, room) => {
// ignore events for other rooms // ignore events for other rooms
const tileRoom = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId()); const tileRoom = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId());
if (room !== tileRoom) return; if (room !== tileRoom) return;
if (!this._shouldShowSentReceipt && !this._shouldShowSendingReceipt && !this._isListeningForReceipts) { if (!this.shouldShowSentReceipt && !this.shouldShowSendingReceipt && !this.isListeningForReceipts) {
return; return;
} }
@ -444,36 +475,36 @@ export default class EventTile extends React.Component {
// the getters we use here to determine what needs rendering. // the getters we use here to determine what needs rendering.
this.forceUpdate(() => { this.forceUpdate(() => {
// Per elsewhere in this file, we can remove the listener once we will have no further purpose for it. // Per elsewhere in this file, we can remove the listener once we will have no further purpose for it.
if (!this._shouldShowSentReceipt && !this._shouldShowSendingReceipt) { if (!this.shouldShowSentReceipt && !this.shouldShowSendingReceipt) {
this.context.removeListener("Room.receipt", this._onRoomReceipt); this.context.removeListener("Room.receipt", this.onRoomReceipt);
this._isListeningForReceipts = false; this.isListeningForReceipts = false;
} }
}); });
}; };
/** called when the event is decrypted after we show it. /** called when the event is decrypted after we show it.
*/ */
_onDecrypted = () => { private onDecrypted = () => {
// we need to re-verify the sending device. // we need to re-verify the sending device.
// (we call onHeightChanged in _verifyEvent to handle the case where decryption // (we call onHeightChanged in verifyEvent to handle the case where decryption
// has caused a change in size of the event tile) // has caused a change in size of the event tile)
this._verifyEvent(this.props.mxEvent); this.verifyEvent(this.props.mxEvent);
this.forceUpdate(); this.forceUpdate();
}; };
onDeviceVerificationChanged = (userId, device) => { private onDeviceVerificationChanged = (userId, device) => {
if (userId === this.props.mxEvent.getSender()) { if (userId === this.props.mxEvent.getSender()) {
this._verifyEvent(this.props.mxEvent); this.verifyEvent(this.props.mxEvent);
} }
}; };
onUserVerificationChanged = (userId, _trustStatus) => { private onUserVerificationChanged = (userId, _trustStatus) => {
if (userId === this.props.mxEvent.getSender()) { if (userId === this.props.mxEvent.getSender()) {
this._verifyEvent(this.props.mxEvent); this.verifyEvent(this.props.mxEvent);
} }
}; };
async _verifyEvent(mxEvent) { private async verifyEvent(mxEvent) {
if (!mxEvent.isEncrypted()) { if (!mxEvent.isEncrypted()) {
return; return;
} }
@ -527,7 +558,7 @@ export default class EventTile extends React.Component {
}, this.props.onHeightChanged); // Decryption may have caused a change in size }, this.props.onHeightChanged); // Decryption may have caused a change in size
} }
_propsEqual(objA, objB) { private propsEqual(objA, objB) {
const keysA = Object.keys(objA); const keysA = Object.keys(objA);
const keysB = Object.keys(objB); const keysB = Object.keys(objB);
@ -594,7 +625,7 @@ export default class EventTile extends React.Component {
}; };
getReadAvatars() { getReadAvatars() {
if (this._shouldShowSentReceipt || this._shouldShowSendingReceipt) { if (this.shouldShowSentReceipt || this.shouldShowSendingReceipt) {
return <SentReceipt messageState={this.props.mxEvent.getAssociatedStatus()} />; return <SentReceipt messageState={this.props.mxEvent.getAssociatedStatus()} />;
} }
@ -641,7 +672,7 @@ export default class EventTile extends React.Component {
leftOffset={left} hidden={hidden} leftOffset={left} hidden={hidden}
readReceiptInfo={readReceiptInfo} readReceiptInfo={readReceiptInfo}
checkUnmounting={this.props.checkUnmounting} checkUnmounting={this.props.checkUnmounting}
suppressAnimation={this._suppressReadReceiptAnimation} suppressAnimation={this.suppressReadReceiptAnimation}
onClick={this.toggleAllReadAvatars} onClick={this.toggleAllReadAvatars}
timestamp={receipt.ts} timestamp={receipt.ts}
showTwelveHour={this.props.isTwelveHour} showTwelveHour={this.props.isTwelveHour}
@ -698,7 +729,7 @@ export default class EventTile extends React.Component {
}); });
}; };
_renderE2EPadlock() { private renderE2EPadlock() {
const ev = this.props.mxEvent; const ev = this.props.mxEvent;
// event could not be decrypted // event could not be decrypted
@ -747,9 +778,9 @@ export default class EventTile extends React.Component {
}); });
}; };
getTile = () => this._tile.current; getTile = () => this.tile.current;
getReplyThread = () => this._replyThread.current; getReplyThread = () => this.replyThread.current;
getReactions = () => { getReactions = () => {
if ( if (
@ -769,11 +800,11 @@ export default class EventTile extends React.Component {
return this.props.getRelationsForEvent(eventId, "m.annotation", "m.reaction"); return this.props.getRelationsForEvent(eventId, "m.annotation", "m.reaction");
}; };
_onReactionsCreated = (relationType, eventType) => { private onReactionsCreated = (relationType, eventType) => {
if (relationType !== "m.annotation" || eventType !== "m.reaction") { if (relationType !== "m.annotation" || eventType !== "m.reaction") {
return; return;
} }
this.props.mxEvent.removeListener("Event.relationsCreated", this._onReactionsCreated); this.props.mxEvent.removeListener("Event.relationsCreated", this.onReactionsCreated);
this.setState({ this.setState({
reactions: this.getReactions(), reactions: this.getReactions(),
}); });
@ -838,7 +869,6 @@ export default class EventTile extends React.Component {
mx_EventTile_12hr: this.props.isTwelveHour, mx_EventTile_12hr: this.props.isTwelveHour,
// Note: we keep the `sending` state class for tests, not for our styles // Note: we keep the `sending` state class for tests, not for our styles
mx_EventTile_sending: !isEditing && isSending, mx_EventTile_sending: !isEditing && isSending,
mx_EventTile_notSent: this.props.eventSendStatus === 'not_sent',
mx_EventTile_highlight: this.props.tileShape === 'notif' ? false : this.shouldHighlight(), mx_EventTile_highlight: this.props.tileShape === 'notif' ? false : this.shouldHighlight(),
mx_EventTile_selected: this.props.isSelectedEvent, mx_EventTile_selected: this.props.isSelectedEvent,
mx_EventTile_continuation: this.props.tileShape ? '' : this.props.continuation, mx_EventTile_continuation: this.props.tileShape ? '' : this.props.continuation,
@ -913,7 +943,8 @@ export default class EventTile extends React.Component {
if (!this.props.tileShape || this.props.tileShape === 'reply' || this.props.tileShape === 'reply_preview') { if (!this.props.tileShape || this.props.tileShape === 'reply' || this.props.tileShape === 'reply_preview') {
sender = <SenderProfile onClick={this.onSenderProfileClick} sender = <SenderProfile onClick={this.onSenderProfileClick}
mxEvent={this.props.mxEvent} mxEvent={this.props.mxEvent}
enableFlair={this.props.enableFlair} />; enableFlair={this.props.enableFlair}
/>;
} else { } else {
sender = <SenderProfile mxEvent={this.props.mxEvent} enableFlair={this.props.enableFlair} />; sender = <SenderProfile mxEvent={this.props.mxEvent} enableFlair={this.props.enableFlair} />;
} }
@ -986,8 +1017,8 @@ export default class EventTile extends React.Component {
const useIRCLayout = this.props.layout == Layout.IRC; const useIRCLayout = this.props.layout == Layout.IRC;
const groupTimestamp = !useIRCLayout ? linkedTimestamp : null; const groupTimestamp = !useIRCLayout ? linkedTimestamp : null;
const ircTimestamp = useIRCLayout ? linkedTimestamp : null; const ircTimestamp = useIRCLayout ? linkedTimestamp : null;
const groupPadlock = !useIRCLayout && !isBubbleMessage && this._renderE2EPadlock(); const groupPadlock = !useIRCLayout && !isBubbleMessage && this.renderE2EPadlock();
const ircPadlock = useIRCLayout && !isBubbleMessage && this._renderE2EPadlock(); const ircPadlock = useIRCLayout && !isBubbleMessage && this.renderE2EPadlock();
let msgOption; let msgOption;
if (this.props.showReadReceipts) { if (this.props.showReadReceipts) {
@ -1018,12 +1049,13 @@ export default class EventTile extends React.Component {
</a> </a>
</div> </div>
<div className="mx_EventTile_line"> <div className="mx_EventTile_line">
<EventTileType ref={this._tile} <EventTileType ref={this.tile}
mxEvent={this.props.mxEvent} mxEvent={this.props.mxEvent}
highlights={this.props.highlights} highlights={this.props.highlights}
highlightLink={this.props.highlightLink} highlightLink={this.props.highlightLink}
showUrlPreview={this.props.showUrlPreview} showUrlPreview={this.props.showUrlPreview}
onHeightChanged={this.props.onHeightChanged} /> onHeightChanged={this.props.onHeightChanged}
/>
</div> </div>
</div> </div>
); );
@ -1032,13 +1064,14 @@ export default class EventTile extends React.Component {
return ( return (
<div className={classes} aria-live={ariaLive} aria-atomic="true"> <div className={classes} aria-live={ariaLive} aria-atomic="true">
<div className="mx_EventTile_line"> <div className="mx_EventTile_line">
<EventTileType ref={this._tile} <EventTileType ref={this.tile}
mxEvent={this.props.mxEvent} mxEvent={this.props.mxEvent}
highlights={this.props.highlights} highlights={this.props.highlights}
highlightLink={this.props.highlightLink} highlightLink={this.props.highlightLink}
showUrlPreview={this.props.showUrlPreview} showUrlPreview={this.props.showUrlPreview}
tileShape={this.props.tileShape} tileShape={this.props.tileShape}
onHeightChanged={this.props.onHeightChanged} /> onHeightChanged={this.props.onHeightChanged}
/>
</div> </div>
<a <a
className="mx_EventTile_senderDetailsLink" className="mx_EventTile_senderDetailsLink"
@ -1062,7 +1095,7 @@ export default class EventTile extends React.Component {
this.props.mxEvent, this.props.mxEvent,
this.props.onHeightChanged, this.props.onHeightChanged,
this.props.permalinkCreator, this.props.permalinkCreator,
this._replyThread, this.replyThread,
); );
} }
return ( return (
@ -1075,13 +1108,14 @@ export default class EventTile extends React.Component {
{ groupTimestamp } { groupTimestamp }
{ groupPadlock } { groupPadlock }
{ thread } { thread }
<EventTileType ref={this._tile} <EventTileType ref={this.tile}
mxEvent={this.props.mxEvent} mxEvent={this.props.mxEvent}
highlights={this.props.highlights} highlights={this.props.highlights}
highlightLink={this.props.highlightLink} highlightLink={this.props.highlightLink}
onHeightChanged={this.props.onHeightChanged} onHeightChanged={this.props.onHeightChanged}
replacingEventId={this.props.replacingEventId} replacingEventId={this.props.replacingEventId}
showUrlPreview={false} /> showUrlPreview={false}
/>
</div> </div>
</div> </div>
); );
@ -1091,7 +1125,7 @@ export default class EventTile extends React.Component {
this.props.mxEvent, this.props.mxEvent,
this.props.onHeightChanged, this.props.onHeightChanged,
this.props.permalinkCreator, this.props.permalinkCreator,
this._replyThread, this.replyThread,
this.props.layout, this.props.layout,
); );
@ -1105,7 +1139,7 @@ export default class EventTile extends React.Component {
{ groupTimestamp } { groupTimestamp }
{ groupPadlock } { groupPadlock }
{ thread } { thread }
<EventTileType ref={this._tile} <EventTileType ref={this.tile}
mxEvent={this.props.mxEvent} mxEvent={this.props.mxEvent}
replacingEventId={this.props.replacingEventId} replacingEventId={this.props.replacingEventId}
editState={this.props.editState} editState={this.props.editState}
@ -1113,7 +1147,8 @@ export default class EventTile extends React.Component {
highlightLink={this.props.highlightLink} highlightLink={this.props.highlightLink}
showUrlPreview={this.props.showUrlPreview} showUrlPreview={this.props.showUrlPreview}
permalinkCreator={this.props.permalinkCreator} permalinkCreator={this.props.permalinkCreator}
onHeightChanged={this.props.onHeightChanged} /> onHeightChanged={this.props.onHeightChanged}
/>
{ keyRequestInfo } { keyRequestInfo }
{ reactionsRow } { reactionsRow }
{ actionBar } { actionBar }
@ -1182,18 +1217,26 @@ function E2ePadlockUnknown(props) {
function E2ePadlockUnauthenticated(props) { function E2ePadlockUnauthenticated(props) {
return ( return (
<E2ePadlock title={_t("The authenticity of this encrypted message can't be guaranteed on this device.")} icon="unauthenticated" {...props} /> <E2ePadlock
title={_t("The authenticity of this encrypted message can't be guaranteed on this device.")}
icon="unauthenticated"
{...props}
/>
); );
} }
class E2ePadlock extends React.Component { interface IE2ePadlockProps {
static propTypes = { icon: string;
icon: PropTypes.string.isRequired, title: string;
title: PropTypes.string.isRequired, }
};
constructor() { interface IE2ePadlockState {
super(); hover: boolean;
}
class E2ePadlock extends React.Component<IE2ePadlockProps, IE2ePadlockState> {
constructor(props) {
super(props);
this.state = { this.state = {
hover: false, hover: false,
@ -1211,14 +1254,13 @@ class E2ePadlock extends React.Component {
render() { render() {
let tooltip = null; let tooltip = null;
if (this.state.hover) { if (this.state.hover) {
tooltip = <Tooltip className="mx_EventTile_e2eIcon_tooltip" label={this.props.title} dir="auto" />; tooltip = <Tooltip className="mx_EventTile_e2eIcon_tooltip" label={this.props.title} />;
} }
const classes = `mx_EventTile_e2eIcon mx_EventTile_e2eIcon_${this.props.icon}`; const classes = `mx_EventTile_e2eIcon mx_EventTile_e2eIcon_${this.props.icon}`;
return ( return (
<div <div
className={classes} className={classes}
onClick={this.onClick}
onMouseEnter={this.onHoverStart} onMouseEnter={this.onHoverStart}
onMouseLeave={this.onHoverEnd} onMouseLeave={this.onHoverEnd}
>{tooltip}</div> >{tooltip}</div>
@ -1235,8 +1277,8 @@ interface ISentReceiptState {
} }
class SentReceipt extends React.PureComponent<ISentReceiptProps, ISentReceiptState> { class SentReceipt extends React.PureComponent<ISentReceiptProps, ISentReceiptState> {
constructor() { constructor(props) {
super(); super(props);
this.state = { this.state = {
hover: false, hover: false,
@ -1253,11 +1295,19 @@ class SentReceipt extends React.PureComponent<ISentReceiptProps, ISentReceiptSta
render() { render() {
const isSent = !this.props.messageState || this.props.messageState === 'sent'; const isSent = !this.props.messageState || this.props.messageState === 'sent';
const isFailed = this.props.messageState === 'not_sent';
const receiptClasses = classNames({ const receiptClasses = classNames({
'mx_EventTile_receiptSent': isSent, 'mx_EventTile_receiptSent': isSent,
'mx_EventTile_receiptSending': !isSent, 'mx_EventTile_receiptSending': !isSent && !isFailed,
}); });
let nonCssBadge = null;
if (isFailed) {
nonCssBadge = <NotificationBadge
notification={StaticNotificationState.RED_EXCLAMATION}
/>;
}
let tooltip = null; let tooltip = null;
if (this.state.hover) { if (this.state.hover) {
let label = _t("Sending your message..."); let label = _t("Sending your message...");
@ -1265,6 +1315,8 @@ class SentReceipt extends React.PureComponent<ISentReceiptProps, ISentReceiptSta
label = _t("Encrypting your message..."); label = _t("Encrypting your message...");
} else if (isSent) { } else if (isSent) {
label = _t("Your message was sent"); label = _t("Your message was sent");
} else if (isFailed) {
label = _t("Failed to send");
} }
// The yOffset is somewhat arbitrary - it just brings the tooltip down to be more associated // The yOffset is somewhat arbitrary - it just brings the tooltip down to be more associated
// with the read receipt. // with the read receipt.
@ -1273,6 +1325,7 @@ class SentReceipt extends React.PureComponent<ISentReceiptProps, ISentReceiptSta
return <span className="mx_EventTile_readAvatars"> return <span className="mx_EventTile_readAvatars">
<span className={receiptClasses} onMouseEnter={this.onHoverStart} onMouseLeave={this.onHoverEnd}> <span className={receiptClasses} onMouseEnter={this.onHoverStart} onMouseLeave={this.onHoverEnd}>
{nonCssBadge}
{tooltip} {tooltip}
</span> </span>
</span>; </span>;

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2015-2018, 2020, 2021 The Matrix.org Foundation C.I.C. Copyright 2015-2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -13,15 +13,18 @@ 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 React from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import {MatrixClientPeg} from '../../../MatrixClientPeg'; import {MatrixClientPeg} from '../../../MatrixClientPeg';
import * as sdk from '../../../index'; import * as sdk from '../../../index';
import {MatrixEvent} from "matrix-js-sdk/src/models/event";
import {Room} from "matrix-js-sdk/src/models/room";
import {RoomMember} from "matrix-js-sdk/src/models/room-member";
import dis from '../../../dispatcher/dispatcher'; import dis from '../../../dispatcher/dispatcher';
import { ActionPayload } from "../../../dispatcher/payloads";
import Stickerpicker from './Stickerpicker'; import Stickerpicker from './Stickerpicker';
import { makeRoomPermalink } from '../../../utils/permalinks/Permalinks'; import { makeRoomPermalink, RoomPermalinkCreator } from '../../../utils/permalinks/Permalinks';
import ContentMessages from '../../../ContentMessages'; import ContentMessages from '../../../ContentMessages';
import E2EIcon from './E2EIcon'; import E2EIcon from './E2EIcon';
import SettingsStore from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
@ -35,19 +38,26 @@ import VoiceRecordComposerTile from "./VoiceRecordComposerTile";
import {VoiceRecordingStore} from "../../../stores/VoiceRecordingStore"; import {VoiceRecordingStore} from "../../../stores/VoiceRecordingStore";
import {RecordingState} from "../../../voice/VoiceRecording"; import {RecordingState} from "../../../voice/VoiceRecording";
import Tooltip, {Alignment} from "../elements/Tooltip"; import Tooltip, {Alignment} from "../elements/Tooltip";
import ResizeNotifier from "../../../utils/ResizeNotifier";
import { E2EStatus } from '../../../utils/ShieldUtils';
import SendMessageComposer from "./SendMessageComposer";
function ComposerAvatar(props) { interface IComposerAvatarProps {
me: object;
}
function ComposerAvatar(props: IComposerAvatarProps) {
const MemberStatusMessageAvatar = sdk.getComponent('avatars.MemberStatusMessageAvatar'); const MemberStatusMessageAvatar = sdk.getComponent('avatars.MemberStatusMessageAvatar');
return <div className="mx_MessageComposer_avatar"> return <div className="mx_MessageComposer_avatar">
<MemberStatusMessageAvatar member={props.me} width={24} height={24} /> <MemberStatusMessageAvatar member={props.me} width={24} height={24} />
</div>; </div>;
} }
ComposerAvatar.propTypes = { interface ISendButtonProps {
me: PropTypes.object.isRequired, onClick: () => void;
}; }
function SendButton(props) { function SendButton(props: ISendButtonProps) {
return ( return (
<AccessibleTooltipButton <AccessibleTooltipButton
className="mx_MessageComposer_sendMessage" className="mx_MessageComposer_sendMessage"
@ -57,10 +67,6 @@ function SendButton(props) {
); );
} }
SendButton.propTypes = {
onClick: PropTypes.func.isRequired,
};
const EmojiButton = ({addEmoji}) => { const EmojiButton = ({addEmoji}) => {
const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu(); const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu();
@ -68,7 +74,7 @@ const EmojiButton = ({addEmoji}) => {
if (menuDisplayed) { if (menuDisplayed) {
const buttonRect = button.current.getBoundingClientRect(); const buttonRect = button.current.getBoundingClientRect();
const EmojiPicker = sdk.getComponent('emojipicker.EmojiPicker'); const EmojiPicker = sdk.getComponent('emojipicker.EmojiPicker');
contextMenu = <ContextMenu {...aboveLeftOf(buttonRect)} onFinished={closeMenu} catchTab={false}> contextMenu = <ContextMenu {...aboveLeftOf(buttonRect)} onFinished={closeMenu} managed={false}>
<EmojiPicker onChoose={addEmoji} showQuickReactions={true} /> <EmojiPicker onChoose={addEmoji} showQuickReactions={true} />
</ContextMenu>; </ContextMenu>;
} }
@ -98,39 +104,39 @@ const EmojiButton = ({addEmoji}) => {
</React.Fragment>; </React.Fragment>;
}; };
class UploadButton extends React.Component { interface IUploadButtonProps {
static propTypes = { roomId: string;
roomId: PropTypes.string.isRequired, }
}
class UploadButton extends React.Component<IUploadButtonProps> {
private uploadInput = React.createRef<HTMLInputElement>();
private dispatcherRef: string;
constructor(props) { constructor(props) {
super(props); super(props);
this.onUploadClick = this.onUploadClick.bind(this);
this.onUploadFileInputChange = this.onUploadFileInputChange.bind(this);
this._uploadInput = createRef(); this.dispatcherRef = dis.register(this.onAction);
this._dispatcherRef = dis.register(this.onAction);
} }
componentWillUnmount() { componentWillUnmount() {
dis.unregister(this._dispatcherRef); dis.unregister(this.dispatcherRef);
} }
onAction = payload => { private onAction = (payload: ActionPayload) => {
if (payload.action === "upload_file") { if (payload.action === "upload_file") {
this.onUploadClick(); this.onUploadClick();
} }
}; };
onUploadClick(ev) { private onUploadClick = () => {
if (MatrixClientPeg.get().isGuest()) { if (MatrixClientPeg.get().isGuest()) {
dis.dispatch({action: 'require_registration'}); dis.dispatch({action: 'require_registration'});
return; return;
} }
this._uploadInput.current.click(); this.uploadInput.current.click();
} }
onUploadFileInputChange(ev) { private onUploadFileInputChange = (ev: React.ChangeEvent<HTMLInputElement>) => {
if (ev.target.files.length === 0) return; if (ev.target.files.length === 0) return;
// take a copy so we can safely reset the value of the form control // take a copy so we can safely reset the value of the form control
@ -160,7 +166,7 @@ class UploadButton extends React.Component {
title={_t('Upload file')} title={_t('Upload file')}
> >
<input <input
ref={this._uploadInput} ref={this.uploadInput}
type="file" type="file"
style={uploadInputStyle} style={uploadInputStyle}
multiple multiple
@ -171,19 +177,34 @@ class UploadButton extends React.Component {
} }
} }
interface IProps {
room: Room;
resizeNotifier: ResizeNotifier;
permalinkCreator: RoomPermalinkCreator;
replyToEvent?: MatrixEvent;
e2eStatus?: E2EStatus;
}
interface IState {
tombstone: MatrixEvent;
canSendMessages: boolean;
isComposerEmpty: boolean;
haveRecording: boolean;
recordingTimeLeftSeconds?: number;
me?: RoomMember;
}
@replaceableComponent("views.rooms.MessageComposer") @replaceableComponent("views.rooms.MessageComposer")
export default class MessageComposer extends React.Component { export default class MessageComposer extends React.Component<IProps, IState> {
private dispatcherRef: string;
private messageComposerInput: SendMessageComposer;
constructor(props) { constructor(props) {
super(props); super(props);
this.onInputStateChanged = this.onInputStateChanged.bind(this); VoiceRecordingStore.instance.on(UPDATE_EVENT, this.onVoiceStoreUpdate);
this._onRoomStateEvents = this._onRoomStateEvents.bind(this);
this._onTombstoneClick = this._onTombstoneClick.bind(this);
this.renderPlaceholderText = this.renderPlaceholderText.bind(this);
VoiceRecordingStore.instance.on(UPDATE_EVENT, this._onVoiceStoreUpdate);
this._dispatcherRef = null;
this.state = { this.state = {
tombstone: this._getRoomTombstone(), tombstone: this.getRoomTombstone(),
canSendMessages: this.props.room.maySendMessage(), canSendMessages: this.props.room.maySendMessage(),
isComposerEmpty: true, isComposerEmpty: true,
haveRecording: false, haveRecording: false,
@ -191,7 +212,13 @@ export default class MessageComposer extends React.Component {
}; };
} }
onAction = (payload) => { componentDidMount() {
this.dispatcherRef = dis.register(this.onAction);
MatrixClientPeg.get().on("RoomState.events", this.onRoomStateEvents);
this.waitForOwnMember();
}
private onAction = (payload: ActionPayload) => {
if (payload.action === 'reply_to_event') { if (payload.action === 'reply_to_event') {
// add a timeout for the reply preview to be rendered, so // add a timeout for the reply preview to be rendered, so
// that the ScrollPanel listening to the resizeNotifier can // that the ScrollPanel listening to the resizeNotifier can
@ -203,13 +230,7 @@ export default class MessageComposer extends React.Component {
} }
}; };
componentDidMount() { private waitForOwnMember() {
this.dispatcherRef = dis.register(this.onAction);
MatrixClientPeg.get().on("RoomState.events", this._onRoomStateEvents);
this._waitForOwnMember();
}
_waitForOwnMember() {
// if we have the member already, do that // if we have the member already, do that
const me = this.props.room.getMember(MatrixClientPeg.get().getUserId()); const me = this.props.room.getMember(MatrixClientPeg.get().getUserId());
if (me) { if (me) {
@ -227,34 +248,28 @@ export default class MessageComposer extends React.Component {
componentWillUnmount() { componentWillUnmount() {
if (MatrixClientPeg.get()) { if (MatrixClientPeg.get()) {
MatrixClientPeg.get().removeListener("RoomState.events", this._onRoomStateEvents); MatrixClientPeg.get().removeListener("RoomState.events", this.onRoomStateEvents);
} }
VoiceRecordingStore.instance.off(UPDATE_EVENT, this._onVoiceStoreUpdate); VoiceRecordingStore.instance.off(UPDATE_EVENT, this.onVoiceStoreUpdate);
dis.unregister(this.dispatcherRef); dis.unregister(this.dispatcherRef);
} }
_onRoomStateEvents(ev, state) { private onRoomStateEvents = (ev, state) => {
if (ev.getRoomId() !== this.props.room.roomId) return; if (ev.getRoomId() !== this.props.room.roomId) return;
if (ev.getType() === 'm.room.tombstone') { if (ev.getType() === 'm.room.tombstone') {
this.setState({tombstone: this._getRoomTombstone()}); this.setState({tombstone: this.getRoomTombstone()});
} }
if (ev.getType() === 'm.room.power_levels') { if (ev.getType() === 'm.room.power_levels') {
this.setState({canSendMessages: this.props.room.maySendMessage()}); this.setState({canSendMessages: this.props.room.maySendMessage()});
} }
} }
_getRoomTombstone() { private getRoomTombstone() {
return this.props.room.currentState.getStateEvents('m.room.tombstone', ''); return this.props.room.currentState.getStateEvents('m.room.tombstone', '');
} }
onInputStateChanged(inputState) { private onTombstoneClick = (ev) => {
// Merge the new input state with old to support partial updates
inputState = Object.assign({}, this.state.inputState, inputState);
this.setState({inputState});
}
_onTombstoneClick(ev) {
ev.preventDefault(); ev.preventDefault();
const replacementRoomId = this.state.tombstone.getContent()['replacement_room']; const replacementRoomId = this.state.tombstone.getContent()['replacement_room'];
@ -284,7 +299,7 @@ export default class MessageComposer extends React.Component {
}); });
} }
renderPlaceholderText() { private renderPlaceholderText = () => {
if (this.props.replyToEvent) { if (this.props.replyToEvent) {
if (this.props.e2eStatus) { if (this.props.e2eStatus) {
return _t('Send an encrypted reply…'); return _t('Send an encrypted reply…');
@ -317,7 +332,7 @@ export default class MessageComposer extends React.Component {
}); });
} }
_onVoiceStoreUpdate = () => { private onVoiceStoreUpdate = () => {
const recording = VoiceRecordingStore.instance.activeRecording; const recording = VoiceRecordingStore.instance.activeRecording;
this.setState({haveRecording: !!recording}); this.setState({haveRecording: !!recording});
if (recording) { if (recording) {
@ -386,7 +401,7 @@ export default class MessageComposer extends React.Component {
const continuesLink = replacementRoomId ? ( const continuesLink = replacementRoomId ? (
<a href={makeRoomPermalink(replacementRoomId)} <a href={makeRoomPermalink(replacementRoomId)}
className="mx_MessageComposer_roomReplaced_link" className="mx_MessageComposer_roomReplaced_link"
onClick={this._onTombstoneClick} onClick={this.onTombstoneClick}
> >
{_t("The conversation continues here.")} {_t("The conversation continues here.")}
</a> </a>
@ -394,7 +409,9 @@ export default class MessageComposer extends React.Component {
controls.push(<div className="mx_MessageComposer_replaced_wrapper" key="room_replaced"> controls.push(<div className="mx_MessageComposer_replaced_wrapper" key="room_replaced">
<div className="mx_MessageComposer_replaced_valign"> <div className="mx_MessageComposer_replaced_valign">
<img className="mx_MessageComposer_roomReplaced_icon" src={require("../../../../res/img/room_replaced.svg")} /> <img className="mx_MessageComposer_roomReplaced_icon"
src={require("../../../../res/img/room_replaced.svg")}
/>
<span className="mx_MessageComposer_roomReplaced_header"> <span className="mx_MessageComposer_roomReplaced_header">
{_t("This room has been replaced and is no longer active.")} {_t("This room has been replaced and is no longer active.")}
</span><br /> </span><br />
@ -431,14 +448,3 @@ export default class MessageComposer extends React.Component {
); );
} }
} }
MessageComposer.propTypes = {
// js-sdk Room object
room: PropTypes.object.isRequired,
// string representing the current voip call state
callState: PropTypes.string,
// string representing the current room app drawer state
showApps: PropTypes.bool,
};

View file

@ -30,7 +30,7 @@ interface IProps {
* If true, the badge will show a count if at all possible. This is typically * If true, the badge will show a count if at all possible. This is typically
* used to override the user's preference for things like room sublists. * used to override the user's preference for things like room sublists.
*/ */
forceCount: boolean; forceCount?: boolean;
/** /**
* The room ID, if any, the badge represents. * The room ID, if any, the badge represents.

View file

@ -1,7 +1,5 @@
/* /*
Copyright 2015, 2016 OpenMarket Ltd Copyright 2015-2018, 2020, 2021 The Matrix.org Foundation C.I.C.
Copyright 2017, 2018 Vector Creations 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.
@ -37,7 +35,6 @@ import { MatrixClientPeg } from "../../../MatrixClientPeg";
import GroupAvatar from "../avatars/GroupAvatar"; import GroupAvatar from "../avatars/GroupAvatar";
import ExtraTile from "./ExtraTile"; import ExtraTile from "./ExtraTile";
import { StaticNotificationState } from "../../../stores/notifications/StaticNotificationState"; import { StaticNotificationState } from "../../../stores/notifications/StaticNotificationState";
import { NotificationColor } from "../../../stores/notifications/NotificationColor";
import { Action } from "../../../dispatcher/actions"; import { Action } from "../../../dispatcher/actions";
import { ViewRoomDeltaPayload } from "../../../dispatcher/payloads/ViewRoomDeltaPayload"; import { ViewRoomDeltaPayload } from "../../../dispatcher/payloads/ViewRoomDeltaPayload";
import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore"; import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore";
@ -492,7 +489,7 @@ export default class RoomList extends React.PureComponent<IProps, IState> {
isSelected={false} isSelected={false}
displayName={g.name} displayName={g.name}
avatar={avatar} avatar={avatar}
notificationState={StaticNotificationState.forSymbol("!", NotificationColor.Red)} notificationState={StaticNotificationState.RED_EXCLAMATION}
onClick={openGroup} onClick={openGroup}
key={`temporaryGroupTile_${g.groupId}`} key={`temporaryGroupTile_${g.groupId}`}
/> />

View file

@ -763,7 +763,9 @@ export default class RoomSublist extends React.Component<IProps, IState> {
'mx_RoomSublist': true, 'mx_RoomSublist': true,
'mx_RoomSublist_hasMenuOpen': !!this.state.contextMenuPosition, 'mx_RoomSublist_hasMenuOpen': !!this.state.contextMenuPosition,
'mx_RoomSublist_minimized': this.props.isMinimized, 'mx_RoomSublist_minimized': this.props.isMinimized,
'mx_RoomSublist_hidden': !this.state.rooms.length && this.props.alwaysVisible !== true, 'mx_RoomSublist_hidden': (
!this.state.rooms.length && !this.props.extraTiles?.length && this.props.alwaysVisible !== true
),
}); });
let content = null; let content = null;

View file

@ -1,8 +1,6 @@
/* /*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 New Vector Ltd
Copyright 2018 Michael Telatynski <7t3chguy@gmail.com> Copyright 2018 Michael Telatynski <7t3chguy@gmail.com>
Copyright 2019, 2020 The Matrix.org Foundation C.I.C. Copyright 2015-2017, 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.
@ -19,6 +17,7 @@ limitations under the License.
import React, { createRef } from "react"; import React, { createRef } from "react";
import { Room } from "matrix-js-sdk/src/models/room"; import { Room } from "matrix-js-sdk/src/models/room";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import classNames from "classnames"; import classNames from "classnames";
import { RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex"; import { RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex";
import AccessibleButton, { ButtonEvent } from "../../views/elements/AccessibleButton"; import AccessibleButton, { ButtonEvent } from "../../views/elements/AccessibleButton";
@ -51,7 +50,9 @@ import IconizedContextMenu, {
IconizedContextMenuRadio, IconizedContextMenuRadio,
} from "../context_menus/IconizedContextMenu"; } from "../context_menus/IconizedContextMenu";
import { CommunityPrototypeStore, IRoomProfile } from "../../../stores/CommunityPrototypeStore"; import { CommunityPrototypeStore, IRoomProfile } from "../../../stores/CommunityPrototypeStore";
import {replaceableComponent} from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import { getUnsentMessages } from "../../structures/RoomStatusBar";
import { StaticNotificationState } from "../../../stores/notifications/StaticNotificationState";
interface IProps { interface IProps {
room: Room; room: Room;
@ -67,6 +68,7 @@ interface IState {
notificationsMenuPosition: PartialDOMRect; notificationsMenuPosition: PartialDOMRect;
generalMenuPosition: PartialDOMRect; generalMenuPosition: PartialDOMRect;
messagePreview?: string; messagePreview?: string;
hasUnsentEvents: boolean;
} }
const messagePreviewId = (roomId: string) => `mx_RoomTile_messagePreview_${roomId}`; const messagePreviewId = (roomId: string) => `mx_RoomTile_messagePreview_${roomId}`;
@ -93,6 +95,7 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
selected: ActiveRoomObserver.activeRoomId === this.props.room.roomId, selected: ActiveRoomObserver.activeRoomId === this.props.room.roomId,
notificationsMenuPosition: null, notificationsMenuPosition: null,
generalMenuPosition: null, generalMenuPosition: null,
hasUnsentEvents: this.countUnsentEvents() > 0,
// generatePreview() will return nothing if the user has previews disabled // generatePreview() will return nothing if the user has previews disabled
messagePreview: this.generatePreview(), messagePreview: this.generatePreview(),
@ -101,6 +104,10 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
this.roomProps = EchoChamber.forRoom(this.props.room); this.roomProps = EchoChamber.forRoom(this.props.room);
} }
private countUnsentEvents(): number {
return getUnsentMessages(this.props.room).length;
}
private onRoomNameUpdate = (room) => { private onRoomNameUpdate = (room) => {
this.forceUpdate(); this.forceUpdate();
} }
@ -109,6 +116,11 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
this.forceUpdate(); // notification state changed - update this.forceUpdate(); // notification state changed - update
}; };
private onLocalEchoUpdated = (ev: MatrixEvent, room: Room) => {
if (!room?.roomId === this.props.room.roomId) return;
this.setState({hasUnsentEvents: this.countUnsentEvents() > 0});
};
private onRoomPropertyUpdate = (property: CachedRoomKey) => { private onRoomPropertyUpdate = (property: CachedRoomKey) => {
if (property === CachedRoomKey.NotificationVolume) this.onNotificationUpdate(); if (property === CachedRoomKey.NotificationVolume) this.onNotificationUpdate();
// else ignore - not important for this tile // else ignore - not important for this tile
@ -167,6 +179,7 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
CommunityPrototypeStore.getUpdateEventName(this.props.room.roomId), CommunityPrototypeStore.getUpdateEventName(this.props.room.roomId),
this.onCommunityUpdate, this.onCommunityUpdate,
); );
MatrixClientPeg.get().on("Room.localEchoUpdated", this.onLocalEchoUpdated);
} }
public componentWillUnmount() { public componentWillUnmount() {
@ -191,6 +204,7 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
CommunityPrototypeStore.getUpdateEventName(this.props.room.roomId), CommunityPrototypeStore.getUpdateEventName(this.props.room.roomId),
this.onCommunityUpdate, this.onCommunityUpdate,
); );
MatrixClientPeg.get()?.removeListener("Room.localEchoUpdated", this.onLocalEchoUpdated);
} }
private onAction = (payload: ActionPayload) => { private onAction = (payload: ActionPayload) => {
@ -554,8 +568,20 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
/>; />;
let badge: React.ReactNode; let badge: React.ReactNode;
if (!this.props.isMinimized && this.notificationState) { if (!this.props.isMinimized) {
// aria-hidden because we summarise the unread count/highlight status in a manual aria-label below // aria-hidden because we summarise the unread count/highlight status in a manual aria-label below
if (this.state.hasUnsentEvents) {
// hardcode the badge to a danger state when there's unsent messages
badge = (
<div className="mx_RoomTile_badgeContainer" aria-hidden="true">
<NotificationBadge
notification={StaticNotificationState.RED_EXCLAMATION}
forceCount={false}
roomId={this.props.room.roomId}
/>
</div>
);
} else if (this.notificationState) {
badge = ( badge = (
<div className="mx_RoomTile_badgeContainer" aria-hidden="true"> <div className="mx_RoomTile_badgeContainer" aria-hidden="true">
<NotificationBadge <NotificationBadge
@ -566,6 +592,7 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
</div> </div>
); );
} }
}
let messagePreview = null; let messagePreview = null;
if (this.showMessagePreview && this.state.messagePreview) { if (this.showMessagePreview && this.state.messagePreview) {

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.
@ -15,9 +15,9 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import {MatrixClientPeg} from "../../../MatrixClientPeg"; import {MatrixClientPeg} from "../../../MatrixClientPeg";
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 {_t} from "../../../languageHandler"; import {_t} from "../../../languageHandler";
import dis from "../../../dispatcher/dispatcher"; import dis from "../../../dispatcher/dispatcher";
import * as sdk from "../../../index"; import * as sdk from "../../../index";
@ -27,11 +27,22 @@ import RoomAvatar from "../avatars/RoomAvatar";
import RoomName from "../elements/RoomName"; import RoomName from "../elements/RoomName";
import {replaceableComponent} from "../../../utils/replaceableComponent"; import {replaceableComponent} from "../../../utils/replaceableComponent";
interface IProps {
event: MatrixEvent;
}
interface IState {
stateKey: string;
roomId: string;
displayName: string;
invited: boolean;
canKick: boolean;
senderName: string;
}
@replaceableComponent("views.rooms.ThirdPartyMemberInfo") @replaceableComponent("views.rooms.ThirdPartyMemberInfo")
export default class ThirdPartyMemberInfo extends React.Component { export default class ThirdPartyMemberInfo extends React.Component<IProps, IState> {
static propTypes = { private room: Room;
event: PropTypes.instanceOf(MatrixEvent).isRequired,
};
constructor(props) { constructor(props) {
super(props); super(props);

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2020 The Matrix.org Foundation C.I.C. Copyright 2020-2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -28,10 +28,17 @@ import {SettingLevel} from "../../../settings/SettingLevel";
import {replaceableComponent} from "../../../utils/replaceableComponent"; import {replaceableComponent} from "../../../utils/replaceableComponent";
import SeshatResetDialog from '../dialogs/SeshatResetDialog'; import SeshatResetDialog from '../dialogs/SeshatResetDialog';
interface IState {
enabling: boolean;
eventIndexSize: number;
roomCount: number;
eventIndexingEnabled: boolean;
}
@replaceableComponent("views.settings.EventIndexPanel") @replaceableComponent("views.settings.EventIndexPanel")
export default class EventIndexPanel extends React.Component { export default class EventIndexPanel extends React.Component<{}, IState> {
constructor() { constructor(props) {
super(); super(props);
this.state = { this.state = {
enabling: false, enabling: false,
@ -68,7 +75,7 @@ export default class EventIndexPanel extends React.Component {
} }
} }
async componentDidMount(): void { componentDidMount(): void {
this.updateState(); this.updateState();
} }
@ -102,8 +109,10 @@ export default class EventIndexPanel extends React.Component {
}); });
} }
_onManage = async () => { private onManage = async () => {
Modal.createTrackedDialogAsync('Message search', 'Message search', Modal.createTrackedDialogAsync('Message search', 'Message search',
// @ts-ignore: TS doesn't seem to like the type of this now that it
// has also been converted to TS as well, but I can't figure out why...
import('../../../async-components/views/dialogs/eventindex/ManageEventIndexDialog'), import('../../../async-components/views/dialogs/eventindex/ManageEventIndexDialog'),
{ {
onFinished: () => {}, onFinished: () => {},
@ -111,7 +120,7 @@ export default class EventIndexPanel extends React.Component {
); );
} }
_onEnable = async () => { private onEnable = async () => {
this.setState({ this.setState({
enabling: true, enabling: true,
}); });
@ -123,14 +132,13 @@ export default class EventIndexPanel extends React.Component {
await this.updateState(); await this.updateState();
} }
_confirmEventStoreReset = () => { private confirmEventStoreReset = () => {
const self = this;
const { close } = Modal.createDialog(SeshatResetDialog, { const { close } = Modal.createDialog(SeshatResetDialog, {
onFinished: async (success) => { onFinished: async (success) => {
if (success) { if (success) {
await SettingsStore.setValue('enableEventIndexing', null, SettingLevel.DEVICE, false); await SettingsStore.setValue('enableEventIndexing', null, SettingLevel.DEVICE, false);
await EventIndexPeg.deleteEventIndex(); await EventIndexPeg.deleteEventIndex();
await self._onEnable(); await this.onEnable();
close(); close();
} }
}, },
@ -145,8 +153,8 @@ export default class EventIndexPanel extends React.Component {
if (EventIndexPeg.get() !== null) { if (EventIndexPeg.get() !== null) {
eventIndexingSettings = ( eventIndexingSettings = (
<div> <div>
<div className='mx_SettingsTab_subsectionText'> <div className='mx_SettingsTab_subsectionText'>{_t(
{_t("Securely cache encrypted messages locally for them " + "Securely cache encrypted messages locally for them " +
"to appear in search results, using %(size)s to store messages from %(rooms)s rooms.", "to appear in search results, using %(size)s to store messages from %(rooms)s rooms.",
{ {
size: formatBytes(this.state.eventIndexSize, 0), size: formatBytes(this.state.eventIndexSize, 0),
@ -155,10 +163,9 @@ export default class EventIndexPanel extends React.Component {
count: this.state.roomCount, count: this.state.roomCount,
rooms: formatCountLong(this.state.roomCount), rooms: formatCountLong(this.state.roomCount),
}, },
)} )}</div>
</div>
<div> <div>
<AccessibleButton kind="primary" onClick={this._onManage}> <AccessibleButton kind="primary" onClick={this.onManage}>
{_t("Manage")} {_t("Manage")}
</AccessibleButton> </AccessibleButton>
</div> </div>
@ -167,13 +174,13 @@ export default class EventIndexPanel extends React.Component {
} else if (!this.state.eventIndexingEnabled && EventIndexPeg.supportIsInstalled()) { } else if (!this.state.eventIndexingEnabled && EventIndexPeg.supportIsInstalled()) {
eventIndexingSettings = ( eventIndexingSettings = (
<div> <div>
<div className='mx_SettingsTab_subsectionText'> <div className='mx_SettingsTab_subsectionText'>{_t(
{_t( "Securely cache encrypted messages locally for them to " + "Securely cache encrypted messages locally for them to " +
"appear in search results.")} "appear in search results.",
</div> )}</div>
<div> <div>
<AccessibleButton kind="primary" disabled={this.state.enabling} <AccessibleButton kind="primary" disabled={this.state.enabling}
onClick={this._onEnable}> onClick={this.onEnable}>
{_t("Enable")} {_t("Enable")}
</AccessibleButton> </AccessibleButton>
{this.state.enabling ? <InlineSpinner /> : <div />} {this.state.enabling ? <InlineSpinner /> : <div />}
@ -188,9 +195,8 @@ export default class EventIndexPanel extends React.Component {
); );
eventIndexingSettings = ( eventIndexingSettings = (
<div className='mx_SettingsTab_subsectionText'> <div className='mx_SettingsTab_subsectionText'>{_t(
{ "%(brand)s is missing some components required for securely " +
_t( "%(brand)s is missing some components required for securely " +
"caching encrypted messages locally. If you'd like to " + "caching encrypted messages locally. If you'd like to " +
"experiment with this feature, build a custom %(brand)s Desktop " + "experiment with this feature, build a custom %(brand)s Desktop " +
"with <nativeLink>search components added</nativeLink>.", "with <nativeLink>search components added</nativeLink>.",
@ -198,30 +204,27 @@ export default class EventIndexPanel extends React.Component {
brand, brand,
}, },
{ {
'nativeLink': (sub) => <a href={nativeLink} target="_blank" nativeLink: sub => <a href={nativeLink}
rel="noreferrer noopener">{sub}</a>, target="_blank" rel="noreferrer noopener"
>{sub}</a>,
}, },
) )}</div>
}
</div>
); );
} else if (!EventIndexPeg.platformHasSupport()) { } else if (!EventIndexPeg.platformHasSupport()) {
eventIndexingSettings = ( eventIndexingSettings = (
<div className='mx_SettingsTab_subsectionText'> <div className='mx_SettingsTab_subsectionText'>{_t(
{ "%(brand)s can't securely cache encrypted messages locally " +
_t( "%(brand)s can't securely cache encrypted messages locally " +
"while running in a web browser. Use <desktopLink>%(brand)s Desktop</desktopLink> " + "while running in a web browser. Use <desktopLink>%(brand)s Desktop</desktopLink> " +
"for encrypted messages to appear in search results.", "for encrypted messages to appear in search results.",
{ {
brand, brand,
}, },
{ {
'desktopLink': (sub) => <a href="https://element.io/get-started" desktopLink: sub => <a href="https://element.io/get-started"
target="_blank" rel="noreferrer noopener">{sub}</a>, target="_blank" rel="noreferrer noopener"
>{sub}</a>,
}, },
) )}</div>
}
</div>
); );
} else { } else {
eventIndexingSettings = ( eventIndexingSettings = (
@ -239,13 +242,12 @@ export default class EventIndexPanel extends React.Component {
{EventIndexPeg.error.message} {EventIndexPeg.error.message}
</code> </code>
<p> <p>
<AccessibleButton key="delete" kind="danger" onClick={this._confirmEventStoreReset}> <AccessibleButton key="delete" kind="danger" onClick={this.confirmEventStoreReset}>
{_t("Reset")} {_t("Reset")}
</AccessibleButton> </AccessibleButton>
</p> </p>
</details> </details>
)} )}
</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.
@ -16,7 +16,6 @@ limitations under the License.
import url from 'url'; import url from 'url';
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import {_t} from "../../../languageHandler"; import {_t} from "../../../languageHandler";
import * as sdk from '../../../index'; import * as sdk from '../../../index';
import {MatrixClientPeg} from "../../../MatrixClientPeg"; import {MatrixClientPeg} from "../../../MatrixClientPeg";
@ -28,6 +27,7 @@ import {abbreviateUrl, unabbreviateUrl} from "../../../utils/UrlUtils";
import { getDefaultIdentityServerUrl, doesIdentityServerHaveTerms } from '../../../utils/IdentityServerUtils'; import { getDefaultIdentityServerUrl, doesIdentityServerHaveTerms } from '../../../utils/IdentityServerUtils';
import {timeout} from "../../../utils/promise"; import {timeout} from "../../../utils/promise";
import {replaceableComponent} from "../../../utils/replaceableComponent"; import {replaceableComponent} from "../../../utils/replaceableComponent";
import { ActionPayload } from '../../../dispatcher/payloads';
// We'll wait up to this long when checking for 3PID bindings on the IS. // We'll wait up to this long when checking for 3PID bindings on the IS.
const REACHABILITY_TIMEOUT = 10000; // ms const REACHABILITY_TIMEOUT = 10000; // ms
@ -59,16 +59,28 @@ async function checkIdentityServerUrl(u) {
} }
} }
@replaceableComponent("views.settings.SetIdServer") interface IProps {
export default class SetIdServer extends React.Component {
static propTypes = {
// Whether or not the ID server is missing terms. This affects the text // Whether or not the ID server is missing terms. This affects the text
// shown to the user. // shown to the user.
missingTerms: PropTypes.bool, missingTerms: boolean;
}; }
constructor() { interface IState {
super(); defaultIdServer?: string;
currentClientIdServer: string;
idServer?: string;
error?: string;
busy: boolean;
disconnectBusy: boolean;
checking: boolean;
}
@replaceableComponent("views.settings.SetIdServer")
export default class SetIdServer extends React.Component<IProps, IState> {
private dispatcherRef: string;
constructor(props) {
super(props);
let defaultIdServer = ''; let defaultIdServer = '';
if (!MatrixClientPeg.get().getIdentityServerUrl() && getDefaultIdentityServerUrl()) { if (!MatrixClientPeg.get().getIdentityServerUrl() && getDefaultIdentityServerUrl()) {
@ -96,7 +108,7 @@ export default class SetIdServer extends React.Component {
dis.unregister(this.dispatcherRef); dis.unregister(this.dispatcherRef);
} }
onAction = (payload) => { private onAction = (payload: ActionPayload) => {
// We react to changes in the ID server in the event the user is staring at this form // We react to changes in the ID server in the event the user is staring at this form
// when changing their identity server on another device. // when changing their identity server on another device.
if (payload.action !== "id_server_changed") return; if (payload.action !== "id_server_changed") return;
@ -106,13 +118,13 @@ export default class SetIdServer extends React.Component {
}); });
}; };
_onIdentityServerChanged = (ev) => { private onIdentityServerChanged = (ev) => {
const u = ev.target.value; const u = ev.target.value;
this.setState({idServer: u}); this.setState({idServer: u});
}; };
_getTooltip = () => { private getTooltip = () => {
if (this.state.checking) { if (this.state.checking) {
const InlineSpinner = sdk.getComponent('views.elements.InlineSpinner'); const InlineSpinner = sdk.getComponent('views.elements.InlineSpinner');
return <div> return <div>
@ -126,11 +138,11 @@ export default class SetIdServer extends React.Component {
} }
}; };
_idServerChangeEnabled = () => { private idServerChangeEnabled = () => {
return !!this.state.idServer && !this.state.busy; return !!this.state.idServer && !this.state.busy;
}; };
_saveIdServer = (fullUrl) => { private saveIdServer = (fullUrl) => {
// Account data change will update localstorage, client, etc through dispatcher // Account data change will update localstorage, client, etc through dispatcher
MatrixClientPeg.get().setAccountData("m.identity_server", { MatrixClientPeg.get().setAccountData("m.identity_server", {
base_url: fullUrl, base_url: fullUrl,
@ -143,7 +155,7 @@ export default class SetIdServer extends React.Component {
}); });
}; };
_checkIdServer = async (e) => { private checkIdServer = async (e) => {
e.preventDefault(); e.preventDefault();
const { idServer, currentClientIdServer } = this.state; const { idServer, currentClientIdServer } = this.state;
@ -166,14 +178,14 @@ export default class SetIdServer extends React.Component {
// Double check that the identity server even has terms of service. // Double check that the identity server even has terms of service.
const hasTerms = await doesIdentityServerHaveTerms(fullUrl); const hasTerms = await doesIdentityServerHaveTerms(fullUrl);
if (!hasTerms) { if (!hasTerms) {
const [confirmed] = await this._showNoTermsWarning(fullUrl); const [confirmed] = await this.showNoTermsWarning(fullUrl);
save = confirmed; save = confirmed;
} }
// Show a general warning, possibly with details about any bound // Show a general warning, possibly with details about any bound
// 3PIDs that would be left behind. // 3PIDs that would be left behind.
if (save && currentClientIdServer && fullUrl !== currentClientIdServer) { if (save && currentClientIdServer && fullUrl !== currentClientIdServer) {
const [confirmed] = await this._showServerChangeWarning({ const [confirmed] = await this.showServerChangeWarning({
title: _t("Change identity server"), title: _t("Change identity server"),
unboundMessage: _t( unboundMessage: _t(
"Disconnect from the identity server <current /> and " + "Disconnect from the identity server <current /> and " +
@ -189,7 +201,7 @@ export default class SetIdServer extends React.Component {
} }
if (save) { if (save) {
this._saveIdServer(fullUrl); this.saveIdServer(fullUrl);
} }
} catch (e) { } catch (e) {
console.error(e); console.error(e);
@ -204,7 +216,7 @@ export default class SetIdServer extends React.Component {
}); });
}; };
_showNoTermsWarning(fullUrl) { private showNoTermsWarning(fullUrl) {
const QuestionDialog = sdk.getComponent("views.dialogs.QuestionDialog"); const QuestionDialog = sdk.getComponent("views.dialogs.QuestionDialog");
const { finished } = Modal.createTrackedDialog('No Terms Warning', '', QuestionDialog, { const { finished } = Modal.createTrackedDialog('No Terms Warning', '', QuestionDialog, {
title: _t("Identity server has no terms of service"), title: _t("Identity server has no terms of service"),
@ -223,10 +235,10 @@ export default class SetIdServer extends React.Component {
return finished; return finished;
} }
_onDisconnectClicked = async () => { private onDisconnectClicked = async () => {
this.setState({disconnectBusy: true}); this.setState({disconnectBusy: true});
try { try {
const [confirmed] = await this._showServerChangeWarning({ const [confirmed] = await this.showServerChangeWarning({
title: _t("Disconnect identity server"), title: _t("Disconnect identity server"),
unboundMessage: _t( unboundMessage: _t(
"Disconnect from the identity server <idserver />?", {}, "Disconnect from the identity server <idserver />?", {},
@ -235,14 +247,14 @@ export default class SetIdServer extends React.Component {
button: _t("Disconnect"), button: _t("Disconnect"),
}); });
if (confirmed) { if (confirmed) {
this._disconnectIdServer(); this.disconnectIdServer();
} }
} finally { } finally {
this.setState({disconnectBusy: false}); this.setState({disconnectBusy: false});
} }
}; };
async _showServerChangeWarning({ title, unboundMessage, button }) { private async showServerChangeWarning({ title, unboundMessage, button }) {
const { currentClientIdServer } = this.state; const { currentClientIdServer } = this.state;
let threepids = []; let threepids = [];
@ -318,7 +330,7 @@ export default class SetIdServer extends React.Component {
return finished; return finished;
} }
_disconnectIdServer = () => { private disconnectIdServer = () => {
// Account data change will update localstorage, client, etc through dispatcher // Account data change will update localstorage, client, etc through dispatcher
MatrixClientPeg.get().setAccountData("m.identity_server", { MatrixClientPeg.get().setAccountData("m.identity_server", {
base_url: null, // clear base_url: null, // clear
@ -371,7 +383,7 @@ export default class SetIdServer extends React.Component {
let discoSection; let discoSection;
if (idServerUrl) { if (idServerUrl) {
let discoButtonContent = _t("Disconnect"); let discoButtonContent: React.ReactNode = _t("Disconnect");
let discoBodyText = _t( let discoBodyText = _t(
"Disconnecting from your identity server will mean you " + "Disconnecting from your identity server will mean you " +
"won't be discoverable by other users and you won't be " + "won't be discoverable by other users and you won't be " +
@ -391,14 +403,14 @@ export default class SetIdServer extends React.Component {
} }
discoSection = <div> discoSection = <div>
<span className="mx_SettingsTab_subsectionText">{discoBodyText}</span> <span className="mx_SettingsTab_subsectionText">{discoBodyText}</span>
<AccessibleButton onClick={this._onDisconnectClicked} kind="danger_sm"> <AccessibleButton onClick={this.onDisconnectClicked} kind="danger_sm">
{discoButtonContent} {discoButtonContent}
</AccessibleButton> </AccessibleButton>
</div>; </div>;
} }
return ( return (
<form className="mx_SettingsTab_section mx_SetIdServer" onSubmit={this._checkIdServer}> <form className="mx_SettingsTab_section mx_SetIdServer" onSubmit={this.checkIdServer}>
<span className="mx_SettingsTab_subheading"> <span className="mx_SettingsTab_subheading">
{sectionTitle} {sectionTitle}
</span> </span>
@ -411,15 +423,15 @@ export default class SetIdServer extends React.Component {
autoComplete="off" autoComplete="off"
placeholder={this.state.defaultIdServer} placeholder={this.state.defaultIdServer}
value={this.state.idServer} value={this.state.idServer}
onChange={this._onIdentityServerChanged} onChange={this.onIdentityServerChanged}
tooltipContent={this._getTooltip()} tooltipContent={this.getTooltip()}
tooltipClassName="mx_SetIdServer_tooltip" tooltipClassName="mx_SetIdServer_tooltip"
disabled={this.state.busy} disabled={this.state.busy}
forceValidity={this.state.error ? false : null} forceValidity={this.state.error ? false : null}
/> />
<AccessibleButton type="submit" kind="primary_sm" <AccessibleButton type="submit" kind="primary_sm"
onClick={this._checkIdServer} onClick={this.checkIdServer}
disabled={!this._idServerChangeEnabled()} disabled={!this.idServerChangeEnabled()}
>{_t("Change")}</AccessibleButton> >{_t("Change")}</AccessibleButton>
{discoSection} {discoSection}
</form> </form>

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2019, 2021 The Matrix.org Foundation C.I.C. Copyright 2019-2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -15,7 +15,6 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import {_t, _td} from "../../../../../languageHandler"; import {_t, _td} from "../../../../../languageHandler";
import {MatrixClientPeg} from "../../../../../MatrixClientPeg"; import {MatrixClientPeg} from "../../../../../MatrixClientPeg";
import * as sdk from "../../../../.."; import * as sdk from "../../../../..";
@ -23,6 +22,9 @@ import AccessibleButton from "../../../elements/AccessibleButton";
import Modal from "../../../../../Modal"; import Modal from "../../../../../Modal";
import {replaceableComponent} from "../../../../../utils/replaceableComponent"; import {replaceableComponent} from "../../../../../utils/replaceableComponent";
import {EventType} from "matrix-js-sdk/src/@types/event"; import {EventType} from "matrix-js-sdk/src/@types/event";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { RoomState } from "matrix-js-sdk/src/models/room-state";
const plEventsToLabels = { const plEventsToLabels = {
// These will be translated for us later. // These will be translated for us later.
@ -63,15 +65,15 @@ function parseIntWithDefault(val, def) {
return isNaN(res) ? def : res; return isNaN(res) ? def : res;
} }
export class BannedUser extends React.Component { interface IBannedUserProps {
static propTypes = { canUnban?: boolean;
canUnban: PropTypes.bool, member: RoomMember;
member: PropTypes.object.isRequired, // js-sdk RoomMember by: string;
by: PropTypes.string.isRequired, reason?: string;
reason: PropTypes.string, }
};
_onUnbanClick = (e) => { export class BannedUser extends React.Component<IBannedUserProps> {
private onUnbanClick = (e) => {
MatrixClientPeg.get().unban(this.props.member.roomId, this.props.member.userId).catch((err) => { MatrixClientPeg.get().unban(this.props.member.roomId, this.props.member.userId).catch((err) => {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
console.error("Failed to unban: " + err); console.error("Failed to unban: " + err);
@ -87,8 +89,10 @@ export class BannedUser extends React.Component {
if (this.props.canUnban) { if (this.props.canUnban) {
unbanButton = ( unbanButton = (
<AccessibleButton kind='danger_sm' onClick={this._onUnbanClick} <AccessibleButton className='mx_RolesRoomSettingsTab_unbanBtn'
className='mx_RolesRoomSettingsTab_unbanBtn'> kind='danger_sm'
onClick={this.onUnbanClick}
>
{ _t('Unban') } { _t('Unban') }
</AccessibleButton> </AccessibleButton>
); );
@ -107,29 +111,29 @@ export class BannedUser extends React.Component {
} }
} }
@replaceableComponent("views.settings.tabs.room.RolesRoomSettingsTab") interface IProps {
export default class RolesRoomSettingsTab extends React.Component { roomId: string;
static propTypes = { }
roomId: PropTypes.string.isRequired,
};
componentDidMount(): void { @replaceableComponent("views.settings.tabs.room.RolesRoomSettingsTab")
MatrixClientPeg.get().on("RoomState.members", this._onRoomMembership); export default class RolesRoomSettingsTab extends React.Component<IProps> {
componentDidMount() {
MatrixClientPeg.get().on("RoomState.members", this.onRoomMembership);
} }
componentWillUnmount(): void { componentWillUnmount() {
const client = MatrixClientPeg.get(); const client = MatrixClientPeg.get();
if (client) { if (client) {
client.removeListener("RoomState.members", this._onRoomMembership); client.removeListener("RoomState.members", this.onRoomMembership);
} }
} }
_onRoomMembership = (event, state, member) => { private onRoomMembership = (event: MatrixEvent, state: RoomState, member: RoomMember) => {
if (state.roomId !== this.props.roomId) return; if (state.roomId !== this.props.roomId) return;
this.forceUpdate(); this.forceUpdate();
}; };
_populateDefaultPlEvents(eventsSection, stateLevel, eventsLevel) { private populateDefaultPlEvents(eventsSection: Record<string, number>, stateLevel: number, eventsLevel: number) {
for (const desiredEvent of Object.keys(plEventsToShow)) { for (const desiredEvent of Object.keys(plEventsToShow)) {
if (!(desiredEvent in eventsSection)) { if (!(desiredEvent in eventsSection)) {
eventsSection[desiredEvent] = (plEventsToShow[desiredEvent].isState ? stateLevel : eventsLevel); eventsSection[desiredEvent] = (plEventsToShow[desiredEvent].isState ? stateLevel : eventsLevel);
@ -137,7 +141,7 @@ export default class RolesRoomSettingsTab extends React.Component {
} }
} }
_onPowerLevelsChanged = (value, powerLevelKey) => { private onPowerLevelsChanged = (inputValue: string, powerLevelKey: string) => {
const client = MatrixClientPeg.get(); const client = MatrixClientPeg.get();
const room = client.getRoom(this.props.roomId); const room = client.getRoom(this.props.roomId);
const plEvent = room.currentState.getStateEvents('m.room.power_levels', ''); const plEvent = room.currentState.getStateEvents('m.room.power_levels', '');
@ -148,7 +152,7 @@ export default class RolesRoomSettingsTab extends React.Component {
const eventsLevelPrefix = "event_levels_"; const eventsLevelPrefix = "event_levels_";
value = parseInt(value); const value = parseInt(inputValue);
if (powerLevelKey.startsWith(eventsLevelPrefix)) { if (powerLevelKey.startsWith(eventsLevelPrefix)) {
// deep copy "events" object, Object.assign itself won't deep copy // deep copy "events" object, Object.assign itself won't deep copy
@ -182,7 +186,7 @@ export default class RolesRoomSettingsTab extends React.Component {
}); });
}; };
_onUserPowerLevelChanged = (value, powerLevelKey) => { private onUserPowerLevelChanged = (value: string, powerLevelKey: string) => {
const client = MatrixClientPeg.get(); const client = MatrixClientPeg.get();
const room = client.getRoom(this.props.roomId); const room = client.getRoom(this.props.roomId);
const plEvent = room.currentState.getStateEvents('m.room.power_levels', ''); const plEvent = room.currentState.getStateEvents('m.room.power_levels', '');
@ -266,7 +270,7 @@ export default class RolesRoomSettingsTab extends React.Component {
currentUserLevel = defaultUserLevel; currentUserLevel = defaultUserLevel;
} }
this._populateDefaultPlEvents( this.populateDefaultPlEvents(
eventsLevels, eventsLevels,
parseIntWithDefault(plContent.state_default, powerLevelDescriptors.state_default.defaultValue), parseIntWithDefault(plContent.state_default, powerLevelDescriptors.state_default.defaultValue),
parseIntWithDefault(plContent.events_default, powerLevelDescriptors.events_default.defaultValue), parseIntWithDefault(plContent.events_default, powerLevelDescriptors.events_default.defaultValue),
@ -288,7 +292,7 @@ export default class RolesRoomSettingsTab extends React.Component {
label={user} label={user}
key={user} key={user}
powerLevelKey={user} // Will be sent as the second parameter to `onChange` powerLevelKey={user} // Will be sent as the second parameter to `onChange`
onChange={this._onUserPowerLevelChanged} onChange={this.onUserPowerLevelChanged}
/>, />,
); );
} else if (userLevels[user] < defaultUserLevel) { // muted } else if (userLevels[user] < defaultUserLevel) { // muted
@ -299,7 +303,7 @@ export default class RolesRoomSettingsTab extends React.Component {
label={user} label={user}
key={user} key={user}
powerLevelKey={user} // Will be sent as the second parameter to `onChange` powerLevelKey={user} // Will be sent as the second parameter to `onChange`
onChange={this._onUserPowerLevelChanged} onChange={this.onUserPowerLevelChanged}
/>, />,
); );
} }
@ -346,7 +350,8 @@ export default class RolesRoomSettingsTab extends React.Component {
return ( return (
<BannedUser key={member.userId} canUnban={canBanUsers} <BannedUser key={member.userId} canUnban={canBanUsers}
member={member} reason={banEvent.reason} member={member} reason={banEvent.reason}
by={bannedBy} /> by={bannedBy}
/>
); );
})} })}
</ul> </ul>
@ -373,7 +378,7 @@ export default class RolesRoomSettingsTab extends React.Component {
usersDefault={defaultUserLevel} usersDefault={defaultUserLevel}
disabled={!canChangeLevels || currentUserLevel < value} disabled={!canChangeLevels || currentUserLevel < value}
powerLevelKey={key} // Will be sent as the second parameter to `onChange` powerLevelKey={key} // Will be sent as the second parameter to `onChange`
onChange={this._onPowerLevelsChanged} onChange={this.onPowerLevelsChanged}
/> />
</div>; </div>;
}); });
@ -398,7 +403,7 @@ export default class RolesRoomSettingsTab extends React.Component {
usersDefault={defaultUserLevel} usersDefault={defaultUserLevel}
disabled={!canChangeLevels || currentUserLevel < eventsLevels[eventType]} disabled={!canChangeLevels || currentUserLevel < eventsLevels[eventType]}
powerLevelKey={"event_levels_" + eventType} powerLevelKey={"event_levels_" + eventType}
onChange={this._onPowerLevelsChanged} onChange={this.onPowerLevelsChanged}
/> />
</div> </div>
); );

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.
@ -15,7 +15,7 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import {_t} from "../../../../../languageHandler"; import {_t} from "../../../../../languageHandler";
import {MatrixClientPeg} from "../../../../../MatrixClientPeg"; import {MatrixClientPeg} from "../../../../../MatrixClientPeg";
import * as sdk from "../../../../.."; import * as sdk from "../../../../..";
@ -26,64 +26,92 @@ import StyledRadioGroup from '../../../elements/StyledRadioGroup';
import {SettingLevel} from "../../../../../settings/SettingLevel"; import {SettingLevel} from "../../../../../settings/SettingLevel";
import SettingsStore from "../../../../../settings/SettingsStore"; import SettingsStore from "../../../../../settings/SettingsStore";
import {UIFeature} from "../../../../../settings/UIFeature"; import {UIFeature} from "../../../../../settings/UIFeature";
import {replaceableComponent} from "../../../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../../../utils/replaceableComponent";
// Knock and private are reserved keywords which are not yet implemented.
enum JoinRule {
Public = "public",
Knock = "knock",
Invite = "invite",
Private = "private",
}
enum GuestAccess {
CanJoin = "can_join",
Forbidden = "forbidden",
}
enum HistoryVisibility {
Invited = "invited",
Joined = "joined",
Shared = "shared",
WorldReadable = "world_readable",
}
interface IProps {
roomId: string;
}
interface IState {
joinRule: JoinRule;
guestAccess: GuestAccess;
history: HistoryVisibility;
hasAliases: boolean;
encrypted: boolean;
}
@replaceableComponent("views.settings.tabs.room.SecurityRoomSettingsTab") @replaceableComponent("views.settings.tabs.room.SecurityRoomSettingsTab")
export default class SecurityRoomSettingsTab extends React.Component { export default class SecurityRoomSettingsTab extends React.Component<IProps, IState> {
static propTypes = { constructor(props) {
roomId: PropTypes.string.isRequired, super(props);
};
constructor() {
super();
this.state = { this.state = {
joinRule: "invite", joinRule: JoinRule.Invite,
guestAccess: "can_join", guestAccess: GuestAccess.CanJoin,
history: "shared", history: HistoryVisibility.Shared,
hasAliases: false, hasAliases: false,
encrypted: false, encrypted: false,
}; };
} }
// TODO: [REACT-WARNING] Move this to constructor // TODO: [REACT-WARNING] Move this to constructor
async UNSAFE_componentWillMount(): void { // eslint-disable-line camelcase async UNSAFE_componentWillMount() { // eslint-disable-line camelcase
MatrixClientPeg.get().on("RoomState.events", this._onStateEvent); MatrixClientPeg.get().on("RoomState.events", this.onStateEvent);
const room = MatrixClientPeg.get().getRoom(this.props.roomId); const room = MatrixClientPeg.get().getRoom(this.props.roomId);
const state = room.currentState; const state = room.currentState;
const joinRule = this._pullContentPropertyFromEvent( const joinRule: JoinRule = this.pullContentPropertyFromEvent(
state.getStateEvents("m.room.join_rules", ""), state.getStateEvents("m.room.join_rules", ""),
'join_rule', 'join_rule',
'invite', JoinRule.Invite,
); );
const guestAccess = this._pullContentPropertyFromEvent( const guestAccess: GuestAccess = this.pullContentPropertyFromEvent(
state.getStateEvents("m.room.guest_access", ""), state.getStateEvents("m.room.guest_access", ""),
'guest_access', 'guest_access',
'forbidden', GuestAccess.Forbidden,
); );
const history = this._pullContentPropertyFromEvent( const history: HistoryVisibility = this.pullContentPropertyFromEvent(
state.getStateEvents("m.room.history_visibility", ""), state.getStateEvents("m.room.history_visibility", ""),
'history_visibility', 'history_visibility',
'shared', HistoryVisibility.Shared,
); );
const encrypted = MatrixClientPeg.get().isRoomEncrypted(this.props.roomId); const encrypted = MatrixClientPeg.get().isRoomEncrypted(this.props.roomId);
this.setState({joinRule, guestAccess, history, encrypted}); this.setState({joinRule, guestAccess, history, encrypted});
const hasAliases = await this._hasAliases(); const hasAliases = await this.hasAliases();
this.setState({hasAliases}); this.setState({hasAliases});
} }
_pullContentPropertyFromEvent(event, key, defaultValue) { private pullContentPropertyFromEvent<T>(event: MatrixEvent, key: string, defaultValue: T): T {
if (!event || !event.getContent()) return defaultValue; if (!event || !event.getContent()) return defaultValue;
return event.getContent()[key] || defaultValue; return event.getContent()[key] || defaultValue;
} }
componentWillUnmount(): void { componentWillUnmount() {
MatrixClientPeg.get().removeListener("RoomState.events", this._onStateEvent); MatrixClientPeg.get().removeListener("RoomState.events", this.onStateEvent);
} }
_onStateEvent = (e) => { private onStateEvent = (e: MatrixEvent) => {
const refreshWhenTypes = [ const refreshWhenTypes = [
'm.room.join_rules', 'm.room.join_rules',
'm.room.guest_access', 'm.room.guest_access',
@ -93,7 +121,7 @@ export default class SecurityRoomSettingsTab extends React.Component {
if (refreshWhenTypes.includes(e.getType())) this.forceUpdate(); if (refreshWhenTypes.includes(e.getType())) this.forceUpdate();
}; };
_onEncryptionChange = (e) => { private onEncryptionChange = (e: React.ChangeEvent) => {
Modal.createTrackedDialog('Enable encryption', '', QuestionDialog, { Modal.createTrackedDialog('Enable encryption', '', QuestionDialog, {
title: _t('Enable encryption?'), title: _t('Enable encryption?'),
description: _t( description: _t(
@ -102,10 +130,9 @@ export default class SecurityRoomSettingsTab extends React.Component {
"may prevent many bots and bridges from working correctly. <a>Learn more about encryption.</a>", "may prevent many bots and bridges from working correctly. <a>Learn more about encryption.</a>",
{}, {},
{ {
'a': (sub) => { a: sub => <a href="https://element.io/help#encryption"
return <a rel='noreferrer noopener' target='_blank' rel="noreferrer noopener" target="_blank"
href='https://element.io/help#encryption'>{sub}</a>; >{sub}</a>,
},
}, },
), ),
onFinished: (confirm) => { onFinished: (confirm) => {
@ -127,12 +154,12 @@ export default class SecurityRoomSettingsTab extends React.Component {
}); });
}; };
_fixGuestAccess = (e) => { private fixGuestAccess = (e: React.MouseEvent) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
const joinRule = "invite"; const joinRule = JoinRule.Invite;
const guestAccess = "can_join"; const guestAccess = GuestAccess.CanJoin;
const beforeJoinRule = this.state.joinRule; const beforeJoinRule = this.state.joinRule;
const beforeGuestAccess = this.state.guestAccess; const beforeGuestAccess = this.state.guestAccess;
@ -149,7 +176,7 @@ export default class SecurityRoomSettingsTab extends React.Component {
}); });
}; };
_onRoomAccessRadioToggle = (roomAccess) => { private onRoomAccessRadioToggle = (roomAccess: string) => {
// join_rule // join_rule
// INVITE | PUBLIC // INVITE | PUBLIC
// ----------------------+---------------- // ----------------------+----------------
@ -163,20 +190,20 @@ export default class SecurityRoomSettingsTab extends React.Component {
// invite them, you clearly want them to join, whether they're a // invite them, you clearly want them to join, whether they're a
// guest or not. In practice, guest_access should probably have // guest or not. In practice, guest_access should probably have
// been implemented as part of the join_rules enum. // been implemented as part of the join_rules enum.
let joinRule = "invite"; let joinRule = JoinRule.Invite;
let guestAccess = "can_join"; let guestAccess = GuestAccess.CanJoin;
switch (roomAccess) { switch (roomAccess) {
case "invite_only": case "invite_only":
// no change - use defaults above // no change - use defaults above
break; break;
case "public_no_guests": case "public_no_guests":
joinRule = "public"; joinRule = JoinRule.Public;
guestAccess = "forbidden"; guestAccess = GuestAccess.Forbidden;
break; break;
case "public_with_guests": case "public_with_guests":
joinRule = "public"; joinRule = JoinRule.Public;
guestAccess = "can_join"; guestAccess = GuestAccess.CanJoin;
break; break;
} }
@ -195,7 +222,7 @@ export default class SecurityRoomSettingsTab extends React.Component {
}); });
}; };
_onHistoryRadioToggle = (history) => { private onHistoryRadioToggle = (history: HistoryVisibility) => {
const beforeHistory = this.state.history; const beforeHistory = this.state.history;
this.setState({history: history}); this.setState({history: history});
MatrixClientPeg.get().sendStateEvent(this.props.roomId, "m.room.history_visibility", { MatrixClientPeg.get().sendStateEvent(this.props.roomId, "m.room.history_visibility", {
@ -206,11 +233,11 @@ export default class SecurityRoomSettingsTab extends React.Component {
}); });
}; };
_updateBlacklistDevicesFlag = (checked) => { private updateBlacklistDevicesFlag = (checked: boolean) => {
MatrixClientPeg.get().getRoom(this.props.roomId).setBlacklistUnverifiedDevices(checked); MatrixClientPeg.get().getRoom(this.props.roomId).setBlacklistUnverifiedDevices(checked);
}; };
async _hasAliases() { private async hasAliases(): Promise<boolean> {
const cli = MatrixClientPeg.get(); const cli = MatrixClientPeg.get();
if (await cli.doesServerSupportUnstableFeature("org.matrix.msc2432")) { if (await cli.doesServerSupportUnstableFeature("org.matrix.msc2432")) {
const response = await cli.unstableGetLocalAliases(this.props.roomId); const response = await cli.unstableGetLocalAliases(this.props.roomId);
@ -224,7 +251,7 @@ export default class SecurityRoomSettingsTab extends React.Component {
} }
} }
_renderRoomAccess() { private renderRoomAccess() {
const client = MatrixClientPeg.get(); const client = MatrixClientPeg.get();
const room = client.getRoom(this.props.roomId); const room = client.getRoom(this.props.roomId);
const joinRule = this.state.joinRule; const joinRule = this.state.joinRule;
@ -240,7 +267,7 @@ export default class SecurityRoomSettingsTab extends React.Component {
<img src={require("../../../../../../res/img/warning.svg")} width={15} height={15} /> <img src={require("../../../../../../res/img/warning.svg")} width={15} height={15} />
<span> <span>
{_t("Guests cannot join this room even if explicitly invited.")}&nbsp; {_t("Guests cannot join this room even if explicitly invited.")}&nbsp;
<a href="" onClick={this._fixGuestAccess}>{_t("Click here to fix")}</a> <a href="" onClick={this.fixGuestAccess}>{_t("Click here to fix")}</a>
</span> </span>
</div> </div>
); );
@ -265,7 +292,7 @@ export default class SecurityRoomSettingsTab extends React.Component {
<StyledRadioGroup <StyledRadioGroup
name="roomVis" name="roomVis"
value={joinRule} value={joinRule}
onChange={this._onRoomAccessRadioToggle} onChange={this.onRoomAccessRadioToggle}
definitions={[ definitions={[
{ {
value: "invite_only", value: "invite_only",
@ -291,7 +318,7 @@ export default class SecurityRoomSettingsTab extends React.Component {
); );
} }
_renderHistory() { private renderHistory() {
const client = MatrixClientPeg.get(); const client = MatrixClientPeg.get();
const history = this.state.history; const history = this.state.history;
const state = client.getRoom(this.props.roomId).currentState; const state = client.getRoom(this.props.roomId).currentState;
@ -306,25 +333,25 @@ export default class SecurityRoomSettingsTab extends React.Component {
<StyledRadioGroup <StyledRadioGroup
name="historyVis" name="historyVis"
value={history} value={history}
onChange={this._onHistoryRadioToggle} onChange={this.onHistoryRadioToggle}
definitions={[ definitions={[
{ {
value: "world_readable", value: HistoryVisibility.WorldReadable,
disabled: !canChangeHistory, disabled: !canChangeHistory,
label: _t("Anyone"), label: _t("Anyone"),
}, },
{ {
value: "shared", value: HistoryVisibility.Shared,
disabled: !canChangeHistory, disabled: !canChangeHistory,
label: _t('Members only (since the point in time of selecting this option)'), label: _t('Members only (since the point in time of selecting this option)'),
}, },
{ {
value: "invited", value: HistoryVisibility.Invited,
disabled: !canChangeHistory, disabled: !canChangeHistory,
label: _t('Members only (since they were invited)'), label: _t('Members only (since they were invited)'),
}, },
{ {
value: "joined", value: HistoryVisibility.Joined,
disabled: !canChangeHistory, disabled: !canChangeHistory,
label: _t('Members only (since they joined)'), label: _t('Members only (since they joined)'),
}, },
@ -348,7 +375,7 @@ export default class SecurityRoomSettingsTab extends React.Component {
encryptionSettings = <SettingsFlag encryptionSettings = <SettingsFlag
name="blacklistUnverifiedDevices" name="blacklistUnverifiedDevices"
level={SettingLevel.ROOM_DEVICE} level={SettingLevel.ROOM_DEVICE}
onChange={this._updateBlacklistDevicesFlag} onChange={this.updateBlacklistDevicesFlag}
roomId={this.props.roomId} roomId={this.props.roomId}
/>; />;
} }
@ -356,7 +383,7 @@ export default class SecurityRoomSettingsTab extends React.Component {
let historySection = (<> let historySection = (<>
<span className='mx_SettingsTab_subheading'>{_t("Who can read history?")}</span> <span className='mx_SettingsTab_subheading'>{_t("Who can read history?")}</span>
<div className='mx_SettingsTab_section mx_SettingsTab_subsectionText'> <div className='mx_SettingsTab_section mx_SettingsTab_subsectionText'>
{this._renderHistory()} {this.renderHistory()}
</div> </div>
</>); </>);
if (!SettingsStore.getValue(UIFeature.RoomHistorySettings)) { if (!SettingsStore.getValue(UIFeature.RoomHistorySettings)) {
@ -373,15 +400,16 @@ export default class SecurityRoomSettingsTab extends React.Component {
<div className='mx_SettingsTab_subsectionText'> <div className='mx_SettingsTab_subsectionText'>
<span>{_t("Once enabled, encryption cannot be disabled.")}</span> <span>{_t("Once enabled, encryption cannot be disabled.")}</span>
</div> </div>
<LabelledToggleSwitch value={isEncrypted} onChange={this._onEncryptionChange} <LabelledToggleSwitch value={isEncrypted} onChange={this.onEncryptionChange}
label={_t("Encrypted")} disabled={!canEnableEncryption} /> label={_t("Encrypted")} disabled={!canEnableEncryption}
/>
</div> </div>
{encryptionSettings} {encryptionSettings}
</div> </div>
<span className='mx_SettingsTab_subheading'>{_t("Who can access this room?")}</span> <span className='mx_SettingsTab_subheading'>{_t("Who can access this room?")}</span>
<div className='mx_SettingsTab_section mx_SettingsTab_subsectionText'> <div className='mx_SettingsTab_section mx_SettingsTab_subsectionText'>
{this._renderRoomAccess()} {this.renderRoomAccess()}
</div> </div>
{historySection} {historySection}

View file

@ -192,7 +192,11 @@ export default class GeneralUserSettingsTab extends React.Component {
SettingsStore.setValue("language", null, SettingLevel.DEVICE, newLanguage); SettingsStore.setValue("language", null, SettingLevel.DEVICE, newLanguage);
this.setState({language: newLanguage}); this.setState({language: newLanguage});
PlatformPeg.get().reload(); const platform = PlatformPeg.get();
if (platform) {
platform.setLanguage(newLanguage);
platform.reload();
}
}; };
_onSpellCheckLanguagesChange = (languages) => { _onSpellCheckLanguagesChange = (languages) => {

View file

@ -1,6 +1,5 @@
/* /*
Copyright 2019 New Vector Ltd Copyright 2019-2021 The Matrix.org Foundation C.I.C.
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,27 +15,31 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import {_t, getCurrentLanguage} from "../../../../../languageHandler"; import {_t, getCurrentLanguage} from "../../../../../languageHandler";
import {MatrixClientPeg} from "../../../../../MatrixClientPeg"; import {MatrixClientPeg} from "../../../../../MatrixClientPeg";
import AccessibleButton from "../../../elements/AccessibleButton"; import AccessibleButton from "../../../elements/AccessibleButton";
import SdkConfig from "../../../../../SdkConfig"; import SdkConfig from "../../../../../SdkConfig";
import createRoom from "../../../../../createRoom"; import createRoom from "../../../../../createRoom";
import Modal from "../../../../../Modal"; import Modal from "../../../../../Modal";
import * as sdk from "../../../../../"; import * as sdk from "../../../../..";
import PlatformPeg from "../../../../../PlatformPeg"; import PlatformPeg from "../../../../../PlatformPeg";
import * as KeyboardShortcuts from "../../../../../accessibility/KeyboardShortcuts"; import * as KeyboardShortcuts from "../../../../../accessibility/KeyboardShortcuts";
import UpdateCheckButton from "../../UpdateCheckButton"; import UpdateCheckButton from "../../UpdateCheckButton";
import {replaceableComponent} from "../../../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../../../utils/replaceableComponent";
interface IProps {
closeSettingsFn: () => {};
}
interface IState {
appVersion: string;
canUpdate: boolean;
}
@replaceableComponent("views.settings.tabs.user.HelpUserSettingsTab") @replaceableComponent("views.settings.tabs.user.HelpUserSettingsTab")
export default class HelpUserSettingsTab extends React.Component { export default class HelpUserSettingsTab extends React.Component<IProps, IState> {
static propTypes = { constructor(props) {
closeSettingsFn: PropTypes.func.isRequired, super(props);
};
constructor() {
super();
this.state = { this.state = {
appVersion: null, appVersion: null,
@ -53,7 +56,7 @@ export default class HelpUserSettingsTab extends React.Component {
}); });
} }
_onClearCacheAndReload = (e) => { private onClearCacheAndReload = (e) => {
if (!PlatformPeg.get()) return; if (!PlatformPeg.get()) return;
// Dev note: please keep this log line, it's useful when troubleshooting a MatrixClient suddenly // Dev note: please keep this log line, it's useful when troubleshooting a MatrixClient suddenly
@ -65,7 +68,7 @@ export default class HelpUserSettingsTab extends React.Component {
}); });
}; };
_onBugReport = (e) => { private onBugReport = (e) => {
const BugReportDialog = sdk.getComponent("dialogs.BugReportDialog"); const BugReportDialog = sdk.getComponent("dialogs.BugReportDialog");
if (!BugReportDialog) { if (!BugReportDialog) {
return; return;
@ -73,7 +76,7 @@ export default class HelpUserSettingsTab extends React.Component {
Modal.createTrackedDialog('Bug Report Dialog', '', BugReportDialog, {}); Modal.createTrackedDialog('Bug Report Dialog', '', BugReportDialog, {});
}; };
_onStartBotChat = (e) => { private onStartBotChat = (e) => {
this.props.closeSettingsFn(); this.props.closeSettingsFn();
createRoom({ createRoom({
dmUserId: SdkConfig.get().welcomeUserId, dmUserId: SdkConfig.get().welcomeUserId,
@ -81,7 +84,7 @@ export default class HelpUserSettingsTab extends React.Component {
}); });
}; };
_showSpoiler = (event) => { private showSpoiler = (event) => {
const target = event.target; const target = event.target;
target.innerHTML = target.getAttribute('data-spoiler'); target.innerHTML = target.getAttribute('data-spoiler');
@ -93,7 +96,7 @@ export default class HelpUserSettingsTab extends React.Component {
selection.addRange(range); selection.addRange(range);
}; };
_renderLegal() { private renderLegal() {
const tocLinks = SdkConfig.get().terms_and_conditions_links; const tocLinks = SdkConfig.get().terms_and_conditions_links;
if (!tocLinks) return null; if (!tocLinks) return null;
@ -114,7 +117,7 @@ export default class HelpUserSettingsTab extends React.Component {
); );
} }
_renderCredits() { private renderCredits() {
// Note: This is not translated because it is legal text. // Note: This is not translated because it is legal text.
// Also, &nbsp; is ugly but necessary. // Also, &nbsp; is ugly but necessary.
return ( return (
@ -122,28 +125,28 @@ export default class HelpUserSettingsTab extends React.Component {
<span className='mx_SettingsTab_subheading'>{_t("Credits")}</span> <span className='mx_SettingsTab_subheading'>{_t("Credits")}</span>
<ul> <ul>
<li> <li>
The <a href="themes/element/img/backgrounds/lake.jpg" rel="noreferrer noopener" target="_blank"> The <a href="themes/element/img/backgrounds/lake.jpg" rel="noreferrer noopener"
default cover photo</a> is ©&nbsp; target="_blank">default cover photo</a> is ©&nbsp;
<a href="https://www.flickr.com/golan" rel="noreferrer noopener" target="_blank">Jesús Roncero</a>{' '} <a href="https://www.flickr.com/golan" rel="noreferrer noopener"
used under the terms of&nbsp; target="_blank">Jesús Roncero</a> used under the terms of&nbsp;
<a href="https://creativecommons.org/licenses/by-sa/4.0/" rel="noreferrer noopener" target="_blank"> <a href="https://creativecommons.org/licenses/by-sa/4.0/" rel="noreferrer noopener"
CC-BY-SA 4.0</a>. target="_blank">CC-BY-SA 4.0</a>.
</li> </li>
<li> <li>
The <a href="https://github.com/matrix-org/twemoji-colr" rel="noreferrer noopener" The <a href="https://github.com/matrix-org/twemoji-colr" rel="noreferrer noopener"
target="_blank"> twemoji-colr</a> font is ©&nbsp; target="_blank">twemoji-colr</a> font is ©&nbsp;
<a href="https://mozilla.org" rel="noreferrer noopener" target="_blank">Mozilla Foundation</a>{' '} <a href="https://mozilla.org" rel="noreferrer noopener"
used under the terms of&nbsp; target="_blank">Mozilla Foundation</a> used under the terms of&nbsp;
<a href="http://www.apache.org/licenses/LICENSE-2.0" rel="noreferrer noopener" target="_blank"> <a href="http://www.apache.org/licenses/LICENSE-2.0" rel="noreferrer noopener"
Apache 2.0</a>. target="_blank">Apache 2.0</a>.
</li> </li>
<li> <li>
The <a href="https://twemoji.twitter.com/" rel="noreferrer noopener" target="_blank"> The <a href="https://twemoji.twitter.com/" rel="noreferrer noopener"
Twemoji</a> emoji art is ©&nbsp; target="_blank">Twemoji</a> emoji art is ©&nbsp;
<a href="https://twemoji.twitter.com/" rel="noreferrer noopener" target="_blank">Twitter, Inc and other <a href="https://twemoji.twitter.com/" rel="noreferrer noopener"
contributors</a> used under the terms of&nbsp; target="_blank">Twitter, Inc and other contributors</a> used under the terms of&nbsp;
<a href="https://creativecommons.org/licenses/by/4.0/" rel="noreferrer noopener" target="_blank"> <a href="https://creativecommons.org/licenses/by/4.0/" rel="noreferrer noopener"
CC-BY 4.0</a>. target="_blank">CC-BY 4.0</a>.
</li> </li>
</ul> </ul>
</div> </div>
@ -188,7 +191,7 @@ export default class HelpUserSettingsTab extends React.Component {
}, },
)} )}
<div> <div>
<AccessibleButton onClick={this._onStartBotChat} kind='primary'> <AccessibleButton onClick={this.onStartBotChat} kind='primary'>
{_t("Chat with %(brand)s Bot", { brand })} {_t("Chat with %(brand)s Bot", { brand })}
</AccessibleButton> </AccessibleButton>
</div> </div>
@ -212,28 +215,27 @@ export default class HelpUserSettingsTab extends React.Component {
<div className="mx_SettingsTab_section"> <div className="mx_SettingsTab_section">
<span className='mx_SettingsTab_subheading'>{_t('Bug reporting')}</span> <span className='mx_SettingsTab_subheading'>{_t('Bug reporting')}</span>
<div className='mx_SettingsTab_subsectionText'> <div className='mx_SettingsTab_subsectionText'>
{ {_t(
_t( "If you've submitted a bug via GitHub, debug logs can help " + "If you've submitted a bug via GitHub, debug logs can help " +
"us track down the problem. Debug logs contain application " + "us track down the problem. Debug logs contain application " +
"usage data including your username, the IDs or aliases of " + "usage data including your username, the IDs or aliases of " +
"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.",
) )}
}
<div className='mx_HelpUserSettingsTab_debugButton'> <div className='mx_HelpUserSettingsTab_debugButton'>
<AccessibleButton onClick={this._onBugReport} kind='primary'> <AccessibleButton onClick={this.onBugReport} kind='primary'>
{_t("Submit debug logs")} {_t("Submit debug logs")}
</AccessibleButton> </AccessibleButton>
</div> </div>
{ {_t(
_t( "To report a Matrix-related security issue, please read the Matrix.org " + "To report a Matrix-related security issue, please read the Matrix.org " +
"<a>Security Disclosure Policy</a>.", {}, "<a>Security Disclosure Policy</a>.", {},
{ {
'a': (sub) => a: sub => <a href="https://matrix.org/security-disclosure-policy/"
<a href="https://matrix.org/security-disclosure-policy/" rel="noreferrer noopener" target="_blank"
rel="noreferrer noopener" target="_blank">{sub}</a>, >{sub}</a>,
}) },
} )}
</div> </div>
</div> </div>
); );
@ -260,20 +262,21 @@ export default class HelpUserSettingsTab extends React.Component {
{updateButton} {updateButton}
</div> </div>
</div> </div>
{this._renderLegal()} {this.renderLegal()}
{this._renderCredits()} {this.renderCredits()}
<div className='mx_SettingsTab_section mx_HelpUserSettingsTab_versions'> <div className='mx_SettingsTab_section mx_HelpUserSettingsTab_versions'>
<span className='mx_SettingsTab_subheading'>{_t("Advanced")}</span> <span className='mx_SettingsTab_subheading'>{_t("Advanced")}</span>
<div className='mx_SettingsTab_subsectionText'> <div className='mx_SettingsTab_subsectionText'>
{_t("Homeserver is")} <code>{MatrixClientPeg.get().getHomeserverUrl()}</code><br /> {_t("Homeserver is")} <code>{MatrixClientPeg.get().getHomeserverUrl()}</code><br />
{_t("Identity Server is")} <code>{MatrixClientPeg.get().getIdentityServerUrl()}</code><br /> {_t("Identity Server is")} <code>{MatrixClientPeg.get().getIdentityServerUrl()}</code><br />
{_t("Access Token:") + ' '} {_t("Access Token:") + ' '}
<AccessibleButton element="span" onClick={this._showSpoiler} <AccessibleButton element="span" onClick={this.showSpoiler}
data-spoiler={MatrixClientPeg.get().getAccessToken()}> data-spoiler={MatrixClientPeg.get().getAccessToken()}
>
&lt;{ _t("click to reveal") }&gt; &lt;{ _t("click to reveal") }&gt;
</AccessibleButton> </AccessibleButton>
<div className='mx_HelpUserSettingsTab_debugButton'> <div className='mx_HelpUserSettingsTab_debugButton'>
<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

@ -1,5 +1,5 @@
/* /*
Copyright 2019, 2020 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.
@ -25,10 +25,16 @@ import {MatrixClientPeg} from "../../../../../MatrixClientPeg";
import * as sdk from "../../../../../index"; import * as sdk from "../../../../../index";
import {replaceableComponent} from "../../../../../utils/replaceableComponent"; import {replaceableComponent} from "../../../../../utils/replaceableComponent";
interface IState {
busy: boolean;
newPersonalRule: string;
newList: string;
}
@replaceableComponent("views.settings.tabs.user.MjolnirUserSettingsTab") @replaceableComponent("views.settings.tabs.user.MjolnirUserSettingsTab")
export default class MjolnirUserSettingsTab extends React.Component { export default class MjolnirUserSettingsTab extends React.Component<{}, IState> {
constructor() { constructor(props) {
super(); super(props);
this.state = { this.state = {
busy: false, busy: false,
@ -37,15 +43,15 @@ export default class MjolnirUserSettingsTab extends React.Component {
}; };
} }
_onPersonalRuleChanged = (e) => { private onPersonalRuleChanged = (e) => {
this.setState({newPersonalRule: e.target.value}); this.setState({newPersonalRule: e.target.value});
}; };
_onNewListChanged = (e) => { private onNewListChanged = (e) => {
this.setState({newList: e.target.value}); this.setState({newList: e.target.value});
}; };
_onAddPersonalRule = async (e) => { private onAddPersonalRule = async (e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
@ -72,7 +78,7 @@ export default class MjolnirUserSettingsTab extends React.Component {
} }
}; };
_onSubscribeList = async (e) => { private onSubscribeList = async (e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
@ -94,7 +100,7 @@ export default class MjolnirUserSettingsTab extends React.Component {
} }
}; };
async _removePersonalRule(rule: ListRule) { private async removePersonalRule(rule: ListRule) {
this.setState({busy: true}); this.setState({busy: true});
try { try {
const list = Mjolnir.sharedInstance().getPersonalList(); const list = Mjolnir.sharedInstance().getPersonalList();
@ -112,7 +118,7 @@ export default class MjolnirUserSettingsTab extends React.Component {
} }
} }
async _unsubscribeFromList(list: BanList) { private async unsubscribeFromList(list: BanList) {
this.setState({busy: true}); this.setState({busy: true});
try { try {
await Mjolnir.sharedInstance().unsubscribeFromList(list.roomId); await Mjolnir.sharedInstance().unsubscribeFromList(list.roomId);
@ -130,7 +136,7 @@ export default class MjolnirUserSettingsTab extends React.Component {
} }
} }
_viewListRules(list: BanList) { private viewListRules(list: BanList) {
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
const room = MatrixClientPeg.get().getRoom(list.roomId); const room = MatrixClientPeg.get().getRoom(list.roomId);
@ -161,7 +167,7 @@ export default class MjolnirUserSettingsTab extends React.Component {
}); });
} }
_renderPersonalBanListRules() { private renderPersonalBanListRules() {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
const list = Mjolnir.sharedInstance().getPersonalList(); const list = Mjolnir.sharedInstance().getPersonalList();
@ -174,7 +180,7 @@ export default class MjolnirUserSettingsTab extends React.Component {
<li key={rule.entity} className="mx_MjolnirUserSettingsTab_listItem"> <li key={rule.entity} className="mx_MjolnirUserSettingsTab_listItem">
<AccessibleButton <AccessibleButton
kind="danger_sm" kind="danger_sm"
onClick={() => this._removePersonalRule(rule)} onClick={() => this.removePersonalRule(rule)}
disabled={this.state.busy} disabled={this.state.busy}
> >
{_t("Remove")} {_t("Remove")}
@ -192,7 +198,7 @@ export default class MjolnirUserSettingsTab extends React.Component {
); );
} }
_renderSubscribedBanLists() { private renderSubscribedBanLists() {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
const personalList = Mjolnir.sharedInstance().getPersonalList(); const personalList = Mjolnir.sharedInstance().getPersonalList();
@ -209,14 +215,14 @@ export default class MjolnirUserSettingsTab extends React.Component {
<li key={list.roomId} className="mx_MjolnirUserSettingsTab_listItem"> <li key={list.roomId} className="mx_MjolnirUserSettingsTab_listItem">
<AccessibleButton <AccessibleButton
kind="danger_sm" kind="danger_sm"
onClick={() => this._unsubscribeFromList(list)} onClick={() => this.unsubscribeFromList(list)}
disabled={this.state.busy} disabled={this.state.busy}
> >
{_t("Unsubscribe")} {_t("Unsubscribe")}
</AccessibleButton>&nbsp; </AccessibleButton>&nbsp;
<AccessibleButton <AccessibleButton
kind="primary_sm" kind="primary_sm"
onClick={() => this._viewListRules(list)} onClick={() => this.viewListRules(list)}
disabled={this.state.busy} disabled={this.state.busy}
> >
{_t("View rules")} {_t("View rules")}
@ -271,21 +277,21 @@ export default class MjolnirUserSettingsTab extends React.Component {
)} )}
</div> </div>
<div> <div>
{this._renderPersonalBanListRules()} {this.renderPersonalBanListRules()}
</div> </div>
<div> <div>
<form onSubmit={this._onAddPersonalRule} autoComplete="off"> <form onSubmit={this.onAddPersonalRule} autoComplete="off">
<Field <Field
type="text" type="text"
label={_t("Server or user ID to ignore")} label={_t("Server or user ID to ignore")}
placeholder={_t("eg: @bot:* or example.org")} placeholder={_t("eg: @bot:* or example.org")}
value={this.state.newPersonalRule} value={this.state.newPersonalRule}
onChange={this._onPersonalRuleChanged} onChange={this.onPersonalRuleChanged}
/> />
<AccessibleButton <AccessibleButton
type="submit" type="submit"
kind="primary" kind="primary"
onClick={this._onAddPersonalRule} onClick={this.onAddPersonalRule}
disabled={this.state.busy} disabled={this.state.busy}
> >
{_t("Ignore")} {_t("Ignore")}
@ -303,20 +309,20 @@ export default class MjolnirUserSettingsTab extends React.Component {
)}</span> )}</span>
</div> </div>
<div> <div>
{this._renderSubscribedBanLists()} {this.renderSubscribedBanLists()}
</div> </div>
<div> <div>
<form onSubmit={this._onSubscribeList} autoComplete="off"> <form onSubmit={this.onSubscribeList} autoComplete="off">
<Field <Field
type="text" type="text"
label={_t("Room ID or address of ban list")} label={_t("Room ID or address of ban list")}
value={this.state.newList} value={this.state.newList}
onChange={this._onNewListChanged} onChange={this.onNewListChanged}
/> />
<AccessibleButton <AccessibleButton
type="submit" type="submit"
kind="primary" kind="primary"
onClick={this._onSubscribeList} onClick={this.onSubscribeList}
disabled={this.state.busy} disabled={this.state.busy}
> >
{_t("Subscribe")} {_t("Subscribe")}

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2019 New Vector Ltd Copyright 2019-2021 The Matrix.org Foundation C.I.C.
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
@ -23,10 +23,24 @@ import Field from "../../../elements/Field";
import * as sdk from "../../../../.."; import * as sdk from "../../../../..";
import PlatformPeg from "../../../../../PlatformPeg"; import PlatformPeg from "../../../../../PlatformPeg";
import {SettingLevel} from "../../../../../settings/SettingLevel"; import {SettingLevel} from "../../../../../settings/SettingLevel";
import {replaceableComponent} from "../../../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../../../utils/replaceableComponent";
interface IState {
autoLaunch: boolean;
autoLaunchSupported: boolean;
warnBeforeExit: boolean;
warnBeforeExitSupported: boolean;
alwaysShowMenuBarSupported: boolean;
alwaysShowMenuBar: boolean;
minimizeToTraySupported: boolean;
minimizeToTray: boolean;
autocompleteDelay: string;
readMarkerInViewThresholdMs: string;
readMarkerOutOfViewThresholdMs: string;
}
@replaceableComponent("views.settings.tabs.user.PreferencesUserSettingsTab") @replaceableComponent("views.settings.tabs.user.PreferencesUserSettingsTab")
export default class PreferencesUserSettingsTab extends React.Component { export default class PreferencesUserSettingsTab extends React.Component<{}, IState> {
static ROOM_LIST_SETTINGS = [ static ROOM_LIST_SETTINGS = [
'breadcrumbs', 'breadcrumbs',
]; ];
@ -68,8 +82,8 @@ export default class PreferencesUserSettingsTab extends React.Component {
// Autocomplete delay (niche text box) // Autocomplete delay (niche text box)
]; ];
constructor() { constructor(props) {
super(); super(props);
this.state = { this.state = {
autoLaunch: false, autoLaunch: false,
@ -89,7 +103,7 @@ export default class PreferencesUserSettingsTab extends React.Component {
}; };
} }
async componentDidMount(): void { async componentDidMount() {
const platform = PlatformPeg.get(); const platform = PlatformPeg.get();
const autoLaunchSupported = await platform.supportsAutoLaunch(); const autoLaunchSupported = await platform.supportsAutoLaunch();
@ -128,38 +142,38 @@ export default class PreferencesUserSettingsTab extends React.Component {
}); });
} }
_onAutoLaunchChange = (checked) => { private onAutoLaunchChange = (checked: boolean) => {
PlatformPeg.get().setAutoLaunchEnabled(checked).then(() => this.setState({autoLaunch: checked})); PlatformPeg.get().setAutoLaunchEnabled(checked).then(() => this.setState({autoLaunch: checked}));
}; };
_onWarnBeforeExitChange = (checked) => { private onWarnBeforeExitChange = (checked: boolean) => {
PlatformPeg.get().setWarnBeforeExit(checked).then(() => this.setState({warnBeforeExit: checked})); PlatformPeg.get().setWarnBeforeExit(checked).then(() => this.setState({warnBeforeExit: checked}));
} }
_onAlwaysShowMenuBarChange = (checked) => { private onAlwaysShowMenuBarChange = (checked: boolean) => {
PlatformPeg.get().setAutoHideMenuBarEnabled(!checked).then(() => this.setState({alwaysShowMenuBar: checked})); PlatformPeg.get().setAutoHideMenuBarEnabled(!checked).then(() => this.setState({alwaysShowMenuBar: checked}));
}; };
_onMinimizeToTrayChange = (checked) => { private onMinimizeToTrayChange = (checked: boolean) => {
PlatformPeg.get().setMinimizeToTrayEnabled(checked).then(() => this.setState({minimizeToTray: checked})); PlatformPeg.get().setMinimizeToTrayEnabled(checked).then(() => this.setState({minimizeToTray: checked}));
}; };
_onAutocompleteDelayChange = (e) => { private onAutocompleteDelayChange = (e: React.ChangeEvent<HTMLInputElement>) => {
this.setState({autocompleteDelay: e.target.value}); this.setState({autocompleteDelay: e.target.value});
SettingsStore.setValue("autocompleteDelay", null, SettingLevel.DEVICE, e.target.value); SettingsStore.setValue("autocompleteDelay", null, SettingLevel.DEVICE, e.target.value);
}; };
_onReadMarkerInViewThresholdMs = (e) => { private onReadMarkerInViewThresholdMs = (e: React.ChangeEvent<HTMLInputElement>) => {
this.setState({readMarkerInViewThresholdMs: e.target.value}); this.setState({readMarkerInViewThresholdMs: e.target.value});
SettingsStore.setValue("readMarkerInViewThresholdMs", null, SettingLevel.DEVICE, e.target.value); SettingsStore.setValue("readMarkerInViewThresholdMs", null, SettingLevel.DEVICE, e.target.value);
}; };
_onReadMarkerOutOfViewThresholdMs = (e) => { private onReadMarkerOutOfViewThresholdMs = (e: React.ChangeEvent<HTMLInputElement>) => {
this.setState({readMarkerOutOfViewThresholdMs: e.target.value}); this.setState({readMarkerOutOfViewThresholdMs: e.target.value});
SettingsStore.setValue("readMarkerOutOfViewThresholdMs", null, SettingLevel.DEVICE, e.target.value); SettingsStore.setValue("readMarkerOutOfViewThresholdMs", null, SettingLevel.DEVICE, e.target.value);
}; };
_renderGroup(settingIds) { private renderGroup(settingIds: string[]): React.ReactNodeArray {
const SettingsFlag = sdk.getComponent("views.elements.SettingsFlag"); const SettingsFlag = sdk.getComponent("views.elements.SettingsFlag");
return settingIds.filter(SettingsStore.isEnabled).map(i => { return settingIds.filter(SettingsStore.isEnabled).map(i => {
return <SettingsFlag key={i} name={i} level={SettingLevel.ACCOUNT} />; return <SettingsFlag key={i} name={i} level={SettingLevel.ACCOUNT} />;
@ -171,7 +185,7 @@ export default class PreferencesUserSettingsTab extends React.Component {
if (this.state.autoLaunchSupported) { if (this.state.autoLaunchSupported) {
autoLaunchOption = <LabelledToggleSwitch autoLaunchOption = <LabelledToggleSwitch
value={this.state.autoLaunch} value={this.state.autoLaunch}
onChange={this._onAutoLaunchChange} onChange={this.onAutoLaunchChange}
label={_t('Start automatically after system login')} />; label={_t('Start automatically after system login')} />;
} }
@ -179,7 +193,7 @@ export default class PreferencesUserSettingsTab extends React.Component {
if (this.state.warnBeforeExitSupported) { if (this.state.warnBeforeExitSupported) {
warnBeforeExitOption = <LabelledToggleSwitch warnBeforeExitOption = <LabelledToggleSwitch
value={this.state.warnBeforeExit} value={this.state.warnBeforeExit}
onChange={this._onWarnBeforeExitChange} onChange={this.onWarnBeforeExitChange}
label={_t('Warn before quitting')} />; label={_t('Warn before quitting')} />;
} }
@ -187,7 +201,7 @@ export default class PreferencesUserSettingsTab extends React.Component {
if (this.state.alwaysShowMenuBarSupported) { if (this.state.alwaysShowMenuBarSupported) {
autoHideMenuOption = <LabelledToggleSwitch autoHideMenuOption = <LabelledToggleSwitch
value={this.state.alwaysShowMenuBar} value={this.state.alwaysShowMenuBar}
onChange={this._onAlwaysShowMenuBarChange} onChange={this.onAlwaysShowMenuBarChange}
label={_t('Always show the window menu bar')} />; label={_t('Always show the window menu bar')} />;
} }
@ -195,7 +209,7 @@ export default class PreferencesUserSettingsTab extends React.Component {
if (this.state.minimizeToTraySupported) { if (this.state.minimizeToTraySupported) {
minimizeToTrayOption = <LabelledToggleSwitch minimizeToTrayOption = <LabelledToggleSwitch
value={this.state.minimizeToTray} value={this.state.minimizeToTray}
onChange={this._onMinimizeToTrayChange} onChange={this.onMinimizeToTrayChange}
label={_t('Show tray icon and minimize window to it on close')} />; label={_t('Show tray icon and minimize window to it on close')} />;
} }
@ -205,22 +219,22 @@ export default class PreferencesUserSettingsTab extends React.Component {
<div className="mx_SettingsTab_section"> <div className="mx_SettingsTab_section">
<span className="mx_SettingsTab_subheading">{_t("Room list")}</span> <span className="mx_SettingsTab_subheading">{_t("Room list")}</span>
{this._renderGroup(PreferencesUserSettingsTab.ROOM_LIST_SETTINGS)} {this.renderGroup(PreferencesUserSettingsTab.ROOM_LIST_SETTINGS)}
</div> </div>
<div className="mx_SettingsTab_section"> <div className="mx_SettingsTab_section">
<span className="mx_SettingsTab_subheading">{_t("Composer")}</span> <span className="mx_SettingsTab_subheading">{_t("Composer")}</span>
{this._renderGroup(PreferencesUserSettingsTab.COMPOSER_SETTINGS)} {this.renderGroup(PreferencesUserSettingsTab.COMPOSER_SETTINGS)}
</div> </div>
<div className="mx_SettingsTab_section"> <div className="mx_SettingsTab_section">
<span className="mx_SettingsTab_subheading">{_t("Timeline")}</span> <span className="mx_SettingsTab_subheading">{_t("Timeline")}</span>
{this._renderGroup(PreferencesUserSettingsTab.TIMELINE_SETTINGS)} {this.renderGroup(PreferencesUserSettingsTab.TIMELINE_SETTINGS)}
</div> </div>
<div className="mx_SettingsTab_section"> <div className="mx_SettingsTab_section">
<span className="mx_SettingsTab_subheading">{_t("General")}</span> <span className="mx_SettingsTab_subheading">{_t("General")}</span>
{this._renderGroup(PreferencesUserSettingsTab.GENERAL_SETTINGS)} {this.renderGroup(PreferencesUserSettingsTab.GENERAL_SETTINGS)}
{minimizeToTrayOption} {minimizeToTrayOption}
{autoHideMenuOption} {autoHideMenuOption}
{autoLaunchOption} {autoLaunchOption}
@ -229,17 +243,17 @@ export default class PreferencesUserSettingsTab extends React.Component {
label={_t('Autocomplete delay (ms)')} label={_t('Autocomplete delay (ms)')}
type='number' type='number'
value={this.state.autocompleteDelay} value={this.state.autocompleteDelay}
onChange={this._onAutocompleteDelayChange} /> onChange={this.onAutocompleteDelayChange} />
<Field <Field
label={_t('Read Marker lifetime (ms)')} label={_t('Read Marker lifetime (ms)')}
type='number' type='number'
value={this.state.readMarkerInViewThresholdMs} value={this.state.readMarkerInViewThresholdMs}
onChange={this._onReadMarkerInViewThresholdMs} /> onChange={this.onReadMarkerInViewThresholdMs} />
<Field <Field
label={_t('Read Marker off-screen lifetime (ms)')} label={_t('Read Marker off-screen lifetime (ms)')}
type='number' type='number'
value={this.state.readMarkerOutOfViewThresholdMs} value={this.state.readMarkerOutOfViewThresholdMs}
onChange={this._onReadMarkerOutOfViewThresholdMs} /> onChange={this.onReadMarkerOutOfViewThresholdMs} />
</div> </div>
</div> </div>
); );

View file

@ -96,6 +96,9 @@ export class Media {
*/ */
public getThumbnailHttp(width: number, height: number, mode: ResizeMethod = "scale"): string | null | undefined { public getThumbnailHttp(width: number, height: number, mode: ResizeMethod = "scale"): string | null | undefined {
if (!this.hasThumbnail) return null; if (!this.hasThumbnail) return null;
// scale using the device pixel ratio to keep images clear
width = Math.floor(width * window.devicePixelRatio);
height = Math.floor(height * window.devicePixelRatio);
return this.client.mxcUrlToHttp(this.thumbnailMxc, width, height, mode); return this.client.mxcUrlToHttp(this.thumbnailMxc, width, height, mode);
} }
@ -107,6 +110,9 @@ export class Media {
* @returns {string} The HTTP URL which points to the thumbnail. * @returns {string} The HTTP URL which points to the thumbnail.
*/ */
public getThumbnailOfSourceHttp(width: number, height: number, mode: ResizeMethod = "scale"): string { public getThumbnailOfSourceHttp(width: number, height: number, mode: ResizeMethod = "scale"): string {
// scale using the device pixel ratio to keep images clear
width = Math.floor(width * window.devicePixelRatio);
height = Math.floor(height * window.devicePixelRatio);
return this.client.mxcUrlToHttp(this.srcMxc, width, height, mode); return this.client.mxcUrlToHttp(this.srcMxc, width, height, mode);
} }
@ -117,6 +123,7 @@ export class Media {
* @returns {string} An HTTP URL for the thumbnail. * @returns {string} An HTTP URL for the thumbnail.
*/ */
public getSquareThumbnailHttp(dim: number): string { public getSquareThumbnailHttp(dim: number): string {
dim = Math.floor(dim * window.devicePixelRatio); // scale using the device pixel ratio to keep images clear
if (this.hasThumbnail) { if (this.hasThumbnail) {
return this.getThumbnailHttp(dim, dim, 'crop'); return this.getThumbnailHttp(dim, dim, 'crop');
} }

View file

@ -341,11 +341,7 @@ class RoomPillPart extends PillPart {
setAvatar(node: HTMLElement) { setAvatar(node: HTMLElement) {
let initialLetter = ""; let initialLetter = "";
let avatarUrl = Avatar.avatarUrlForRoom( let avatarUrl = Avatar.avatarUrlForRoom(this.room, 16, 16, "crop");
this.room,
16 * window.devicePixelRatio,
16 * window.devicePixelRatio,
"crop");
if (!avatarUrl) { if (!avatarUrl) {
initialLetter = Avatar.getInitialLetter(this.room ? this.room.name : this.resourceId); initialLetter = Avatar.getInitialLetter(this.room ? this.room.name : this.resourceId);
avatarUrl = Avatar.defaultAvatarUrlForString(this.room ? this.room.roomId : this.resourceId); avatarUrl = Avatar.defaultAvatarUrlForString(this.room ? this.room.roomId : this.resourceId);
@ -383,11 +379,7 @@ class UserPillPart extends PillPart {
} }
const name = this.member.name || this.member.userId; const name = this.member.name || this.member.userId;
const defaultAvatarUrl = Avatar.defaultAvatarUrlForString(this.member.userId); const defaultAvatarUrl = Avatar.defaultAvatarUrlForString(this.member.userId);
const avatarUrl = Avatar.avatarUrlForMember( const avatarUrl = Avatar.avatarUrlForMember(this.member, 16, 16, "crop");
this.member,
16 * window.devicePixelRatio,
16 * window.devicePixelRatio,
"crop");
let initialLetter = ""; let initialLetter = "";
if (avatarUrl === defaultAvatarUrl) { if (avatarUrl === defaultAvatarUrl) {
initialLetter = Avatar.getInitialLetter(name); initialLetter = Avatar.getInitialLetter(name);

View file

@ -658,7 +658,6 @@
"No homeserver URL provided": "No homeserver URL provided", "No homeserver URL provided": "No homeserver URL provided",
"Unexpected error resolving homeserver configuration": "Unexpected error resolving homeserver configuration", "Unexpected error resolving homeserver configuration": "Unexpected error resolving homeserver configuration",
"Unexpected error resolving identity server configuration": "Unexpected error resolving identity server configuration", "Unexpected error resolving identity server configuration": "Unexpected error resolving identity server configuration",
"The message you are trying to send is too large.": "The message you are trying to send is too large.",
"This homeserver has hit its Monthly Active User limit.": "This homeserver has hit its Monthly Active User limit.", "This homeserver has hit its Monthly Active User limit.": "This homeserver has hit its Monthly Active User limit.",
"This homeserver has been blocked by its administrator.": "This homeserver has been blocked by its administrator.", "This homeserver has been blocked by its administrator.": "This homeserver has been blocked by its administrator.",
"This homeserver has exceeded one of its resource limits.": "This homeserver has exceeded one of its resource limits.", "This homeserver has exceeded one of its resource limits.": "This homeserver has exceeded one of its resource limits.",
@ -1454,6 +1453,7 @@
"Sending your message...": "Sending your message...", "Sending your message...": "Sending your message...",
"Encrypting your message...": "Encrypting your message...", "Encrypting your message...": "Encrypting your message...",
"Your message was sent": "Your message was sent", "Your message was sent": "Your message was sent",
"Failed to send": "Failed to send",
"Please select the destination room for this message": "Please select the destination room for this message", "Please select the destination room for this message": "Please select the destination room for this message",
"Scroll to most recent messages": "Scroll to most recent messages", "Scroll to most recent messages": "Scroll to most recent messages",
"Close preview": "Close preview", "Close preview": "Close preview",
@ -1812,8 +1812,9 @@
"The encryption used by this room isn't supported.": "The encryption used by this room isn't supported.", "The encryption used by this room isn't supported.": "The encryption used by this room isn't supported.",
"Error decrypting audio": "Error decrypting audio", "Error decrypting audio": "Error decrypting audio",
"React": "React", "React": "React",
"Reply": "Reply",
"Edit": "Edit", "Edit": "Edit",
"Retry": "Retry",
"Reply": "Reply",
"Message Actions": "Message Actions", "Message Actions": "Message Actions",
"Attachment": "Attachment", "Attachment": "Attachment",
"Error decrypting attachment": "Error decrypting attachment", "Error decrypting attachment": "Error decrypting attachment",
@ -1924,10 +1925,10 @@
"%(count)s members including %(commaSeparatedMembers)s|one": "%(commaSeparatedMembers)s", "%(count)s members including %(commaSeparatedMembers)s|one": "%(commaSeparatedMembers)s",
"%(count)s people you know have already joined|other": "%(count)s people you know have already joined", "%(count)s people you know have already joined|other": "%(count)s people you know have already joined",
"%(count)s people you know have already joined|one": "%(count)s person you know has already joined", "%(count)s people you know have already joined|one": "%(count)s person you know has already joined",
"Rotate Right": "Rotate Right",
"Rotate Left": "Rotate Left",
"Zoom out": "Zoom out", "Zoom out": "Zoom out",
"Zoom in": "Zoom in", "Zoom in": "Zoom in",
"Rotate Right": "Rotate Right",
"Rotate Left": "Rotate Left",
"Download": "Download", "Download": "Download",
"Information": "Information", "Information": "Information",
"View message": "View message", "View message": "View message",
@ -2398,7 +2399,6 @@
"Confirm encryption setup": "Confirm encryption setup", "Confirm encryption setup": "Confirm encryption setup",
"Click the button below to confirm setting up encryption.": "Click the button below to confirm setting up encryption.", "Click the button below to confirm setting up encryption.": "Click the button below to confirm setting up encryption.",
"Unable to set up keys": "Unable to set up keys", "Unable to set up keys": "Unable to set up keys",
"Retry": "Retry",
"Restoring keys from backup": "Restoring keys from backup", "Restoring keys from backup": "Restoring keys from backup",
"Fetching keys from server...": "Fetching keys from server...", "Fetching keys from server...": "Fetching keys from server...",
"%(completed)s of %(total)s keys restored": "%(completed)s of %(total)s keys restored", "%(completed)s of %(total)s keys restored": "%(completed)s of %(total)s keys restored",
@ -2427,10 +2427,7 @@
"Reject invitation": "Reject invitation", "Reject invitation": "Reject invitation",
"Are you sure you want to reject the invitation?": "Are you sure you want to reject the invitation?", "Are you sure you want to reject the invitation?": "Are you sure you want to reject the invitation?",
"Unable to reject invite": "Unable to reject invite", "Unable to reject invite": "Unable to reject invite",
"Resend edit": "Resend edit",
"Resend %(unsentCount)s reaction(s)": "Resend %(unsentCount)s reaction(s)", "Resend %(unsentCount)s reaction(s)": "Resend %(unsentCount)s reaction(s)",
"Resend removal": "Resend removal",
"Cancel Sending": "Cancel Sending",
"Forward Message": "Forward Message", "Forward Message": "Forward Message",
"Pin Message": "Pin Message", "Pin Message": "Pin Message",
"Unhide Preview": "Unhide Preview", "Unhide Preview": "Unhide Preview",
@ -2618,10 +2615,11 @@
"Your message wasn't sent because this homeserver has hit its Monthly Active User Limit. Please <a>contact your service administrator</a> to continue using the service.": "Your message wasn't sent because this homeserver has hit its Monthly Active User Limit. Please <a>contact your service administrator</a> to continue using the service.", "Your message wasn't sent because this homeserver has hit its Monthly Active User Limit. Please <a>contact your service administrator</a> to continue using the service.": "Your message wasn't sent because this homeserver has hit its Monthly Active User Limit. Please <a>contact your service administrator</a> to continue using the service.",
"Your message wasn't sent because this homeserver has been blocked by it's administrator. Please <a>contact your service administrator</a> to continue using the service.": "Your message wasn't sent because this homeserver has been blocked by it's administrator. Please <a>contact your service administrator</a> to continue using the service.", "Your message wasn't sent because this homeserver has been blocked by it's administrator. Please <a>contact your service administrator</a> to continue using the service.": "Your message wasn't sent because this homeserver has been blocked by it's administrator. Please <a>contact your service administrator</a> to continue using the service.",
"Your message wasn't sent because this homeserver has exceeded a resource limit. Please <a>contact your service administrator</a> to continue using the service.": "Your message wasn't sent because this homeserver has exceeded a resource limit. Please <a>contact your service administrator</a> to continue using the service.", "Your message wasn't sent because this homeserver has exceeded a resource limit. Please <a>contact your service administrator</a> to continue using the service.": "Your message wasn't sent because this homeserver has exceeded a resource limit. Please <a>contact your service administrator</a> to continue using the service.",
"%(count)s of your messages have not been sent.|other": "Some of your messages have not been sent.", "Some of your messages have not been sent": "Some of your messages have not been sent",
"%(count)s of your messages have not been sent.|one": "Your message was not sent.", "Delete all": "Delete all",
"%(count)s <resendText>Resend all</resendText> or <cancelText>cancel all</cancelText> now. You can also select individual messages to resend or cancel.|other": "<resendText>Resend all</resendText> or <cancelText>cancel all</cancelText> now. You can also select individual messages to resend or cancel.", "Retry all": "Retry all",
"%(count)s <resendText>Resend all</resendText> or <cancelText>cancel all</cancelText> now. You can also select individual messages to resend or cancel.|one": "<resendText>Resend message</resendText> or <cancelText>cancel message</cancelText> now.", "Sending": "Sending",
"You can select all or individual messages to retry or delete": "You can select all or individual messages to retry or delete",
"Connectivity to the server has been lost.": "Connectivity to the server has been lost.", "Connectivity to the server has been lost.": "Connectivity to the server has been lost.",
"Sent messages will be stored until your connection has returned.": "Sent messages will be stored until your connection has returned.", "Sent messages will be stored until your connection has returned.": "Sent messages will be stored until your connection has returned.",
"You seem to be uploading files, are you sure you want to quit?": "You seem to be uploading files, are you sure you want to quit?", "You seem to be uploading files, are you sure you want to quit?": "You seem to be uploading files, are you sure you want to quit?",
@ -2641,6 +2639,7 @@
"%(count)s rooms|one": "%(count)s room", "%(count)s rooms|one": "%(count)s room",
"This room is suggested as a good one to join": "This room is suggested as a good one to join", "This room is suggested as a good one to join": "This room is suggested as a good one to join",
"Suggested": "Suggested", "Suggested": "Suggested",
"Your server does not support showing space hierarchies.": "Your server does not support showing space hierarchies.",
"%(count)s rooms and %(numSpaces)s spaces|other": "%(count)s rooms and %(numSpaces)s spaces", "%(count)s rooms and %(numSpaces)s spaces|other": "%(count)s rooms and %(numSpaces)s spaces",
"%(count)s rooms and %(numSpaces)s spaces|one": "%(count)s room and %(numSpaces)s spaces", "%(count)s rooms and %(numSpaces)s spaces|one": "%(count)s room and %(numSpaces)s spaces",
"%(count)s rooms and 1 space|other": "%(count)s rooms and 1 space", "%(count)s rooms and 1 space|other": "%(count)s rooms and 1 space",
@ -2651,7 +2650,6 @@
"Mark as suggested": "Mark as suggested", "Mark as suggested": "Mark as suggested",
"No results found": "No results found", "No results found": "No results found",
"You may want to try a different search or check for typos.": "You may want to try a different search or check for typos.", "You may want to try a different search or check for typos.": "You may want to try a different search or check for typos.",
"Your server does not support showing space hierarchies.": "Your server does not support showing space hierarchies.",
"Search names and description": "Search names and description", "Search names and description": "Search names and description",
"If you can't find the room you're looking for, ask for an invite or <a>create a new room</a>.": "If you can't find the room you're looking for, ask for an invite or <a>create a new room</a>.", "If you can't find the room you're looking for, ask for an invite or <a>create a new room</a>.": "If you can't find the room you're looking for, ask for an invite or <a>create a new room</a>.",
"Create room": "Create room", "Create room": "Create room",

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2019, 2020 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.
@ -133,6 +133,10 @@ export default abstract class BaseEventIndexManager {
throw new Error("Unimplemented"); throw new Error("Unimplemented");
} }
async isEventIndexEmpty(): Promise<boolean> {
throw new Error("Unimplemented");
}
/** /**
* Check if our event index is empty. * Check if our event index is empty.
*/ */

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.
@ -27,12 +27,11 @@ import {SettingLevel} from "../settings/SettingLevel";
const INDEX_VERSION = 1; const INDEX_VERSION = 1;
class EventIndexPeg { export class EventIndexPeg {
constructor() { public index: EventIndex = null;
this.index = null; public error: Error = null;
this._supportIsInstalled = false;
this.error = null; private _supportIsInstalled = false;
}
/** /**
* Initialize the EventIndexPeg and if event indexing is enabled initialize * Initialize the EventIndexPeg and if event indexing is enabled initialize
@ -181,7 +180,7 @@ class EventIndexPeg {
} }
} }
if (!global.mxEventIndexPeg) { if (!window.mxEventIndexPeg) {
global.mxEventIndexPeg = new EventIndexPeg(); window.mxEventIndexPeg = new EventIndexPeg();
} }
export default global.mxEventIndexPeg; export default window.mxEventIndexPeg;

View file

@ -113,7 +113,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
} }
public async setActiveSpace(space: Room | null, contextSwitch = true) { public async setActiveSpace(space: Room | null, contextSwitch = true) {
if (space === this.activeSpace) return; if (space === this.activeSpace || (space && !space?.isSpaceRoom())) return;
this._activeSpace = space; this._activeSpace = space;
this.emit(UPDATE_SELECTED_SPACE, this.activeSpace); this.emit(UPDATE_SELECTED_SPACE, this.activeSpace);
@ -195,7 +195,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
const childEvents = room?.currentState.getStateEvents(EventType.SpaceChild).filter(ev => ev.getContent()?.via); const childEvents = room?.currentState.getStateEvents(EventType.SpaceChild).filter(ev => ev.getContent()?.via);
return sortBy(childEvents, getOrder) return sortBy(childEvents, getOrder)
.map(ev => this.matrixClient.getRoom(ev.getStateKey())) .map(ev => this.matrixClient.getRoom(ev.getStateKey()))
.filter(room => room?.getMyMembership() === "join") || []; .filter(room => room?.getMyMembership() === "join" || room?.getMyMembership() === "invite") || [];
} }
public getChildRooms(spaceId: string): Room[] { public getChildRooms(spaceId: string): Room[] {
@ -203,7 +203,8 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
} }
public getChildSpaces(spaceId: string): Room[] { public getChildSpaces(spaceId: string): Room[] {
return this.getChildren(spaceId).filter(r => r.isSpaceRoom()); // don't show invited subspaces as they surface at the top level for better visibility
return this.getChildren(spaceId).filter(r => r.isSpaceRoom() && r.getMyMembership() === "join");
} }
public getParents(roomId: string, canonicalOnly = false): Room[] { public getParents(roomId: string, canonicalOnly = false): Room[] {
@ -409,33 +410,40 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
}); });
}, 100, {trailing: true, leading: true}); }, 100, {trailing: true, leading: true});
private onRoom = (room: Room, membership?: string, oldMembership?: string) => { private onRoom = (room: Room, newMembership?: string, oldMembership?: string) => {
if ((membership || room.getMyMembership()) === "invite") { const membership = newMembership || room.getMyMembership();
this._invitedSpaces.add(room);
this.emit(UPDATE_INVITED_SPACES, this.invitedSpaces); if (!room.isSpaceRoom()) {
} else if (oldMembership === "invite") {
this._invitedSpaces.delete(room);
this.emit(UPDATE_INVITED_SPACES, this.invitedSpaces);
} else if (room?.isSpaceRoom()) {
this.onSpaceUpdate();
this.emit(room.roomId);
} else {
// this.onRoomUpdate(room); // this.onRoomUpdate(room);
this.onRoomsUpdate(); this.onRoomsUpdate();
}
if (room.getMyMembership() === "join") { if (membership === "join") {
if (!room.isSpaceRoom()) { // the user just joined a room, remove it from the suggested list if it was there
const numSuggestedRooms = this._suggestedRooms.length; const numSuggestedRooms = this._suggestedRooms.length;
this._suggestedRooms = this._suggestedRooms.filter(r => r.room_id !== room.roomId); this._suggestedRooms = this._suggestedRooms.filter(r => r.room_id !== room.roomId);
if (numSuggestedRooms !== this._suggestedRooms.length) { if (numSuggestedRooms !== this._suggestedRooms.length) {
this.emit(SUGGESTED_ROOMS, this._suggestedRooms); this.emit(SUGGESTED_ROOMS, this._suggestedRooms);
} }
} else if (room.roomId === RoomViewStore.getRoomId()) { }
return;
}
// Space
if (membership === "invite") {
this._invitedSpaces.add(room);
this.emit(UPDATE_INVITED_SPACES, this.invitedSpaces);
} else if (oldMembership === "invite" && membership !== "join") {
this._invitedSpaces.delete(room);
this.emit(UPDATE_INVITED_SPACES, this.invitedSpaces);
} else {
this.onSpaceUpdate();
this.emit(room.roomId);
}
if (membership === "join" && room.roomId === RoomViewStore.getRoomId()) {
// if the user was looking at the space and then joined: select that space // if the user was looking at the space and then joined: select that space
this.setActiveSpace(room); this.setActiveSpace(room);
} }
}
}; };
private onRoomState = (ev: MatrixEvent) => { private onRoomState = (ev: MatrixEvent) => {
@ -498,6 +506,17 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
} }
}; };
protected async reset() {
this.rootSpaces = [];
this.orphanedRooms = new Set();
this.parentMap = new EnhancedMap();
this.notificationStateMap = new Map();
this.spaceFilteredRooms = new Map();
this._activeSpace = null;
this._suggestedRooms = [];
this._invitedSpaces = new Set();
}
protected async onNotReady() { protected async onNotReady() {
if (!SettingsStore.getValue("feature_spaces")) return; if (!SettingsStore.getValue("feature_spaces")) return;
if (this.matrixClient) { if (this.matrixClient) {
@ -507,7 +526,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
this.matrixClient.removeListener("Room.accountData", this.onRoomAccountData); this.matrixClient.removeListener("Room.accountData", this.onRoomAccountData);
this.matrixClient.removeListener("accountData", this.onAccountData); this.matrixClient.removeListener("accountData", this.onAccountData);
} }
await this.reset({}); await this.reset();
} }
protected async onReady() { protected async onReady() {
@ -540,15 +559,14 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
// as this is not helpful and can create loops of rooms/space switching // as this is not helpful and can create loops of rooms/space switching
if (!room || payload.context_switch) break; if (!room || payload.context_switch) break;
// persist last viewed room from a space
if (room.isSpaceRoom()) { if (room.isSpaceRoom()) {
this.setActiveSpace(room); // Don't context switch when navigating to the space room
// as it will cause you to end up in the wrong room
this.setActiveSpace(room, false);
} else if (!this.getSpaceFilteredRoomIds(this.activeSpace).has(room.roomId)) { } else if (!this.getSpaceFilteredRoomIds(this.activeSpace).has(room.roomId)) {
// TODO maybe reverse these first 2 clauses once space panel active is fixed let parent = this.getCanonicalParent(room.roomId);
let parent = this.rootSpaces.find(s => this.spaceFilteredRooms.get(s.roomId)?.has(room.roomId));
if (!parent) { if (!parent) {
parent = this.getCanonicalParent(room.roomId); parent = this.rootSpaces.find(s => this.spaceFilteredRooms.get(s.roomId)?.has(room.roomId));
} }
if (!parent) { if (!parent) {
const parents = Array.from(this.parentMap.get(room.roomId) || []); const parents = Array.from(this.parentMap.get(room.roomId) || []);
@ -582,7 +600,9 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
return state; return state;
} }
// traverse space tree with DFS calling fn on each space including the given root one // traverse space tree with DFS calling fn on each space including the given root one,
// if includeRooms is true then fn will be called on each leaf room, if it is present in multiple sub-spaces
// then fn will be called with it multiple times.
public traverseSpace( public traverseSpace(
spaceId: string, spaceId: string,
fn: (roomId: string) => void, fn: (roomId: string) => void,

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.
@ -25,15 +25,23 @@ const TYPING_SERVER_TIMEOUT = 30000;
* Tracks typing state for users. * Tracks typing state for users.
*/ */
export default class TypingStore { export default class TypingStore {
private typingStates: {
[roomId: string]: {
isTyping: boolean,
userTimer: Timer,
serverTimer: Timer,
},
};
constructor() { constructor() {
this.reset(); this.reset();
} }
static sharedInstance(): TypingStore { static sharedInstance(): TypingStore {
if (global.mxTypingStore === undefined) { if (window.mxTypingStore === undefined) {
global.mxTypingStore = new TypingStore(); window.mxTypingStore = new TypingStore();
} }
return global.mxTypingStore; return window.mxTypingStore;
} }
/** /**
@ -41,7 +49,7 @@ export default class TypingStore {
* MatrixClientPeg client changes. * MatrixClientPeg client changes.
*/ */
reset() { reset() {
this._typingStates = { this.typingStates = {
// "roomId": { // "roomId": {
// isTyping: bool, // Whether the user is typing or not // isTyping: bool, // Whether the user is typing or not
// userTimer: Timer, // Local timeout for "user has stopped typing" // userTimer: Timer, // Local timeout for "user has stopped typing"
@ -59,14 +67,14 @@ export default class TypingStore {
if (!SettingsStore.getValue('sendTypingNotifications')) return; if (!SettingsStore.getValue('sendTypingNotifications')) return;
if (SettingsStore.getValue('lowBandwidth')) return; if (SettingsStore.getValue('lowBandwidth')) return;
let currentTyping = this._typingStates[roomId]; let currentTyping = this.typingStates[roomId];
if ((!isTyping && !currentTyping) || (currentTyping && currentTyping.isTyping === isTyping)) { if ((!isTyping && !currentTyping) || (currentTyping && currentTyping.isTyping === isTyping)) {
// No change in state, so don't do anything. We'll let the timer run its course. // No change in state, so don't do anything. We'll let the timer run its course.
return; return;
} }
if (!currentTyping) { if (!currentTyping) {
currentTyping = this._typingStates[roomId] = { currentTyping = this.typingStates[roomId] = {
isTyping: isTyping, isTyping: isTyping,
serverTimer: new Timer(TYPING_SERVER_TIMEOUT), serverTimer: new Timer(TYPING_SERVER_TIMEOUT),
userTimer: new Timer(TYPING_USER_TIMEOUT), userTimer: new Timer(TYPING_USER_TIMEOUT),
@ -78,7 +86,7 @@ export default class TypingStore {
if (isTyping) { if (isTyping) {
if (!currentTyping.serverTimer.isRunning()) { if (!currentTyping.serverTimer.isRunning()) {
currentTyping.serverTimer.restart().finished().then(() => { currentTyping.serverTimer.restart().finished().then(() => {
const currentTyping = this._typingStates[roomId]; const currentTyping = this.typingStates[roomId];
if (currentTyping) currentTyping.isTyping = false; if (currentTyping) currentTyping.isTyping = false;
// The server will (should) time us out on typing, so we don't // The server will (should) time us out on typing, so we don't

View file

@ -1,6 +1,5 @@
/* /*
Copyright 2018 New Vector Ltd Copyright 2018-2021 The Matrix.org Foundation C.I.C.
Copyright 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -16,6 +15,8 @@ limitations under the License.
*/ */
import EventEmitter from 'events'; import EventEmitter from 'events';
import { IWidget } from 'matrix-widget-api';
import MatrixEvent from "matrix-js-sdk/src/models/event";
import {WidgetType} from "../widgets/WidgetType"; import {WidgetType} from "../widgets/WidgetType";
/** /**
@ -23,14 +24,20 @@ import {WidgetType} from "../widgets/WidgetType";
* proxying through state from the js-sdk. * proxying through state from the js-sdk.
*/ */
class WidgetEchoStore extends EventEmitter { class WidgetEchoStore extends EventEmitter {
private roomWidgetEcho: {
[roomId: string]: {
[widgetId: string]: IWidget,
},
};
constructor() { constructor() {
super(); super();
this._roomWidgetEcho = { this.roomWidgetEcho = {
// Map as below. Object is the content of the widget state event, // Map as below. Object is the content of the widget state event,
// so for widgets that have been deleted locally, the object is empty. // so for widgets that have been deleted locally, the object is empty.
// roomId: { // roomId: {
// widgetId: [object] // widgetId: IWidget
// } // }
}; };
} }
@ -42,14 +49,14 @@ class WidgetEchoStore extends EventEmitter {
* and we don't really need the actual widget events anyway since we just want to * and we don't really need the actual widget events anyway since we just want to
* show a spinner / prevent widgets being added twice. * show a spinner / prevent widgets being added twice.
* *
* @param {Room} roomId The ID of the room to get widgets for * @param {string} roomId The ID of the room to get widgets for
* @param {MatrixEvent[]} currentRoomWidgets Current widgets for the room * @param {MatrixEvent[]} currentRoomWidgets Current widgets for the room
* @returns {MatrixEvent[]} List of widgets in the room, minus any pending removal * @returns {MatrixEvent[]} List of widgets in the room, minus any pending removal
*/ */
getEchoedRoomWidgets(roomId, currentRoomWidgets) { getEchoedRoomWidgets(roomId: string, currentRoomWidgets: MatrixEvent[]): MatrixEvent[] {
const echoedWidgets = []; const echoedWidgets = [];
const roomEchoState = Object.assign({}, this._roomWidgetEcho[roomId]); const roomEchoState = Object.assign({}, this.roomWidgetEcho[roomId]);
for (const w of currentRoomWidgets) { for (const w of currentRoomWidgets) {
const widgetId = w.getStateKey(); const widgetId = w.getStateKey();
@ -65,8 +72,8 @@ class WidgetEchoStore extends EventEmitter {
return echoedWidgets; return echoedWidgets;
} }
roomHasPendingWidgetsOfType(roomId, currentRoomWidgets, type: WidgetType) { roomHasPendingWidgetsOfType(roomId: string, currentRoomWidgets: MatrixEvent[], type?: WidgetType): boolean {
const roomEchoState = Object.assign({}, this._roomWidgetEcho[roomId]); const roomEchoState = Object.assign({}, this.roomWidgetEcho[roomId]);
// any widget IDs that are already in the room are not pending, so // any widget IDs that are already in the room are not pending, so
// echoes for them don't count as pending. // echoes for them don't count as pending.
@ -85,20 +92,20 @@ class WidgetEchoStore extends EventEmitter {
} }
} }
roomHasPendingWidgets(roomId, currentRoomWidgets) { roomHasPendingWidgets(roomId: string, currentRoomWidgets: MatrixEvent[]): boolean {
return this.roomHasPendingWidgetsOfType(roomId, currentRoomWidgets); return this.roomHasPendingWidgetsOfType(roomId, currentRoomWidgets);
} }
setRoomWidgetEcho(roomId, widgetId, state) { setRoomWidgetEcho(roomId: string, widgetId: string, state: IWidget) {
if (this._roomWidgetEcho[roomId] === undefined) this._roomWidgetEcho[roomId] = {}; if (this.roomWidgetEcho[roomId] === undefined) this.roomWidgetEcho[roomId] = {};
this._roomWidgetEcho[roomId][widgetId] = state; this.roomWidgetEcho[roomId][widgetId] = state;
this.emit('update', roomId, widgetId); this.emit('update', roomId, widgetId);
} }
removeRoomWidgetEcho(roomId, widgetId) { removeRoomWidgetEcho(roomId: string, widgetId: string) {
delete this._roomWidgetEcho[roomId][widgetId]; delete this.roomWidgetEcho[roomId][widgetId];
if (Object.keys(this._roomWidgetEcho[roomId]).length === 0) delete this._roomWidgetEcho[roomId]; if (Object.keys(this.roomWidgetEcho[roomId]).length === 0) delete this.roomWidgetEcho[roomId];
this.emit('update', roomId, widgetId); this.emit('update', roomId, widgetId);
} }
} }

View file

@ -18,6 +18,8 @@ import { NotificationColor } from "./NotificationColor";
import { NotificationState } from "./NotificationState"; import { NotificationState } from "./NotificationState";
export class StaticNotificationState extends NotificationState { export class StaticNotificationState extends NotificationState {
public static readonly RED_EXCLAMATION = StaticNotificationState.forSymbol("!", NotificationColor.Red);
constructor(symbol: string, count: number, color: NotificationColor) { constructor(symbol: string, count: number, color: NotificationColor) {
super(); super();
this._symbol = symbol; this._symbol = symbol;

View file

@ -37,7 +37,11 @@ export class VisibilityProvider {
await VoipUserMapper.sharedInstance().onNewInvitedRoom(room); await VoipUserMapper.sharedInstance().onNewInvitedRoom(room);
} }
public isRoomVisible(room: Room): boolean { public isRoomVisible(room?: Room): boolean {
if (!room) {
return false;
}
if ( if (
CallHandler.sharedInstance().getSupportsVirtualRooms() && CallHandler.sharedInstance().getSupportsVirtualRooms() &&
VoipUserMapper.sharedInstance().isVirtualRoom(room) VoipUserMapper.sharedInstance().isVirtualRoom(room)

View file

@ -20,7 +20,7 @@ import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import { getSenderName, isSelf, shouldPrefixMessagesIn } from "./utils"; import { getSenderName, isSelf, shouldPrefixMessagesIn } from "./utils";
import ReplyThread from "../../../components/views/elements/ReplyThread"; import ReplyThread from "../../../components/views/elements/ReplyThread";
import { sanitizedHtmlNodeInnerText } from "../../../HtmlUtils"; import { getHtmlText } from "../../../HtmlUtils";
export class MessageEventPreview implements IPreview { export class MessageEventPreview implements IPreview {
public getTextFor(event: MatrixEvent, tagId?: TagID): string { public getTextFor(event: MatrixEvent, tagId?: TagID): string {
@ -55,7 +55,7 @@ export class MessageEventPreview implements IPreview {
} }
if (hasHtml) { if (hasHtml) {
body = sanitizedHtmlNodeInnerText(body); body = getHtmlText(body);
} }
if (msgtype === 'm.emote') { if (msgtype === 'm.emote') {

View file

@ -1,6 +1,5 @@
/* /*
Copyright 2019 New Vector Ltd Copyright 2019-2021 The Matrix.org Foundation C.I.C.
Copyright 2019, 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.
@ -15,13 +14,13 @@ 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 {AutoDiscovery} from "matrix-js-sdk/src/autodiscovery"; import {AutoDiscovery} from "matrix-js-sdk/src/autodiscovery";
import {_t, _td, newTranslatableError} from "../languageHandler"; import {_t, _td, newTranslatableError} from "../languageHandler";
import {makeType} from "./TypeUtils"; import {makeType} from "./TypeUtils";
import SdkConfig from '../SdkConfig'; import SdkConfig from '../SdkConfig';
const LIVELINESS_DISCOVERY_ERRORS = [ const LIVELINESS_DISCOVERY_ERRORS: string[] = [
AutoDiscovery.ERROR_INVALID_HOMESERVER, AutoDiscovery.ERROR_INVALID_HOMESERVER,
AutoDiscovery.ERROR_INVALID_IDENTITY_SERVER, AutoDiscovery.ERROR_INVALID_IDENTITY_SERVER,
]; ];
@ -40,17 +39,23 @@ export class ValidatedServerConfig {
warning: string; warning: string;
} }
export interface IAuthComponentState {
serverIsAlive: boolean;
serverErrorIsFatal: boolean;
serverDeadError?: ReactNode;
}
export default class AutoDiscoveryUtils { export default class AutoDiscoveryUtils {
/** /**
* Checks if a given error or error message is considered an error * Checks if a given error or error message is considered an error
* relating to the liveliness of the server. Must be an error returned * relating to the liveliness of the server. Must be an error returned
* from this AutoDiscoveryUtils class. * from this AutoDiscoveryUtils class.
* @param {string|Error} error The error to check * @param {string | Error} error The error to check
* @returns {boolean} True if the error is a liveliness error. * @returns {boolean} True if the error is a liveliness error.
*/ */
static isLivelinessError(error: string|Error): boolean { static isLivelinessError(error: string | Error): boolean {
if (!error) return false; if (!error) return false;
return !!LIVELINESS_DISCOVERY_ERRORS.find(e => e === error || e === error.message); return !!LIVELINESS_DISCOVERY_ERRORS.find(e => typeof error === "string" ? e === error : e === error.message);
} }
/** /**
@ -61,7 +66,7 @@ export default class AutoDiscoveryUtils {
* implementation for known values. * implementation for known values.
* @returns {*} The state for the component, given the error. * @returns {*} The state for the component, given the error.
*/ */
static authComponentStateForError(err: string | Error | null, pageName = "login"): Object { static authComponentStateForError(err: string | Error | null, pageName = "login"): IAuthComponentState {
if (!err) { if (!err) {
return { return {
serverIsAlive: true, serverIsAlive: true,
@ -70,7 +75,7 @@ export default class AutoDiscoveryUtils {
}; };
} }
let title = _t("Cannot reach homeserver"); let title = _t("Cannot reach homeserver");
let body = _t("Ensure you have a stable internet connection, or get in touch with the server admin"); let body: ReactNode = _t("Ensure you have a stable internet connection, or get in touch with the server admin");
if (!AutoDiscoveryUtils.isLivelinessError(err)) { if (!AutoDiscoveryUtils.isLivelinessError(err)) {
const brand = SdkConfig.get().brand; const brand = SdkConfig.get().brand;
title = _t("Your %(brand)s is misconfigured", { brand }); title = _t("Your %(brand)s is misconfigured", { brand });
@ -92,7 +97,7 @@ export default class AutoDiscoveryUtils {
} }
let isFatalError = true; let isFatalError = true;
const errorMessage = err.message ? err.message : err; const errorMessage = typeof err === "string" ? err : err.message;
if (errorMessage === AutoDiscovery.ERROR_INVALID_IDENTITY_SERVER) { if (errorMessage === AutoDiscovery.ERROR_INVALID_IDENTITY_SERVER) {
isFatalError = false; isFatalError = false;
title = _t("Cannot reach identity server"); title = _t("Cannot reach identity server");
@ -141,7 +146,10 @@ export default class AutoDiscoveryUtils {
* @returns {Promise<ValidatedServerConfig>} Resolves to the validated configuration. * @returns {Promise<ValidatedServerConfig>} Resolves to the validated configuration.
*/ */
static async validateServerConfigWithStaticUrls( static async validateServerConfigWithStaticUrls(
homeserverUrl: string, identityUrl: string, syntaxOnly = false): ValidatedServerConfig { homeserverUrl: string,
identityUrl?: string,
syntaxOnly = false,
): Promise<ValidatedServerConfig> {
if (!homeserverUrl) { if (!homeserverUrl) {
throw newTranslatableError(_td("No homeserver URL provided")); throw newTranslatableError(_td("No homeserver URL provided"));
} }
@ -171,7 +179,7 @@ export default class AutoDiscoveryUtils {
* @param {string} serverName The homeserver domain name (eg: "matrix.org") to validate. * @param {string} serverName The homeserver domain name (eg: "matrix.org") to validate.
* @returns {Promise<ValidatedServerConfig>} Resolves to the validated configuration. * @returns {Promise<ValidatedServerConfig>} Resolves to the validated configuration.
*/ */
static async validateServerName(serverName: string): ValidatedServerConfig { static async validateServerName(serverName: string): Promise<ValidatedServerConfig> {
const result = await AutoDiscovery.findClientConfig(serverName); const result = await AutoDiscovery.findClientConfig(serverName);
return AutoDiscoveryUtils.buildValidatedConfigFromDiscovery(serverName, result); return AutoDiscoveryUtils.buildValidatedConfigFromDiscovery(serverName, result);
} }

View file

@ -49,12 +49,6 @@ export function messageForResourceLimitError(limitType, adminContact, strings, e
} }
} }
export function messageForSendError(errorData) {
if (errorData.errcode === "M_TOO_LARGE") {
return _t("The message you are trying to send is too large.");
}
}
export function messageForSyncError(err) { export function messageForSyncError(err) {
if (err.errcode === 'M_RESOURCE_LIMIT_EXCEEDED') { if (err.errcode === 'M_RESOURCE_LIMIT_EXCEEDED') {
const limitError = messageForResourceLimitError( const limitError = messageForResourceLimitError(

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.
@ -32,15 +32,15 @@ try {
const SYNC_STORE_NAME = "riot-web-sync"; const SYNC_STORE_NAME = "riot-web-sync";
const CRYPTO_STORE_NAME = "matrix-js-sdk:crypto"; const CRYPTO_STORE_NAME = "matrix-js-sdk:crypto";
function log(msg) { function log(msg: string) {
console.log(`StorageManager: ${msg}`); console.log(`StorageManager: ${msg}`);
} }
function error(msg) { function error(msg: string, ...args: string[]) {
console.error(`StorageManager: ${msg}`); console.error(`StorageManager: ${msg}`, ...args);
} }
function track(action) { function track(action: string) {
Analytics.trackEvent("StorageManager", action); Analytics.trackEvent("StorageManager", action);
} }
@ -73,7 +73,7 @@ export async function checkConsistency() {
dataInLocalStorage = localStorage.length > 0; dataInLocalStorage = localStorage.length > 0;
log(`Local storage contains data? ${dataInLocalStorage}`); log(`Local storage contains data? ${dataInLocalStorage}`);
cryptoInited = localStorage.getItem("mx_crypto_initialised"); cryptoInited = !!localStorage.getItem("mx_crypto_initialised");
log(`Crypto initialised? ${cryptoInited}`); log(`Crypto initialised? ${cryptoInited}`);
} else { } else {
healthy = false; healthy = false;

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.
@ -26,44 +26,48 @@ Once a timer is finished or aborted, it can't be started again
a new one through `clone()` or `cloneIfRun()`. a new one through `clone()` or `cloneIfRun()`.
*/ */
export default class Timer { export default class Timer {
constructor(timeout) { private timerHandle: NodeJS.Timeout;
this._timeout = timeout; private startTs: number;
this._onTimeout = this._onTimeout.bind(this); private promise: Promise<void>;
this._setNotStarted(); private resolve: () => void;
private reject: (Error) => void;
constructor(private timeout: number) {
this.setNotStarted();
} }
_setNotStarted() { private setNotStarted() {
this._timerHandle = null; this.timerHandle = null;
this._startTs = null; this.startTs = null;
this._promise = new Promise((resolve, reject) => { this.promise = new Promise<void>((resolve, reject) => {
this._resolve = resolve; this.resolve = resolve;
this._reject = reject; this.reject = reject;
}).finally(() => { }).finally(() => {
this._timerHandle = null; this.timerHandle = null;
}); });
} }
_onTimeout() { private onTimeout = () => {
const now = Date.now(); const now = Date.now();
const elapsed = now - this._startTs; const elapsed = now - this.startTs;
if (elapsed >= this._timeout) { if (elapsed >= this.timeout) {
this._resolve(); this.resolve();
this._setNotStarted(); this.setNotStarted();
} else { } else {
const delta = this._timeout - elapsed; const delta = this.timeout - elapsed;
this._timerHandle = setTimeout(this._onTimeout, delta); this.timerHandle = setTimeout(this.onTimeout, delta);
} }
} }
changeTimeout(timeout) { changeTimeout(timeout: number) {
if (timeout === this._timeout) { if (timeout === this.timeout) {
return; return;
} }
const isSmallerTimeout = timeout < this._timeout; const isSmallerTimeout = timeout < this.timeout;
this._timeout = timeout; this.timeout = timeout;
if (this.isRunning() && isSmallerTimeout) { if (this.isRunning() && isSmallerTimeout) {
clearTimeout(this._timerHandle); clearTimeout(this.timerHandle);
this._onTimeout(); this.onTimeout();
} }
} }
@ -73,8 +77,8 @@ export default class Timer {
*/ */
start() { start() {
if (!this.isRunning()) { if (!this.isRunning()) {
this._startTs = Date.now(); this.startTs = Date.now();
this._timerHandle = setTimeout(this._onTimeout, this._timeout); this.timerHandle = setTimeout(this.onTimeout, this.timeout);
} }
return this; return this;
} }
@ -89,7 +93,7 @@ export default class Timer {
// can be called in fast succession, // can be called in fast succession,
// instead just take note and compare // instead just take note and compare
// when the already running timeout expires // when the already running timeout expires
this._startTs = Date.now(); this.startTs = Date.now();
return this; return this;
} else { } else {
return this.start(); return this.start();
@ -103,9 +107,9 @@ export default class Timer {
*/ */
abort() { abort() {
if (this.isRunning()) { if (this.isRunning()) {
clearTimeout(this._timerHandle); clearTimeout(this.timerHandle);
this._reject(new Error("Timer was aborted.")); this.reject(new Error("Timer was aborted."));
this._setNotStarted(); this.setNotStarted();
} }
return this; return this;
} }
@ -116,10 +120,10 @@ export default class Timer {
*@return {Promise} *@return {Promise}
*/ */
finished() { finished() {
return this._promise; return this.promise;
} }
isRunning() { isRunning() {
return this._timerHandle !== null; return this.timerHandle !== null;
} }
} }

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2020 The Matrix.org Foundation C.I.C. Copyright 2020, 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -15,23 +15,47 @@ limitations under the License.
*/ */
/** /**
* Quickly resample an array to have less data points. This isn't a perfect representation, * Quickly resample an array to have less/more data points. If an input which is larger
* though this does work best if given a large array to downsample to a much smaller array. * than the desired size is provided, it will be downsampled. Similarly, if the input
* @param {number[]} input The input array to downsample. * is smaller than the desired size then it will be upsampled.
* @param {number[]} input The input array to resample.
* @param {number} points The number of samples to end up with. * @param {number} points The number of samples to end up with.
* @returns {number[]} The downsampled array. * @returns {number[]} The resampled array.
*/ */
export function arrayFastResample(input: number[], points: number): number[] { export function arrayFastResample(input: number[], points: number): number[] {
// Heavily inpired by matrix-media-repo (used with permission) if (input.length === points) return input; // short-circuit a complicated call
// Heavily inspired by matrix-media-repo (used with permission)
// https://github.com/turt2live/matrix-media-repo/blob/abe72c87d2e29/util/util_audio/fastsample.go#L10 // https://github.com/turt2live/matrix-media-repo/blob/abe72c87d2e29/util/util_audio/fastsample.go#L10
let samples: number[] = [];
if (input.length > points) {
// Danger: this loop can cause out of memory conditions if the input is too small.
const everyNth = Math.round(input.length / points); const everyNth = Math.round(input.length / points);
const samples: number[] = [];
for (let i = 0; i < input.length; i += everyNth) { for (let i = 0; i < input.length; i += everyNth) {
samples.push(input[i]); samples.push(input[i]);
} }
} else {
// Smaller inputs mean we have to spread the values over the desired length. We
// end up overshooting the target length in doing this, so we'll resample down
// before returning. This recursion is risky, but mathematically should not go
// further than 1 level deep.
const spreadFactor = Math.ceil(points / input.length);
for (const val of input) {
samples.push(...arraySeed(val, spreadFactor));
}
samples = arrayFastResample(samples, points);
}
// Sanity fill, just in case
while (samples.length < points) { while (samples.length < points) {
samples.push(input[input.length - 1]); samples.push(input[input.length - 1]);
} }
// Sanity trim, just in case
if (samples.length > points) {
samples = samples.slice(0, points);
}
return samples; return samples;
} }
@ -178,6 +202,13 @@ export class GroupedArray<K, T> {
constructor(private val: Map<K, T[]>) { constructor(private val: Map<K, T[]>) {
} }
/**
* The value of this group, after all applicable alterations.
*/
public get value(): Map<K, T[]> {
return this.val;
}
/** /**
* Orders the grouping into an array using the provided key order. * Orders the grouping into an array using the provided key order.
* @param keyOrder The key order. * @param keyOrder The key order.

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2020 The Matrix.org Foundation C.I.C. Copyright 2020, 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -19,11 +19,23 @@ limitations under the License.
* @param e The enum. * @param e The enum.
* @returns The enum values. * @returns The enum values.
*/ */
export function getEnumValues<T>(e: any): T[] { export function getEnumValues(e: any): (string | number)[] {
// String-based enums will simply be objects ({Key: "value"}), but number-based
// enums will instead map themselves twice: in one direction for {Key: 12} and
// the reverse for easy lookup, presumably ({12: Key}). In the reverse mapping,
// the key is a string, not a number.
//
// For this reason, we try to determine what kind of enum we're dealing with.
const keys = Object.keys(e); const keys = Object.keys(e);
return keys const values: (string | number)[] = [];
.filter(k => ['string', 'number'].includes(typeof(e[k]))) for (const key of keys) {
.map(k => e[k]); const value = e[key];
if (Number.isFinite(value) || e[value.toString()] !== Number(key)) {
values.push(value);
}
}
return values;
} }
/** /**

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2020 The Matrix.org Foundation C.I.C. Copyright 2020, 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -141,3 +141,21 @@ export function objectKeyChanges<O extends {}>(a: O, b: O): (keyof O)[] {
export function objectClone<O extends {}>(obj: O): O { export function objectClone<O extends {}>(obj: O): O {
return JSON.parse(JSON.stringify(obj)); return JSON.parse(JSON.stringify(obj));
} }
/**
* Converts a series of entries to an object.
* @param entries The entries to convert.
* @returns The converted object.
*/
// NOTE: Deprecated once we have Object.fromEntries() support.
// @ts-ignore - return type is complaining about non-string keys, but we know better
export function objectFromEntries<K, V>(entries: Iterable<[K, V]>): {[k: K]: V} {
const obj: {
// @ts-ignore - same as return type
[k: K]: V} = {};
for (const e of entries) {
// @ts-ignore - same as return type
obj[e[0]] = e[1];
}
return obj;
}

View file

@ -20,31 +20,31 @@ import PermalinkConstructor, {PermalinkParts} from "./PermalinkConstructor";
* Generates permalinks that self-reference the running webapp * Generates permalinks that self-reference the running webapp
*/ */
export default class ElementPermalinkConstructor extends PermalinkConstructor { export default class ElementPermalinkConstructor extends PermalinkConstructor {
_elementUrl: string; private elementUrl: string;
constructor(elementUrl: string) { constructor(elementUrl: string) {
super(); super();
this._elementUrl = elementUrl; this.elementUrl = elementUrl;
if (!this._elementUrl.startsWith("http:") && !this._elementUrl.startsWith("https:")) { if (!this.elementUrl.startsWith("http:") && !this.elementUrl.startsWith("https:")) {
throw new Error("Element prefix URL does not appear to be an HTTP(S) URL"); throw new Error("Element prefix URL does not appear to be an HTTP(S) URL");
} }
} }
forEvent(roomId: string, eventId: string, serverCandidates: string[]): string { forEvent(roomId: string, eventId: string, serverCandidates: string[]): string {
return `${this._elementUrl}/#/room/${roomId}/${eventId}${this.encodeServerCandidates(serverCandidates)}`; return `${this.elementUrl}/#/room/${roomId}/${eventId}${this.encodeServerCandidates(serverCandidates)}`;
} }
forRoom(roomIdOrAlias: string, serverCandidates: string[]): string { forRoom(roomIdOrAlias: string, serverCandidates?: string[]): string {
return `${this._elementUrl}/#/room/${roomIdOrAlias}${this.encodeServerCandidates(serverCandidates)}`; return `${this.elementUrl}/#/room/${roomIdOrAlias}${this.encodeServerCandidates(serverCandidates)}`;
} }
forUser(userId: string): string { forUser(userId: string): string {
return `${this._elementUrl}/#/user/${userId}`; return `${this.elementUrl}/#/user/${userId}`;
} }
forGroup(groupId: string): string { forGroup(groupId: string): string {
return `${this._elementUrl}/#/group/${groupId}`; return `${this.elementUrl}/#/group/${groupId}`;
} }
forEntity(entityId: string): string { forEntity(entityId: string): string {
@ -58,11 +58,11 @@ export default class ElementPermalinkConstructor extends PermalinkConstructor {
} }
isPermalinkHost(testHost: string): boolean { isPermalinkHost(testHost: string): boolean {
const parsedUrl = new URL(this._elementUrl); const parsedUrl = new URL(this.elementUrl);
return testHost === (parsedUrl.host || parsedUrl.hostname); // one of the hosts should match return testHost === (parsedUrl.host || parsedUrl.hostname); // one of the hosts should match
} }
encodeServerCandidates(candidates: string[]) { encodeServerCandidates(candidates?: string[]) {
if (!candidates || candidates.length === 0) return ''; if (!candidates || candidates.length === 0) return '';
return `?via=${candidates.map(c => encodeURIComponent(c)).join("&via=")}`; return `?via=${candidates.map(c => encodeURIComponent(c)).join("&via=")}`;
} }
@ -71,11 +71,11 @@ export default class ElementPermalinkConstructor extends PermalinkConstructor {
// https://github.com/turt2live/matrix-js-bot-sdk/blob/7c4665c9a25c2c8e0fe4e509f2616505b5b66a1c/src/Permalinks.ts#L33-L61 // https://github.com/turt2live/matrix-js-bot-sdk/blob/7c4665c9a25c2c8e0fe4e509f2616505b5b66a1c/src/Permalinks.ts#L33-L61
// Adapted for Element's URL format // Adapted for Element's URL format
parsePermalink(fullUrl: string): PermalinkParts { parsePermalink(fullUrl: string): PermalinkParts {
if (!fullUrl || !fullUrl.startsWith(this._elementUrl)) { if (!fullUrl || !fullUrl.startsWith(this.elementUrl)) {
throw new Error("Does not appear to be a permalink"); throw new Error("Does not appear to be a permalink");
} }
const parts = fullUrl.substring(`${this._elementUrl}/#/`.length); const parts = fullUrl.substring(`${this.elementUrl}/#/`.length);
return ElementPermalinkConstructor.parseAppRoute(parts); return ElementPermalinkConstructor.parseAppRoute(parts);
} }

View file

@ -17,6 +17,9 @@ limitations under the License.
import isIp from "is-ip"; import isIp from "is-ip";
import * as utils from "matrix-js-sdk/src/utils"; import * as utils from "matrix-js-sdk/src/utils";
import {Room} from "matrix-js-sdk/src/models/room"; import {Room} from "matrix-js-sdk/src/models/room";
import {EventType} from "matrix-js-sdk/src/@types/event";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import {MatrixClientPeg} from "../../MatrixClientPeg"; import {MatrixClientPeg} from "../../MatrixClientPeg";
import SpecPermalinkConstructor, {baseUrl as matrixtoBaseUrl} from "./SpecPermalinkConstructor"; import SpecPermalinkConstructor, {baseUrl as matrixtoBaseUrl} from "./SpecPermalinkConstructor";
@ -74,29 +77,35 @@ const MAX_SERVER_CANDIDATES = 3;
// the list and magically have the link work. // the list and magically have the link work.
export class RoomPermalinkCreator { export class RoomPermalinkCreator {
private room: Room;
private roomId: string;
private highestPlUserId: string;
private populationMap: { [serverName: string]: number };
private bannedHostsRegexps: RegExp[];
private allowedHostsRegexps: RegExp[];
private _serverCandidates: string[];
private started: boolean;
// We support being given a roomId as a fallback in the event the `room` object // We support being given a roomId as a fallback in the event the `room` object
// doesn't exist or is not healthy for us to rely on. For example, loading a // doesn't exist or is not healthy for us to rely on. For example, loading a
// permalink to a room which the MatrixClient doesn't know about. // permalink to a room which the MatrixClient doesn't know about.
constructor(room, roomId = null) { constructor(room: Room, roomId: string = null) {
this._room = room; this.room = room;
this._roomId = room ? room.roomId : roomId; this.roomId = room ? room.roomId : roomId;
this._highestPlUserId = null; this.highestPlUserId = null;
this._populationMap = null; this.populationMap = null;
this._bannedHostsRegexps = null; this.bannedHostsRegexps = null;
this._allowedHostsRegexps = null; this.allowedHostsRegexps = null;
this._serverCandidates = null; this._serverCandidates = null;
this._started = false; this.started = false;
if (!this._roomId) { if (!this.roomId) {
throw new Error("Failed to resolve a roomId for the permalink creator to use"); throw new Error("Failed to resolve a roomId for the permalink creator to use");
} }
this.onMembership = this.onMembership.bind(this);
this.onRoomState = this.onRoomState.bind(this);
} }
load() { load() {
if (!this._room || !this._room.currentState) { if (!this.room || !this.room.currentState) {
// Under rare and unknown circumstances it is possible to have a room with no // Under rare and unknown circumstances it is possible to have a room with no
// currentState, at least potentially at the early stages of joining a room. // currentState, at least potentially at the early stages of joining a room.
// To avoid breaking everything, we'll just warn rather than throw as well as // To avoid breaking everything, we'll just warn rather than throw as well as
@ -104,23 +113,23 @@ export class RoomPermalinkCreator {
console.warn("Tried to load a permalink creator with no room state"); console.warn("Tried to load a permalink creator with no room state");
return; return;
} }
this._updateAllowedServers(); this.updateAllowedServers();
this._updateHighestPlUser(); this.updateHighestPlUser();
this._updatePopulationMap(); this.updatePopulationMap();
this._updateServerCandidates(); this.updateServerCandidates();
} }
start() { start() {
this.load(); this.load();
this._room.on("RoomMember.membership", this.onMembership); this.room.on("RoomMember.membership", this.onMembership);
this._room.on("RoomState.events", this.onRoomState); this.room.on("RoomState.events", this.onRoomState);
this._started = true; this.started = true;
} }
stop() { stop() {
this._room.removeListener("RoomMember.membership", this.onMembership); this.room.removeListener("RoomMember.membership", this.onMembership);
this._room.removeListener("RoomState.events", this.onRoomState); this.room.removeListener("RoomState.events", this.onRoomState);
this._started = false; this.started = false;
} }
get serverCandidates() { get serverCandidates() {
@ -128,44 +137,44 @@ export class RoomPermalinkCreator {
} }
isStarted() { isStarted() {
return this._started; return this.started;
} }
forEvent(eventId) { forEvent(eventId: string): string {
return getPermalinkConstructor().forEvent(this._roomId, eventId, this._serverCandidates); return getPermalinkConstructor().forEvent(this.roomId, eventId, this._serverCandidates);
} }
forShareableRoom() { forShareableRoom(): string {
if (this._room) { if (this.room) {
// Prefer to use canonical alias for permalink if possible // Prefer to use canonical alias for permalink if possible
const alias = this._room.getCanonicalAlias(); const alias = this.room.getCanonicalAlias();
if (alias) { if (alias) {
return getPermalinkConstructor().forRoom(alias, this._serverCandidates); return getPermalinkConstructor().forRoom(alias, this._serverCandidates);
} }
} }
return getPermalinkConstructor().forRoom(this._roomId, this._serverCandidates); return getPermalinkConstructor().forRoom(this.roomId, this._serverCandidates);
} }
forRoom() { forRoom(): string {
return getPermalinkConstructor().forRoom(this._roomId, this._serverCandidates); return getPermalinkConstructor().forRoom(this.roomId, this._serverCandidates);
} }
onRoomState(event) { private onRoomState = (event: MatrixEvent) => {
switch (event.getType()) { switch (event.getType()) {
case "m.room.server_acl": case EventType.RoomServerAcl:
this._updateAllowedServers(); this.updateAllowedServers();
this._updateHighestPlUser(); this.updateHighestPlUser();
this._updatePopulationMap(); this.updatePopulationMap();
this._updateServerCandidates(); this.updateServerCandidates();
return; return;
case "m.room.power_levels": case EventType.RoomPowerLevels:
this._updateHighestPlUser(); this.updateHighestPlUser();
this._updateServerCandidates(); this.updateServerCandidates();
return; return;
} }
} }
onMembership(evt, member, oldMembership) { private onMembership = (evt: MatrixEvent, member: RoomMember, oldMembership: string) => {
const userId = member.userId; const userId = member.userId;
const membership = member.membership; const membership = member.membership;
const serverName = getServerName(userId); const serverName = getServerName(userId);
@ -173,17 +182,17 @@ export class RoomPermalinkCreator {
const hasLeft = oldMembership === "join" && membership !== "join"; const hasLeft = oldMembership === "join" && membership !== "join";
if (hasLeft) { if (hasLeft) {
this._populationMap[serverName]--; this.populationMap[serverName]--;
} else if (hasJoined) { } else if (hasJoined) {
this._populationMap[serverName]++; this.populationMap[serverName]++;
} }
this._updateHighestPlUser(); this.updateHighestPlUser();
this._updateServerCandidates(); this.updateServerCandidates();
} }
_updateHighestPlUser() { private updateHighestPlUser() {
const plEvent = this._room.currentState.getStateEvents("m.room.power_levels", ""); const plEvent = this.room.currentState.getStateEvents("m.room.power_levels", "");
if (plEvent) { if (plEvent) {
const content = plEvent.getContent(); const content = plEvent.getContent();
if (content) { if (content) {
@ -191,14 +200,14 @@ export class RoomPermalinkCreator {
if (users) { if (users) {
const entries = Object.entries(users); const entries = Object.entries(users);
const allowedEntries = entries.filter(([userId]) => { const allowedEntries = entries.filter(([userId]) => {
const member = this._room.getMember(userId); const member = this.room.getMember(userId);
if (!member || member.membership !== "join") { if (!member || member.membership !== "join") {
return false; return false;
} }
const serverName = getServerName(userId); const serverName = getServerName(userId);
return !isHostnameIpAddress(serverName) && return !isHostnameIpAddress(serverName) &&
!isHostInRegex(serverName, this._bannedHostsRegexps) && !isHostInRegex(serverName, this.bannedHostsRegexps) &&
isHostInRegex(serverName, this._allowedHostsRegexps); isHostInRegex(serverName, this.allowedHostsRegexps);
}); });
const maxEntry = allowedEntries.reduce((max, entry) => { const maxEntry = allowedEntries.reduce((max, entry) => {
return (entry[1] > max[1]) ? entry : max; return (entry[1] > max[1]) ? entry : max;
@ -206,20 +215,20 @@ export class RoomPermalinkCreator {
const [userId, powerLevel] = maxEntry; const [userId, powerLevel] = maxEntry;
// object wasn't empty, and max entry wasn't a demotion from the default // object wasn't empty, and max entry wasn't a demotion from the default
if (userId !== null && powerLevel >= 50) { if (userId !== null && powerLevel >= 50) {
this._highestPlUserId = userId; this.highestPlUserId = userId;
return; return;
} }
} }
} }
} }
this._highestPlUserId = null; this.highestPlUserId = null;
} }
_updateAllowedServers() { private updateAllowedServers() {
const bannedHostsRegexps = []; const bannedHostsRegexps = [];
let allowedHostsRegexps = [new RegExp(".*")]; // default allow everyone let allowedHostsRegexps = [new RegExp(".*")]; // default allow everyone
if (this._room.currentState) { if (this.room.currentState) {
const aclEvent = this._room.currentState.getStateEvents("m.room.server_acl", ""); const aclEvent = this.room.currentState.getStateEvents("m.room.server_acl", "");
if (aclEvent && aclEvent.getContent()) { if (aclEvent && aclEvent.getContent()) {
const getRegex = (hostname) => new RegExp("^" + utils.globToRegexp(hostname, false) + "$"); const getRegex = (hostname) => new RegExp("^" + utils.globToRegexp(hostname, false) + "$");
@ -231,35 +240,35 @@ export class RoomPermalinkCreator {
allowed.forEach(h => allowedHostsRegexps.push(getRegex(h))); allowed.forEach(h => allowedHostsRegexps.push(getRegex(h)));
} }
} }
this._bannedHostsRegexps = bannedHostsRegexps; this.bannedHostsRegexps = bannedHostsRegexps;
this._allowedHostsRegexps = allowedHostsRegexps; this.allowedHostsRegexps = allowedHostsRegexps;
} }
_updatePopulationMap() { private updatePopulationMap() {
const populationMap: { [server: string]: number } = {}; const populationMap: { [server: string]: number } = {};
for (const member of this._room.getJoinedMembers()) { for (const member of this.room.getJoinedMembers()) {
const serverName = getServerName(member.userId); const serverName = getServerName(member.userId);
if (!populationMap[serverName]) { if (!populationMap[serverName]) {
populationMap[serverName] = 0; populationMap[serverName] = 0;
} }
populationMap[serverName]++; populationMap[serverName]++;
} }
this._populationMap = populationMap; this.populationMap = populationMap;
} }
_updateServerCandidates() { private updateServerCandidates() {
let candidates = []; let candidates = [];
if (this._highestPlUserId) { if (this.highestPlUserId) {
candidates.push(getServerName(this._highestPlUserId)); candidates.push(getServerName(this.highestPlUserId));
} }
const serversByPopulation = Object.keys(this._populationMap) const serversByPopulation = Object.keys(this.populationMap)
.sort((a, b) => this._populationMap[b] - this._populationMap[a]) .sort((a, b) => this.populationMap[b] - this.populationMap[a])
.filter(a => { .filter(a => {
return !candidates.includes(a) && return !candidates.includes(a) &&
!isHostnameIpAddress(a) && !isHostnameIpAddress(a) &&
!isHostInRegex(a, this._bannedHostsRegexps) && !isHostInRegex(a, this.bannedHostsRegexps) &&
isHostInRegex(a, this._allowedHostsRegexps); isHostInRegex(a, this.allowedHostsRegexps);
}); });
const remainingServers = serversByPopulation.slice(0, MAX_SERVER_CANDIDATES - candidates.length); const remainingServers = serversByPopulation.slice(0, MAX_SERVER_CANDIDATES - candidates.length);
@ -273,11 +282,11 @@ export function makeGenericPermalink(entityId: string): string {
return getPermalinkConstructor().forEntity(entityId); return getPermalinkConstructor().forEntity(entityId);
} }
export function makeUserPermalink(userId) { export function makeUserPermalink(userId: string): string {
return getPermalinkConstructor().forUser(userId); return getPermalinkConstructor().forUser(userId);
} }
export function makeRoomPermalink(roomId) { export function makeRoomPermalink(roomId: string): string {
if (!roomId) { if (!roomId) {
throw new Error("can't permalink a falsey roomId"); throw new Error("can't permalink a falsey roomId");
} }
@ -296,7 +305,7 @@ export function makeRoomPermalink(roomId) {
return permalinkCreator.forRoom(); return permalinkCreator.forRoom();
} }
export function makeGroupPermalink(groupId) { export function makeGroupPermalink(groupId: string): string {
return getPermalinkConstructor().forGroup(groupId); return getPermalinkConstructor().forGroup(groupId);
} }
@ -428,24 +437,24 @@ export function parseAppLocalLink(localLink: string): PermalinkParts {
return null; return null;
} }
function getServerName(userId) { function getServerName(userId: string): string {
return userId.split(":").splice(1).join(":"); return userId.split(":").splice(1).join(":");
} }
function getHostnameFromMatrixDomain(domain) { function getHostnameFromMatrixDomain(domain: string): string {
if (!domain) return null; if (!domain) return null;
return new URL(`https://${domain}`).hostname; return new URL(`https://${domain}`).hostname;
} }
function isHostInRegex(hostname, regexps) { function isHostInRegex(hostname: string, regexps: RegExp[]) {
hostname = getHostnameFromMatrixDomain(hostname); hostname = getHostnameFromMatrixDomain(hostname);
if (!hostname) return true; // assumed if (!hostname) return true; // assumed
if (regexps.length > 0 && !regexps[0].test) throw new Error(regexps[0]); if (regexps.length > 0 && !regexps[0].test) throw new Error(regexps[0].toString());
return regexps.filter(h => h.test(hostname)).length > 0; return regexps.filter(h => h.test(hostname)).length > 0;
} }
function isHostnameIpAddress(hostname) { function isHostnameIpAddress(hostname: string): boolean {
hostname = getHostnameFromMatrixDomain(hostname); hostname = getHostnameFromMatrixDomain(hostname);
if (!hostname) return false; if (!hostname) return false;

View file

@ -29,7 +29,7 @@ describe('ScalarAuthClient', function() {
it('should request a new token if the old one fails', async function() { it('should request a new token if the old one fails', async function() {
const sac = new ScalarAuthClient(); const sac = new ScalarAuthClient();
sac._getAccountName = jest.fn((arg) => { sac.getAccountName = jest.fn((arg) => {
switch (arg) { switch (arg) {
case "brokentoken": case "brokentoken":
return Promise.reject({ return Promise.reject({

View file

@ -29,7 +29,10 @@ function waitForRoomListStoreUpdate() {
describe('RoomList', () => { describe('RoomList', () => {
function createRoom(opts) { function createRoom(opts) {
const room = new Room(generateRoomId(), null, client.getUserId()); const room = new Room(generateRoomId(), MatrixClientPeg.get(), client.getUserId(), {
// The room list now uses getPendingEvents(), so we need a detached ordering.
pendingEventOrdering: "detached",
});
if (opts) { if (opts) {
Object.assign(room, opts); Object.assign(room, opts);
} }

View file

@ -0,0 +1,714 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { EventEmitter } from "events";
import { EventType } from "matrix-js-sdk/src/@types/event";
import "../skinned-sdk"; // Must be first for skinning to work
import SpaceStore, {
UPDATE_INVITED_SPACES,
UPDATE_SELECTED_SPACE,
UPDATE_TOP_LEVEL_SPACES
} from "../../src/stores/SpaceStore";
import { resetAsyncStoreWithClient, setupAsyncStoreWithClient } from "../utils/test-utils";
import { mkEvent, mkStubRoom, stubClient } from "../test-utils";
import { EnhancedMap } from "../../src/utils/maps";
import SettingsStore from "../../src/settings/SettingsStore";
import DMRoomMap from "../../src/utils/DMRoomMap";
import { MatrixClientPeg } from "../../src/MatrixClientPeg";
import defaultDispatcher from "../../src/dispatcher/dispatcher";
type MatrixEvent = any; // importing from js-sdk upsets things
jest.useFakeTimers();
const mockStateEventImplementation = (events: MatrixEvent[]) => {
const stateMap = new EnhancedMap<string, Map<string, MatrixEvent>>();
events.forEach(event => {
stateMap.getOrCreate(event.getType(), new Map()).set(event.getStateKey(), event);
});
return (eventType: string, stateKey?: string) => {
if (stateKey || stateKey === "") {
return stateMap.get(eventType)?.get(stateKey) || null;
}
return Array.from(stateMap.get(eventType)?.values() || []);
};
};
const emitPromise = (e: EventEmitter, k: string | symbol) => new Promise(r => e.once(k, r));
const testUserId = "@test:user";
let rooms = [];
const mkRoom = (roomId: string) => {
const room = mkStubRoom(roomId);
room.currentState.getStateEvents.mockImplementation(mockStateEventImplementation([]));
rooms.push(room);
return room;
};
const mkSpace = (spaceId: string, children: string[] = []) => {
const space = mkRoom(spaceId);
space.isSpaceRoom.mockReturnValue(true);
space.currentState.getStateEvents.mockImplementation(mockStateEventImplementation(children.map(roomId =>
mkEvent({
event: true,
type: EventType.SpaceChild,
room: spaceId,
user: testUserId,
skey: roomId,
content: { via: [] },
ts: Date.now(),
}),
)));
return space;
};
const getValue = jest.fn();
SettingsStore.getValue = getValue;
const getUserIdForRoomId = jest.fn();
// @ts-ignore
DMRoomMap.sharedInstance = { getUserIdForRoomId };
const fav1 = "!fav1:server";
const fav2 = "!fav2:server";
const fav3 = "!fav3:server";
const dm1 = "!dm1:server";
const dm1Partner = "@dm1Partner:server";
const dm2 = "!dm2:server";
const dm2Partner = "@dm2Partner:server";
const dm3 = "!dm3:server";
const dm3Partner = "@dm3Partner:server";
const orphan1 = "!orphan1:server";
const orphan2 = "!orphan2:server";
const invite1 = "!invite1:server";
const invite2 = "!invite2:server";
const room1 = "!room1:server";
const room2 = "!room2:server";
const space1 = "!space1:server";
const space2 = "!space2:server";
const space3 = "!space3:server";
describe("SpaceStore", () => {
stubClient();
const store = SpaceStore.instance;
const client = MatrixClientPeg.get();
const viewRoom = roomId => defaultDispatcher.dispatch({ action: "view_room", room_id: roomId }, true);
const run = async () => {
client.getRoom.mockImplementation(roomId => rooms.find(room => room.roomId === roomId));
await setupAsyncStoreWithClient(store, client);
jest.runAllTimers();
};
beforeEach(() => {
jest.runAllTimers();
client.getVisibleRooms.mockReturnValue(rooms = []);
getValue.mockImplementation(settingName => {
if (settingName === "feature_spaces") {
return true;
}
});
});
afterEach(async () => {
await resetAsyncStoreWithClient(store);
});
describe("static hierarchy resolution tests", () => {
it("handles no spaces", async () => {
await run();
expect(store.spacePanelSpaces).toStrictEqual([]);
expect(store.invitedSpaces).toStrictEqual([]);
});
it("handles 3 joined top level spaces", async () => {
mkSpace("!space1:server");
mkSpace("!space2:server");
mkSpace("!space3:server");
await run();
expect(store.spacePanelSpaces.sort()).toStrictEqual(client.getVisibleRooms().sort());
expect(store.invitedSpaces).toStrictEqual([]);
});
it("handles a basic hierarchy", async () => {
mkSpace("!space1:server");
mkSpace("!space2:server");
mkSpace("!company:server", [
mkSpace("!company_dept1:server", [
mkSpace("!company_dept1_group1:server").roomId,
]).roomId,
mkSpace("!company_dept2:server").roomId,
]);
await run();
expect(store.spacePanelSpaces.map(r => r.roomId).sort()).toStrictEqual([
"!space1:server",
"!space2:server",
"!company:server",
].sort());
expect(store.invitedSpaces).toStrictEqual([]);
expect(store.getChildRooms("!space1:server")).toStrictEqual([]);
expect(store.getChildSpaces("!space1:server")).toStrictEqual([]);
expect(store.getChildRooms("!space2:server")).toStrictEqual([]);
expect(store.getChildSpaces("!space2:server")).toStrictEqual([]);
expect(store.getChildRooms("!company:server")).toStrictEqual([]);
expect(store.getChildSpaces("!company:server")).toStrictEqual([
client.getRoom("!company_dept1:server"),
client.getRoom("!company_dept2:server"),
]);
expect(store.getChildRooms("!company_dept1:server")).toStrictEqual([]);
expect(store.getChildSpaces("!company_dept1:server")).toStrictEqual([
client.getRoom("!company_dept1_group1:server"),
]);
expect(store.getChildRooms("!company_dept1_group1:server")).toStrictEqual([]);
expect(store.getChildSpaces("!company_dept1_group1:server")).toStrictEqual([]);
expect(store.getChildRooms("!company_dept2:server")).toStrictEqual([]);
expect(store.getChildSpaces("!company_dept2:server")).toStrictEqual([]);
});
it("handles a sub-space existing in multiple places in the space tree", async () => {
const subspace = mkSpace("!subspace:server");
mkSpace("!space1:server");
mkSpace("!space2:server");
mkSpace("!company:server", [
mkSpace("!company_dept1:server", [
mkSpace("!company_dept1_group1:server", [subspace.roomId]).roomId,
]).roomId,
mkSpace("!company_dept2:server", [subspace.roomId]).roomId,
subspace.roomId,
]);
await run();
expect(store.spacePanelSpaces.map(r => r.roomId).sort()).toStrictEqual([
"!space1:server",
"!space2:server",
"!company:server",
].sort());
expect(store.invitedSpaces).toStrictEqual([]);
expect(store.getChildRooms("!space1:server")).toStrictEqual([]);
expect(store.getChildSpaces("!space1:server")).toStrictEqual([]);
expect(store.getChildRooms("!space2:server")).toStrictEqual([]);
expect(store.getChildSpaces("!space2:server")).toStrictEqual([]);
expect(store.getChildRooms("!company:server")).toStrictEqual([]);
expect(store.getChildSpaces("!company:server")).toStrictEqual([
client.getRoom("!company_dept1:server"),
client.getRoom("!company_dept2:server"),
subspace,
]);
expect(store.getChildRooms("!company_dept1:server")).toStrictEqual([]);
expect(store.getChildSpaces("!company_dept1:server")).toStrictEqual([
client.getRoom("!company_dept1_group1:server"),
]);
expect(store.getChildRooms("!company_dept1_group1:server")).toStrictEqual([]);
expect(store.getChildSpaces("!company_dept1_group1:server")).toStrictEqual([subspace]);
expect(store.getChildRooms("!company_dept2:server")).toStrictEqual([]);
expect(store.getChildSpaces("!company_dept2:server")).toStrictEqual([subspace]);
});
it("handles full cycles", async () => {
mkSpace("!a:server", [
mkSpace("!b:server", [
mkSpace("!c:server", [
"!a:server",
]).roomId,
]).roomId,
]);
await run();
expect(store.spacePanelSpaces.map(r => r.roomId)).toStrictEqual(["!a:server"]);
expect(store.invitedSpaces).toStrictEqual([]);
expect(store.getChildRooms("!a:server")).toStrictEqual([]);
expect(store.getChildSpaces("!a:server")).toStrictEqual([client.getRoom("!b:server")]);
expect(store.getChildRooms("!b:server")).toStrictEqual([]);
expect(store.getChildSpaces("!b:server")).toStrictEqual([client.getRoom("!c:server")]);
expect(store.getChildRooms("!c:server")).toStrictEqual([]);
expect(store.getChildSpaces("!c:server")).toStrictEqual([client.getRoom("!a:server")]);
});
it("handles partial cycles", async () => {
mkSpace("!b:server", [
mkSpace("!a:server", [
mkSpace("!c:server", [
"!a:server",
]).roomId,
]).roomId,
]);
await run();
expect(store.spacePanelSpaces.map(r => r.roomId)).toStrictEqual(["!b:server"]);
expect(store.invitedSpaces).toStrictEqual([]);
expect(store.getChildRooms("!b:server")).toStrictEqual([]);
expect(store.getChildSpaces("!b:server")).toStrictEqual([client.getRoom("!a:server")]);
expect(store.getChildRooms("!a:server")).toStrictEqual([]);
expect(store.getChildSpaces("!a:server")).toStrictEqual([client.getRoom("!c:server")]);
expect(store.getChildRooms("!c:server")).toStrictEqual([]);
expect(store.getChildSpaces("!c:server")).toStrictEqual([client.getRoom("!a:server")]);
});
it("handles partial cycles with additional spaces coming off them", async () => {
// TODO this test should be failing right now
mkSpace("!a:server", [
mkSpace("!b:server", [
mkSpace("!c:server", [
"!a:server",
mkSpace("!d:server").roomId,
]).roomId,
]).roomId,
]);
await run();
expect(store.spacePanelSpaces.map(r => r.roomId)).toStrictEqual(["!a:server"]);
expect(store.invitedSpaces).toStrictEqual([]);
expect(store.getChildRooms("!a:server")).toStrictEqual([]);
expect(store.getChildSpaces("!a:server")).toStrictEqual([client.getRoom("!b:server")]);
expect(store.getChildRooms("!b:server")).toStrictEqual([]);
expect(store.getChildSpaces("!b:server")).toStrictEqual([client.getRoom("!c:server")]);
expect(store.getChildRooms("!c:server")).toStrictEqual([]);
expect(store.getChildSpaces("!c:server")).toStrictEqual([
client.getRoom("!a:server"),
client.getRoom("!d:server"),
]);
expect(store.getChildRooms("!d:server")).toStrictEqual([]);
expect(store.getChildSpaces("!d:server")).toStrictEqual([]);
});
it("invite to a subspace is only shown at the top level", async () => {
mkSpace(invite1).getMyMembership.mockReturnValue("invite");
mkSpace(space1, [invite1]);
await run();
expect(store.spacePanelSpaces).toStrictEqual([client.getRoom(space1)]);
expect(store.getChildSpaces(space1)).toStrictEqual([]);
expect(store.getChildRooms(space1)).toStrictEqual([]);
expect(store.invitedSpaces).toStrictEqual([client.getRoom(invite1)]);
});
describe("test fixture 1", () => {
beforeEach(async () => {
[fav1, fav2, fav3, dm1, dm2, dm3, orphan1, orphan2, invite1, invite2, room1].forEach(mkRoom);
mkSpace(space1, [fav1, room1]);
mkSpace(space2, [fav1, fav2, fav3, room1]);
mkSpace(space3, [invite2]);
[fav1, fav2, fav3].forEach(roomId => {
client.getRoom(roomId).tags = {
"m.favourite": {
order: 0.5,
},
};
});
[invite1, invite2].forEach(roomId => {
client.getRoom(roomId).getMyMembership.mockReturnValue("invite");
});
getUserIdForRoomId.mockImplementation(roomId => {
return {
[dm1]: dm1Partner,
[dm2]: dm2Partner,
[dm3]: dm3Partner,
}[roomId];
});
await run();
});
it("home space contains orphaned rooms", () => {
expect(store.getSpaceFilteredRoomIds(null).has(orphan1)).toBeTruthy();
expect(store.getSpaceFilteredRoomIds(null).has(orphan2)).toBeTruthy();
});
it("home space contains favourites", () => {
expect(store.getSpaceFilteredRoomIds(null).has(fav1)).toBeTruthy();
expect(store.getSpaceFilteredRoomIds(null).has(fav2)).toBeTruthy();
expect(store.getSpaceFilteredRoomIds(null).has(fav3)).toBeTruthy();
});
it("home space contains dm rooms", () => {
expect(store.getSpaceFilteredRoomIds(null).has(dm1)).toBeTruthy();
expect(store.getSpaceFilteredRoomIds(null).has(dm2)).toBeTruthy();
expect(store.getSpaceFilteredRoomIds(null).has(dm3)).toBeTruthy();
});
it("home space contains invites", () => {
expect(store.getSpaceFilteredRoomIds(null).has(invite1)).toBeTruthy();
});
it("home space contains invites even if they are also shown in a space", () => {
expect(store.getSpaceFilteredRoomIds(null).has(invite2)).toBeTruthy();
});
it("home space does not contain rooms/low priority from rooms within spaces", () => {
expect(store.getSpaceFilteredRoomIds(null).has(room1)).toBeFalsy();
});
it("space contains child rooms", () => {
const space = client.getRoom(space1);
expect(store.getSpaceFilteredRoomIds(space).has(fav1)).toBeTruthy();
expect(store.getSpaceFilteredRoomIds(space).has(room1)).toBeTruthy();
});
it("space contains child favourites", () => {
const space = client.getRoom(space2);
expect(store.getSpaceFilteredRoomIds(space).has(fav1)).toBeTruthy();
expect(store.getSpaceFilteredRoomIds(space).has(fav2)).toBeTruthy();
expect(store.getSpaceFilteredRoomIds(space).has(fav3)).toBeTruthy();
expect(store.getSpaceFilteredRoomIds(space).has(room1)).toBeTruthy();
});
it("space contains child invites", () => {
const space = client.getRoom(space3);
expect(store.getSpaceFilteredRoomIds(space).has(invite2)).toBeTruthy();
});
});
});
describe("hierarchy resolution update tests", () => {
let emitter: EventEmitter;
beforeEach(async () => {
emitter = new EventEmitter();
client.on.mockImplementation(emitter.on.bind(emitter));
client.removeListener.mockImplementation(emitter.removeListener.bind(emitter));
});
afterEach(() => {
client.on.mockReset();
client.removeListener.mockReset();
});
it("updates state when spaces are joined", async () => {
await run();
expect(store.spacePanelSpaces).toStrictEqual([]);
const space = mkSpace(space1);
const prom = emitPromise(store, UPDATE_TOP_LEVEL_SPACES);
emitter.emit("Room", space);
await prom;
expect(store.spacePanelSpaces).toStrictEqual([space]);
expect(store.invitedSpaces).toStrictEqual([]);
});
it("updates state when spaces are left", async () => {
const space = mkSpace(space1);
await run();
expect(store.spacePanelSpaces).toStrictEqual([space]);
space.getMyMembership.mockReturnValue("leave");
const prom = emitPromise(store, UPDATE_TOP_LEVEL_SPACES);
emitter.emit("Room.myMembership", space, "leave", "join");
await prom;
expect(store.spacePanelSpaces).toStrictEqual([]);
});
it("updates state when space invite comes in", async () => {
await run();
expect(store.spacePanelSpaces).toStrictEqual([]);
expect(store.invitedSpaces).toStrictEqual([]);
const space = mkSpace(space1);
space.getMyMembership.mockReturnValue("invite");
const prom = emitPromise(store, UPDATE_INVITED_SPACES);
emitter.emit("Room", space);
await prom;
expect(store.spacePanelSpaces).toStrictEqual([]);
expect(store.invitedSpaces).toStrictEqual([space]);
});
it("updates state when space invite is accepted", async () => {
const space = mkSpace(space1);
space.getMyMembership.mockReturnValue("invite");
await run();
expect(store.spacePanelSpaces).toStrictEqual([]);
expect(store.invitedSpaces).toStrictEqual([space]);
space.getMyMembership.mockReturnValue("join");
const prom = emitPromise(store, UPDATE_TOP_LEVEL_SPACES);
emitter.emit("Room.myMembership", space, "join", "invite");
await prom;
expect(store.spacePanelSpaces).toStrictEqual([space]);
expect(store.invitedSpaces).toStrictEqual([]);
});
it("updates state when space invite is rejected", async () => {
const space = mkSpace(space1);
space.getMyMembership.mockReturnValue("invite");
await run();
expect(store.spacePanelSpaces).toStrictEqual([]);
expect(store.invitedSpaces).toStrictEqual([space]);
space.getMyMembership.mockReturnValue("leave");
const prom = emitPromise(store, UPDATE_INVITED_SPACES);
emitter.emit("Room.myMembership", space, "leave", "invite");
await prom;
expect(store.spacePanelSpaces).toStrictEqual([]);
expect(store.invitedSpaces).toStrictEqual([]);
});
it("room invite gets added to relevant space filters", async () => {
const space = mkSpace(space1, [invite1]);
await run();
expect(store.spacePanelSpaces).toStrictEqual([space]);
expect(store.invitedSpaces).toStrictEqual([]);
expect(store.getChildSpaces(space1)).toStrictEqual([]);
expect(store.getChildRooms(space1)).toStrictEqual([]);
expect(store.getSpaceFilteredRoomIds(client.getRoom(space1)).has(invite1)).toBeFalsy();
expect(store.getSpaceFilteredRoomIds(null).has(invite1)).toBeFalsy();
const invite = mkRoom(invite1);
invite.getMyMembership.mockReturnValue("invite");
const prom = emitPromise(store, space1);
emitter.emit("Room", space);
await prom;
expect(store.spacePanelSpaces).toStrictEqual([space]);
expect(store.invitedSpaces).toStrictEqual([]);
expect(store.getChildSpaces(space1)).toStrictEqual([]);
expect(store.getChildRooms(space1)).toStrictEqual([invite]);
expect(store.getSpaceFilteredRoomIds(client.getRoom(space1)).has(invite1)).toBeTruthy();
expect(store.getSpaceFilteredRoomIds(null).has(invite1)).toBeTruthy();
});
});
describe("active space switching tests", () => {
const fn = jest.spyOn(store, "emit");
beforeEach(async () => {
mkRoom(room1); // not a space
mkSpace(space1, [
mkSpace(space2).roomId,
]);
mkSpace(space3).getMyMembership.mockReturnValue("invite");
await run();
await store.setActiveSpace(null);
expect(store.activeSpace).toBe(null);
});
afterEach(() => {
fn.mockClear();
});
it("switch to home space", async () => {
await store.setActiveSpace(client.getRoom(space1));
fn.mockClear();
await store.setActiveSpace(null);
expect(fn).toHaveBeenCalledWith(UPDATE_SELECTED_SPACE, null);
expect(store.activeSpace).toBe(null);
});
it("switch to invited space", async () => {
const space = client.getRoom(space3);
await store.setActiveSpace(space);
expect(fn).toHaveBeenCalledWith(UPDATE_SELECTED_SPACE, space);
expect(store.activeSpace).toBe(space);
});
it("switch to top level space", async () => {
const space = client.getRoom(space1);
await store.setActiveSpace(space);
expect(fn).toHaveBeenCalledWith(UPDATE_SELECTED_SPACE, space);
expect(store.activeSpace).toBe(space);
});
it("switch to subspace", async () => {
const space = client.getRoom(space2);
await store.setActiveSpace(space);
expect(fn).toHaveBeenCalledWith(UPDATE_SELECTED_SPACE, space);
expect(store.activeSpace).toBe(space);
});
it("switch to unknown space is a nop", async () => {
expect(store.activeSpace).toBe(null);
const space = client.getRoom(room1); // not a space
await store.setActiveSpace(space);
expect(fn).not.toHaveBeenCalledWith(UPDATE_SELECTED_SPACE, space);
expect(store.activeSpace).toBe(null);
});
});
describe("context switching tests", () => {
const fn = jest.spyOn(defaultDispatcher, "dispatch");
beforeEach(async () => {
[room1, room2, orphan1].forEach(mkRoom);
mkSpace(space1, [room1, room2]);
mkSpace(space2, [room2]);
await run();
});
afterEach(() => {
fn.mockClear();
localStorage.clear();
});
const getCurrentRoom = () => fn.mock.calls.reverse().find(([p]) => p.action === "view_room")?.[0].room_id;
it("last viewed room in target space is the current viewed and in both spaces", async () => {
await store.setActiveSpace(client.getRoom(space1));
viewRoom(room2);
await store.setActiveSpace(client.getRoom(space2));
viewRoom(room2);
await store.setActiveSpace(client.getRoom(space1));
expect(getCurrentRoom()).toBe(room2);
});
it("last viewed room in target space is in the current space", async () => {
await store.setActiveSpace(client.getRoom(space1));
viewRoom(room2);
await store.setActiveSpace(client.getRoom(space2));
expect(getCurrentRoom()).toBe(space2);
await store.setActiveSpace(client.getRoom(space1));
expect(getCurrentRoom()).toBe(room2);
});
it("last viewed room in target space is not in the current space", async () => {
await store.setActiveSpace(client.getRoom(space1));
viewRoom(room1);
await store.setActiveSpace(client.getRoom(space2));
viewRoom(room2);
await store.setActiveSpace(client.getRoom(space1));
expect(getCurrentRoom()).toBe(room1);
});
it("last viewed room is target space is not known", async () => {
await store.setActiveSpace(client.getRoom(space1));
viewRoom(room1);
localStorage.setItem(`mx_space_context_${space2}`, orphan2);
await store.setActiveSpace(client.getRoom(space2));
expect(getCurrentRoom()).toBe(space2);
});
it("no last viewed room in target space", async () => {
await store.setActiveSpace(client.getRoom(space1));
viewRoom(room1);
await store.setActiveSpace(client.getRoom(space2));
expect(getCurrentRoom()).toBe(space2);
});
it("no last viewed room in home space", async () => {
await store.setActiveSpace(client.getRoom(space1));
viewRoom(room1);
await store.setActiveSpace(null);
expect(fn.mock.calls[fn.mock.calls.length - 1][0]).toStrictEqual({ action: "view_home_page" });
});
});
describe("space auto switching tests", () => {
beforeEach(async () => {
[room1, room2, orphan1].forEach(mkRoom);
mkSpace(space1, [room1, room2]);
mkSpace(space2, [room1, room2]);
client.getRoom(room2).currentState.getStateEvents.mockImplementation(mockStateEventImplementation([
mkEvent({
event: true,
type: EventType.SpaceParent,
room: room2,
user: testUserId,
skey: space2,
content: { via: [], canonical: true },
ts: Date.now(),
}),
]));
await run();
});
it("no switch required, room is in current space", async () => {
viewRoom(room1);
await store.setActiveSpace(client.getRoom(space1), false);
viewRoom(room2);
expect(store.activeSpace).toBe(client.getRoom(space1));
});
it("switch to canonical parent space for room", async () => {
viewRoom(room1);
await store.setActiveSpace(null, false);
viewRoom(room2);
expect(store.activeSpace).toBe(client.getRoom(space2));
});
it("switch to first containing space for room", async () => {
viewRoom(room2);
await store.setActiveSpace(null, false);
viewRoom(room1);
expect(store.activeSpace).toBe(client.getRoom(space1));
});
it("switch to home for orphaned room", async () => {
viewRoom(room1);
await store.setActiveSpace(client.getRoom(space1), false);
viewRoom(orphan1);
expect(store.activeSpace).toBeNull();
});
});
describe("traverseSpace", () => {
beforeEach(() => {
mkSpace("!a:server", [
mkSpace("!b:server", [
mkSpace("!c:server", [
"!a:server",
mkRoom("!c-child:server").roomId,
mkRoom("!shared-child:server").roomId,
]).roomId,
mkRoom("!b-child:server").roomId,
]).roomId,
mkRoom("!a-child:server").roomId,
"!shared-child:server",
]);
});
it("avoids cycles", () => {
const fn = jest.fn();
store.traverseSpace("!b:server", fn);
expect(fn).toBeCalledTimes(3);
expect(fn).toBeCalledWith("!a:server");
expect(fn).toBeCalledWith("!b:server");
expect(fn).toBeCalledWith("!c:server");
});
it("including rooms", () => {
const fn = jest.fn();
store.traverseSpace("!b:server", fn, true);
expect(fn).toBeCalledTimes(8); // twice for shared-child
expect(fn).toBeCalledWith("!a:server");
expect(fn).toBeCalledWith("!a-child:server");
expect(fn).toBeCalledWith("!b:server");
expect(fn).toBeCalledWith("!b-child:server");
expect(fn).toBeCalledWith("!c:server");
expect(fn).toBeCalledWith("!c-child:server");
expect(fn).toBeCalledWith("!shared-child:server");
});
it("excluding rooms", () => {
const fn = jest.fn();
store.traverseSpace("!b:server", fn, false);
expect(fn).toBeCalledTimes(3);
expect(fn).toBeCalledWith("!a:server");
expect(fn).toBeCalledWith("!b:server");
expect(fn).toBeCalledWith("!c:server");
});
});
});

View file

@ -79,6 +79,17 @@ export function createTestClient() {
generateClientSecret: () => "t35tcl1Ent5ECr3T", generateClientSecret: () => "t35tcl1Ent5ECr3T",
isGuest: () => false, isGuest: () => false,
isCryptoEnabled: () => false, isCryptoEnabled: () => false,
getSpaceSummary: jest.fn().mockReturnValue({
rooms: [],
events: [],
}),
// Used by various internal bits we aren't concerned with (yet)
_sessionStore: {
store: {
getItem: jest.fn(),
},
},
}; };
} }
@ -88,8 +99,8 @@ export function createTestClient() {
* @param {string} opts.type The event.type * @param {string} opts.type The event.type
* @param {string} opts.room The event.room_id * @param {string} opts.room The event.room_id
* @param {string} opts.user The event.user_id * @param {string} opts.user The event.user_id
* @param {string} opts.skey Optional. The state key (auto inserts empty string) * @param {string=} opts.skey Optional. The state key (auto inserts empty string)
* @param {Number} opts.ts Optional. Timestamp for the event * @param {number=} opts.ts Optional. Timestamp for the event
* @param {Object} opts.content The event.content * @param {Object} opts.content The event.content
* @param {boolean} opts.event True to make a MatrixEvent. * @param {boolean} opts.event True to make a MatrixEvent.
* @return {Object} a JSON object representing this event. * @return {Object} a JSON object representing this event.
@ -224,7 +235,7 @@ export function mkStubRoom(roomId = null) {
hasMembershipState: () => null, hasMembershipState: () => null,
getVersion: () => '1', getVersion: () => '1',
shouldUpgradeToVersion: () => null, shouldUpgradeToVersion: () => null,
getMyMembership: () => "join", getMyMembership: jest.fn().mockReturnValue("join"),
maySendMessage: jest.fn().mockReturnValue(true), maySendMessage: jest.fn().mockReturnValue(true),
currentState: { currentState: {
getStateEvents: jest.fn(), getStateEvents: jest.fn(),
@ -233,17 +244,17 @@ export function mkStubRoom(roomId = null) {
maySendEvent: jest.fn().mockReturnValue(true), maySendEvent: jest.fn().mockReturnValue(true),
members: [], members: [],
}, },
tags: { tags: {},
"m.favourite": {
order: 0.5,
},
},
setBlacklistUnverifiedDevices: jest.fn(), setBlacklistUnverifiedDevices: jest.fn(),
on: jest.fn(), on: jest.fn(),
removeListener: jest.fn(), removeListener: jest.fn(),
getDMInviter: jest.fn(), getDMInviter: jest.fn(),
getAvatarUrl: () => 'mxc://avatar.url/room.png', getAvatarUrl: () => 'mxc://avatar.url/room.png',
getMxcAvatarUrl: () => 'mxc://avatar.url/room.png', getMxcAvatarUrl: () => 'mxc://avatar.url/room.png',
isSpaceRoom: jest.fn(() => false),
getUnreadNotificationCount: jest.fn(() => 0),
getEventReadUpTo: jest.fn(() => null),
timeline: [],
}; };
} }

View file

@ -128,7 +128,7 @@ describe("shieldStatusForMembership self-trust behaviour", function() {
describe("shieldStatusForMembership other-trust behaviour", function() { describe("shieldStatusForMembership other-trust behaviour", function() {
beforeAll(() => { beforeAll(() => {
DMRoomMap._sharedInstance = { DMRoomMap.sharedInstance = {
getUserIdForRoomId: (roomId) => roomId === "DM" ? "@any:h" : null, getUserIdForRoomId: (roomId) => roomId === "DM" ? "@any:h" : null,
}; };
}); });

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 {Singleflight} from "../src/utils/Singleflight"; import {Singleflight} from "../../src/utils/Singleflight";
describe('Singleflight', () => { describe('Singleflight', () => {
afterEach(() => { afterEach(() => {

294
test/utils/arrays-test.ts Normal file
View file

@ -0,0 +1,294 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import {
arrayDiff,
arrayFastClone,
arrayFastResample,
arrayHasDiff,
arrayHasOrderChange,
arrayMerge,
arraySeed,
arrayUnion,
ArrayUtil,
GroupedArray,
} from "../../src/utils/arrays";
import {objectFromEntries} from "../../src/utils/objects";
function expectSample(i: number, input: number[], expected: number[]) {
console.log(`Resample case index: ${i}`); // for debugging test failures
const result = arrayFastResample(input, expected.length);
expect(result).toBeDefined();
expect(result).toHaveLength(expected.length);
expect(result).toEqual(expected);
}
describe('arrays', () => {
describe('arrayFastResample', () => {
it('should downsample', () => {
[
{input: [1, 2, 3, 4, 5], output: [1, 4]}, // Odd -> Even
{input: [1, 2, 3, 4, 5], output: [1, 3, 5]}, // Odd -> Odd
{input: [1, 2, 3, 4], output: [1, 2, 3]}, // Even -> Odd
{input: [1, 2, 3, 4], output: [1, 3]}, // Even -> Even
].forEach((c, i) => expectSample(i, c.input, c.output));
});
it('should upsample', () => {
[
{input: [1, 2, 3], output: [1, 1, 2, 2, 3, 3]}, // Odd -> Even
{input: [1, 2, 3], output: [1, 1, 2, 2, 3]}, // Odd -> Odd
{input: [1, 2], output: [1, 1, 1, 2, 2]}, // Even -> Odd
{input: [1, 2], output: [1, 1, 1, 2, 2, 2]}, // Even -> Even
].forEach((c, i) => expectSample(i, c.input, c.output));
});
it('should maintain sample', () => {
[
{input: [1, 2, 3], output: [1, 2, 3]}, // Odd
{input: [1, 2], output: [1, 2]}, // Even
].forEach((c, i) => expectSample(i, c.input, c.output));
});
});
describe('arraySeed', () => {
it('should create an array of given length', () => {
const val = 1;
const output = [val, val, val];
const result = arraySeed(val, output.length);
expect(result).toBeDefined();
expect(result).toHaveLength(output.length);
expect(result).toEqual(output);
});
it('should maintain pointers', () => {
const val = {}; // this works because `{} !== {}`, which is what toEqual checks
const output = [val, val, val];
const result = arraySeed(val, output.length);
expect(result).toBeDefined();
expect(result).toHaveLength(output.length);
expect(result).toEqual(output);
});
});
describe('arrayFastClone', () => {
it('should break pointer reference on source array', () => {
const val = {}; // we'll test to make sure the values maintain pointers too
const input = [val, val, val];
const result = arrayFastClone(input);
expect(result).toBeDefined();
expect(result).toHaveLength(input.length);
expect(result).toEqual(input); // we want the array contents to match...
expect(result).not.toBe(input); // ... but be a different reference
});
});
describe('arrayHasOrderChange', () => {
it('should flag true on B ordering difference', () => {
const a = [1, 2, 3];
const b = [3, 2, 1];
const result = arrayHasOrderChange(a, b);
expect(result).toBe(true);
});
it('should flag false on no ordering difference', () => {
const a = [1, 2, 3];
const b = [1, 2, 3];
const result = arrayHasOrderChange(a, b);
expect(result).toBe(false);
});
it('should flag true on A length > B length', () => {
const a = [1, 2, 3, 4];
const b = [1, 2, 3];
const result = arrayHasOrderChange(a, b);
expect(result).toBe(true);
});
it('should flag true on A length < B length', () => {
const a = [1, 2, 3];
const b = [1, 2, 3, 4];
const result = arrayHasOrderChange(a, b);
expect(result).toBe(true);
});
});
describe('arrayHasDiff', () => {
it('should flag true on A length > B length', () => {
const a = [1, 2, 3, 4];
const b = [1, 2, 3];
const result = arrayHasDiff(a, b);
expect(result).toBe(true);
});
it('should flag true on A length < B length', () => {
const a = [1, 2, 3];
const b = [1, 2, 3, 4];
const result = arrayHasDiff(a, b);
expect(result).toBe(true);
});
it('should flag true on element differences', () => {
const a = [1, 2, 3];
const b = [4, 5, 6];
const result = arrayHasDiff(a, b);
expect(result).toBe(true);
});
it('should flag false if same but order different', () => {
const a = [1, 2, 3];
const b = [3, 1, 2];
const result = arrayHasDiff(a, b);
expect(result).toBe(false);
});
it('should flag false if same', () => {
const a = [1, 2, 3];
const b = [1, 2, 3];
const result = arrayHasDiff(a, b);
expect(result).toBe(false);
});
});
describe('arrayDiff', () => {
it('should see added from A->B', () => {
const a = [1, 2, 3];
const b = [1, 2, 3, 4];
const result = arrayDiff(a, b);
expect(result).toBeDefined();
expect(result.added).toBeDefined();
expect(result.removed).toBeDefined();
expect(result.added).toHaveLength(1);
expect(result.removed).toHaveLength(0);
expect(result.added).toEqual([4]);
});
it('should see removed from A->B', () => {
const a = [1, 2, 3];
const b = [1, 2];
const result = arrayDiff(a, b);
expect(result).toBeDefined();
expect(result.added).toBeDefined();
expect(result.removed).toBeDefined();
expect(result.added).toHaveLength(0);
expect(result.removed).toHaveLength(1);
expect(result.removed).toEqual([3]);
});
it('should see added and removed in the same set', () => {
const a = [1, 2, 3];
const b = [1, 2, 4]; // note diff
const result = arrayDiff(a, b);
expect(result).toBeDefined();
expect(result.added).toBeDefined();
expect(result.removed).toBeDefined();
expect(result.added).toHaveLength(1);
expect(result.removed).toHaveLength(1);
expect(result.added).toEqual([4]);
expect(result.removed).toEqual([3]);
});
});
describe('arrayUnion', () => {
it('should return a union', () => {
const a = [1, 2, 3];
const b = [1, 2, 4]; // note diff
const result = arrayUnion(a, b);
expect(result).toBeDefined();
expect(result).toHaveLength(2);
expect(result).toEqual([1, 2]);
});
it('should return an empty array on no matches', () => {
const a = [1, 2, 3];
const b = [4, 5, 6];
const result = arrayUnion(a, b);
expect(result).toBeDefined();
expect(result).toHaveLength(0);
});
});
describe('arrayMerge', () => {
it('should merge 3 arrays with deduplication', () => {
const a = [1, 2, 3];
const b = [1, 2, 4, 5]; // note missing 3
const c = [6, 7, 8, 9];
const result = arrayMerge(a, b, c);
expect(result).toBeDefined();
expect(result).toHaveLength(9);
expect(result).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9]);
});
it('should deduplicate a single array', () => {
// dev note: this is technically an edge case, but it is described behaviour if the
// function is only provided one function (it'll merge the array against itself)
const a = [1, 1, 2, 2, 3, 3];
const result = arrayMerge(a);
expect(result).toBeDefined();
expect(result).toHaveLength(3);
expect(result).toEqual([1, 2, 3]);
});
});
describe('ArrayUtil', () => {
it('should maintain the pointer to the given array', () => {
const input = [1, 2, 3];
const result = new ArrayUtil(input);
expect(result.value).toBe(input);
});
it('should group appropriately', () => {
const input = [['a', 1], ['b', 2], ['c', 3], ['a', 4], ['a', 5], ['b', 6]];
const output = {
'a': [['a', 1], ['a', 4], ['a', 5]],
'b': [['b', 2], ['b', 6]],
'c': [['c', 3]],
};
const result = new ArrayUtil(input).groupBy(p => p[0]);
expect(result).toBeDefined();
expect(result.value).toBeDefined();
const asObject = objectFromEntries(result.value.entries());
expect(asObject).toMatchObject(output);
});
});
describe('GroupedArray', () => {
it('should maintain the pointer to the given map', () => {
const input = new Map([
['a', [1, 2, 3]],
['b', [7, 8, 9]],
['c', [4, 5, 6]],
]);
const result = new GroupedArray(input);
expect(result.value).toBe(input);
});
it('should ordering by the provided key order', () => {
const input = new Map([
['a', [1, 2, 3]],
['b', [7, 8, 9]], // note counting diff
['c', [4, 5, 6]],
]);
const output = [4, 5, 6, 1, 2, 3, 7, 8, 9];
const keyOrder = ['c', 'a', 'b']; // note weird order to cause the `output` to be strange
const result = new GroupedArray(input).orderBy(keyOrder);
expect(result).toBeDefined();
expect(result.value).toBeDefined();
expect(result.value).toEqual(output);
});
});
});

67
test/utils/enums-test.ts Normal file
View file

@ -0,0 +1,67 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import {getEnumValues, isEnumValue} from "../../src/utils/enums";
enum TestStringEnum {
First = "__first__",
Second = "__second__",
}
enum TestNumberEnum {
FirstKey = 10,
SecondKey = 20,
}
describe('enums', () => {
describe('getEnumValues', () => {
it('should work on string enums', () => {
const result = getEnumValues(TestStringEnum);
expect(result).toBeDefined();
expect(result).toHaveLength(2);
expect(result).toEqual(['__first__', '__second__']);
});
it('should work on number enums', () => {
const result = getEnumValues(TestNumberEnum);
expect(result).toBeDefined();
expect(result).toHaveLength(2);
expect(result).toEqual([10, 20]);
});
});
describe('isEnumValue', () => {
it('should return true on values in a string enum', () => {
const result = isEnumValue(TestStringEnum, '__first__');
expect(result).toBe(true);
});
it('should return false on values not in a string enum', () => {
const result = isEnumValue(TestStringEnum, 'not a value');
expect(result).toBe(false);
});
it('should return true on values in a number enum', () => {
const result = isEnumValue(TestNumberEnum, 10);
expect(result).toBe(true);
});
it('should return false on values not in a number enum', () => {
const result = isEnumValue(TestStringEnum, 99);
expect(result).toBe(false);
});
});
});

View file

@ -0,0 +1,77 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import {iterableDiff, iterableUnion} from "../../src/utils/iterables";
describe('iterables', () => {
describe('iterableUnion', () => {
it('should return a union', () => {
const a = [1, 2, 3];
const b = [1, 2, 4]; // note diff
const result = iterableUnion(a, b);
expect(result).toBeDefined();
expect(result).toHaveLength(2);
expect(result).toEqual([1, 2]);
});
it('should return an empty array on no matches', () => {
const a = [1, 2, 3];
const b = [4, 5, 6];
const result = iterableUnion(a, b);
expect(result).toBeDefined();
expect(result).toHaveLength(0);
});
});
describe('iterableDiff', () => {
it('should see added from A->B', () => {
const a = [1, 2, 3];
const b = [1, 2, 3, 4];
const result = iterableDiff(a, b);
expect(result).toBeDefined();
expect(result.added).toBeDefined();
expect(result.removed).toBeDefined();
expect(result.added).toHaveLength(1);
expect(result.removed).toHaveLength(0);
expect(result.added).toEqual([4]);
});
it('should see removed from A->B', () => {
const a = [1, 2, 3];
const b = [1, 2];
const result = iterableDiff(a, b);
expect(result).toBeDefined();
expect(result.added).toBeDefined();
expect(result.removed).toBeDefined();
expect(result.added).toHaveLength(0);
expect(result.removed).toHaveLength(1);
expect(result.removed).toEqual([3]);
});
it('should see added and removed in the same set', () => {
const a = [1, 2, 3];
const b = [1, 2, 4]; // note diff
const result = iterableDiff(a, b);
expect(result).toBeDefined();
expect(result.added).toBeDefined();
expect(result.removed).toBeDefined();
expect(result.added).toHaveLength(1);
expect(result.removed).toHaveLength(1);
expect(result.added).toEqual([4]);
expect(result.removed).toEqual([3]);
});
});
});

245
test/utils/maps-test.ts Normal file
View file

@ -0,0 +1,245 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import {EnhancedMap, mapDiff, mapKeyChanges} from "../../src/utils/maps";
describe('maps', () => {
describe('mapDiff', () => {
it('should indicate no differences when the pointers are the same', () => {
const a = new Map([[1, 1], [2, 2], [3, 3]]);
const result = mapDiff(a, a);
expect(result).toBeDefined();
expect(result.added).toBeDefined();
expect(result.removed).toBeDefined();
expect(result.changed).toBeDefined();
expect(result.added).toHaveLength(0);
expect(result.removed).toHaveLength(0);
expect(result.changed).toHaveLength(0);
});
it('should indicate no differences when there are none', () => {
const a = new Map([[1, 1], [2, 2], [3, 3]]);
const b = new Map([[1, 1], [2, 2], [3, 3]]);
const result = mapDiff(a, b);
expect(result).toBeDefined();
expect(result.added).toBeDefined();
expect(result.removed).toBeDefined();
expect(result.changed).toBeDefined();
expect(result.added).toHaveLength(0);
expect(result.removed).toHaveLength(0);
expect(result.changed).toHaveLength(0);
});
it('should indicate added properties', () => {
const a = new Map([[1, 1], [2, 2], [3, 3]]);
const b = new Map([[1, 1], [2, 2], [3, 3], [4, 4]]);
const result = mapDiff(a, b);
expect(result).toBeDefined();
expect(result.added).toBeDefined();
expect(result.removed).toBeDefined();
expect(result.changed).toBeDefined();
expect(result.added).toHaveLength(1);
expect(result.removed).toHaveLength(0);
expect(result.changed).toHaveLength(0);
expect(result.added).toEqual([4]);
});
it('should indicate removed properties', () => {
const a = new Map([[1, 1], [2, 2], [3, 3]]);
const b = new Map([[1, 1], [2, 2]]);
const result = mapDiff(a, b);
expect(result).toBeDefined();
expect(result.added).toBeDefined();
expect(result.removed).toBeDefined();
expect(result.changed).toBeDefined();
expect(result.added).toHaveLength(0);
expect(result.removed).toHaveLength(1);
expect(result.changed).toHaveLength(0);
expect(result.removed).toEqual([3]);
});
it('should indicate changed properties', () => {
const a = new Map([[1, 1], [2, 2], [3, 3]]);
const b = new Map([[1, 1], [2, 2], [3, 4]]); // note change
const result = mapDiff(a, b);
expect(result).toBeDefined();
expect(result.added).toBeDefined();
expect(result.removed).toBeDefined();
expect(result.changed).toBeDefined();
expect(result.added).toHaveLength(0);
expect(result.removed).toHaveLength(0);
expect(result.changed).toHaveLength(1);
expect(result.changed).toEqual([3]);
});
it('should indicate changed, added, and removed properties', () => {
const a = new Map([[1, 1], [2, 2], [3, 3]]);
const b = new Map([[1, 1], [2, 8], [4, 4]]); // note change
const result = mapDiff(a, b);
expect(result).toBeDefined();
expect(result.added).toBeDefined();
expect(result.removed).toBeDefined();
expect(result.changed).toBeDefined();
expect(result.added).toHaveLength(1);
expect(result.removed).toHaveLength(1);
expect(result.changed).toHaveLength(1);
expect(result.added).toEqual([4]);
expect(result.removed).toEqual([3]);
expect(result.changed).toEqual([2]);
});
it('should indicate changes for difference in pointers', () => {
const a = new Map([[1, {}]]); // {} always creates a new object
const b = new Map([[1, {}]]);
const result = mapDiff(a, b);
expect(result).toBeDefined();
expect(result.added).toBeDefined();
expect(result.removed).toBeDefined();
expect(result.changed).toBeDefined();
expect(result.added).toHaveLength(0);
expect(result.removed).toHaveLength(0);
expect(result.changed).toHaveLength(1);
expect(result.changed).toEqual([1]);
});
});
describe('mapKeyChanges', () => {
it('should indicate no changes for unchanged pointers', () => {
const a = new Map([[1, 1], [2, 2], [3, 3]]);
const result = mapKeyChanges(a, a);
expect(result).toBeDefined();
expect(result).toHaveLength(0);
});
it('should indicate no changes for unchanged maps with different pointers', () => {
const a = new Map([[1, 1], [2, 2], [3, 3]]);
const b = new Map([[1, 1], [2, 2], [3, 3]]);
const result = mapKeyChanges(a, b);
expect(result).toBeDefined();
expect(result).toHaveLength(0);
});
it('should indicate changes for added properties', () => {
const a = new Map([[1, 1], [2, 2], [3, 3]]);
const b = new Map([[1, 1], [2, 2], [3, 3], [4, 4]]);
const result = mapKeyChanges(a, b);
expect(result).toBeDefined();
expect(result).toHaveLength(1);
expect(result).toEqual([4]);
});
it('should indicate changes for removed properties', () => {
const a = new Map([[1, 1], [2, 2], [3, 3], [4, 4]]);
const b = new Map([[1, 1], [2, 2], [3, 3]]);
const result = mapKeyChanges(a, b);
expect(result).toBeDefined();
expect(result).toHaveLength(1);
expect(result).toEqual([4]);
});
it('should indicate changes for changed properties', () => {
const a = new Map([[1, 1], [2, 2], [3, 3], [4, 4]]);
const b = new Map([[1, 1], [2, 2], [3, 3], [4, 55]]);
const result = mapKeyChanges(a, b);
expect(result).toBeDefined();
expect(result).toHaveLength(1);
expect(result).toEqual([4]);
});
it('should indicate changes for properties with different pointers', () => {
const a = new Map([[1, {}]]); // {} always creates a new object
const b = new Map([[1, {}]]);
const result = mapKeyChanges(a, b);
expect(result).toBeDefined();
expect(result).toHaveLength(1);
expect(result).toEqual([1]);
});
it('should indicate changes for changed, added, and removed properties', () => {
const a = new Map([[1, 1], [2, 2], [3, 3]]);
const b = new Map([[1, 1], [2, 8], [4, 4]]); // note change
const result = mapKeyChanges(a, b);
expect(result).toBeDefined();
expect(result).toHaveLength(3);
expect(result).toEqual([3, 4, 2]); // order irrelevant, but the test cares
});
});
describe('EnhancedMap', () => {
// Most of these tests will make sure it implements the Map<K, V> class
it('should be empty by default', () => {
const result = new EnhancedMap();
expect(result.size).toBe(0);
});
it('should use the provided entries', () => {
const obj = {a: 1, b: 2};
const result = new EnhancedMap(Object.entries(obj));
expect(result.size).toBe(2);
expect(result.get('a')).toBe(1);
expect(result.get('b')).toBe(2);
});
it('should create keys if they do not exist', () => {
const key = 'a';
const val = {}; // we'll check pointers
const result = new EnhancedMap<string, any>();
expect(result.size).toBe(0);
let get = result.getOrCreate(key, val);
expect(get).toBeDefined();
expect(get).toBe(val);
expect(result.size).toBe(1);
get = result.getOrCreate(key, 44); // specifically change `val`
expect(get).toBeDefined();
expect(get).toBe(val);
expect(result.size).toBe(1);
get = result.get(key); // use the base class function
expect(get).toBeDefined();
expect(get).toBe(val);
expect(result.size).toBe(1);
});
it('should proxy remove to delete and return it', () => {
const val = {};
const result = new EnhancedMap<string, any>();
result.set('a', val);
expect(result.size).toBe(1);
const removed = result.remove('a');
expect(result.size).toBe(0);
expect(removed).toBeDefined();
expect(removed).toBe(val);
});
it('should support removing unknown keys', () => {
const val = {};
const result = new EnhancedMap<string, any>();
result.set('a', val);
expect(result.size).toBe(1);
const removed = result.remove('not-a');
expect(result.size).toBe(1);
expect(removed).not.toBeDefined();
});
});
});

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