mirror of
https://github.com/vector-im/element-web.git
synced 2024-11-15 20:54:59 +08:00
Merge branch 'develop' into warn-on-access-token-reveal
This commit is contained in:
commit
31ff05a862
118
CHANGELOG.md
118
CHANGELOG.md
@ -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)
|
||||
=====================================================================================================
|
||||
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.18.0-rc.1...v3.18.0)
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "matrix-react-sdk",
|
||||
"version": "3.18.0",
|
||||
"version": "3.19.0",
|
||||
"description": "SDK for matrix.org using React",
|
||||
"author": "matrix.org",
|
||||
"repository": {
|
||||
@ -23,9 +23,7 @@
|
||||
"package.json"
|
||||
],
|
||||
"bin": {
|
||||
"reskindex": "scripts/reskindex.js",
|
||||
"matrix-gen-i18n": "scripts/gen-i18n.js",
|
||||
"matrix-prune-i18n": "scripts/prune-i18n.js"
|
||||
"reskindex": "scripts/reskindex.js"
|
||||
},
|
||||
"main": "./src/index.js",
|
||||
"matrix_src_main": "./src/index.js",
|
||||
@ -35,7 +33,7 @@
|
||||
"prepublishOnly": "yarn build",
|
||||
"i18n": "matrix-gen-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:watch": "node scripts/reskindex.js -h header -w",
|
||||
"rethemendex": "res/css/rethemendex.sh",
|
||||
@ -160,6 +158,7 @@
|
||||
"jest-fetch-mock": "^3.0.3",
|
||||
"matrix-mock-request": "^1.2.3",
|
||||
"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",
|
||||
"react-test-renderer": "^16.14.0",
|
||||
"rimraf": "^3.0.2",
|
||||
|
@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
.mx_RoomStatusBar {
|
||||
.mx_RoomStatusBar:not(.mx_RoomStatusBar_unsentMessages) {
|
||||
margin-left: 65px;
|
||||
min-height: 50px;
|
||||
}
|
||||
@ -68,6 +68,99 @@ limitations under the License.
|
||||
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 {
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
@ -103,7 +196,7 @@ limitations under the License.
|
||||
}
|
||||
|
||||
.mx_MatrixChat_useCompactLayout {
|
||||
.mx_RoomStatusBar {
|
||||
.mx_RoomStatusBar:not(.mx_RoomStatusBar_unsentMessages) {
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
|
@ -26,7 +26,10 @@ limitations under the License.
|
||||
word-break: break-word;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.mx_SpaceRoomDirectory,
|
||||
.mx_SpaceRoomView_landing {
|
||||
.mx_Dialog_title {
|
||||
display: flex;
|
||||
|
||||
@ -56,65 +59,63 @@ limitations under the License.
|
||||
}
|
||||
}
|
||||
|
||||
.mx_Dialog_content {
|
||||
.mx_AccessibleButton_kind_link {
|
||||
padding: 0;
|
||||
}
|
||||
.mx_AccessibleButton_kind_link {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.mx_SearchBox {
|
||||
margin: 24px 0 16px;
|
||||
}
|
||||
.mx_SearchBox {
|
||||
margin: 24px 0 16px;
|
||||
}
|
||||
|
||||
.mx_SpaceRoomDirectory_noResults {
|
||||
text-align: center;
|
||||
.mx_SpaceRoomDirectory_noResults {
|
||||
text-align: center;
|
||||
|
||||
> div {
|
||||
font-size: $font-15px;
|
||||
line-height: $font-24px;
|
||||
color: $secondary-fg-color;
|
||||
}
|
||||
}
|
||||
|
||||
.mx_SpaceRoomDirectory_listHeader {
|
||||
display: flex;
|
||||
min-height: 32px;
|
||||
align-items: center;
|
||||
> div {
|
||||
font-size: $font-15px;
|
||||
line-height: $font-24px;
|
||||
color: $primary-fg-color;
|
||||
color: $secondary-fg-color;
|
||||
}
|
||||
}
|
||||
|
||||
.mx_AccessibleButton {
|
||||
padding: 2px 8px;
|
||||
font-weight: normal;
|
||||
.mx_SpaceRoomDirectory_listHeader {
|
||||
display: flex;
|
||||
min-height: 32px;
|
||||
align-items: center;
|
||||
font-size: $font-15px;
|
||||
line-height: $font-24px;
|
||||
color: $primary-fg-color;
|
||||
|
||||
& + .mx_AccessibleButton {
|
||||
margin-left: 16px;
|
||||
}
|
||||
}
|
||||
.mx_AccessibleButton {
|
||||
padding: 2px 8px;
|
||||
font-weight: normal;
|
||||
|
||||
> span {
|
||||
margin-left: auto;
|
||||
& + .mx_AccessibleButton {
|
||||
margin-left: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.mx_SpaceRoomDirectory_error {
|
||||
position: relative;
|
||||
font-weight: $font-semi-bold;
|
||||
color: $notice-primary-color;
|
||||
font-size: $font-15px;
|
||||
line-height: $font-18px;
|
||||
margin: 20px auto 12px;
|
||||
padding-left: 24px;
|
||||
width: max-content;
|
||||
> span {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
left: 0;
|
||||
background-image: url("$(res)/img/element-icons/warning-badge.svg");
|
||||
}
|
||||
.mx_SpaceRoomDirectory_error {
|
||||
position: relative;
|
||||
font-weight: $font-semi-bold;
|
||||
color: $notice-primary-color;
|
||||
font-size: $font-15px;
|
||||
line-height: $font-18px;
|
||||
margin: 20px auto 12px;
|
||||
padding-left: 24px;
|
||||
width: max-content;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
left: 0;
|
||||
background-image: url("$(res)/img/element-icons/warning-badge.svg");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -214,12 +214,11 @@ $SpaceRoomViewInnerWidth: 428px;
|
||||
|
||||
.mx_SpaceRoomView_info {
|
||||
display: inline-block;
|
||||
margin: 0;
|
||||
margin: 0 auto 0 0;
|
||||
}
|
||||
|
||||
.mx_FacePile {
|
||||
display: inline-block;
|
||||
margin-left: auto;
|
||||
margin-right: 12px;
|
||||
|
||||
.mx_FacePile_faces {
|
||||
|
@ -148,12 +148,14 @@ limitations under the License.
|
||||
font-size: $font-15px;
|
||||
line-height: 30px;
|
||||
flex-grow: 1;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.mx_FormButton {
|
||||
min-width: 92px;
|
||||
font-weight: normal;
|
||||
box-sizing: border-box;
|
||||
.mx_Checkbox {
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -192,8 +194,4 @@ limitations under the License.
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.mx_FormButton {
|
||||
padding: 8px 22px;
|
||||
}
|
||||
}
|
||||
|
@ -31,8 +31,7 @@ limitations under the License.
|
||||
|
||||
.mx_ImageView_image {
|
||||
pointer-events: all;
|
||||
max-width: 95%;
|
||||
max-height: 95%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mx_ImageView_panel {
|
||||
|
@ -105,3 +105,11 @@ limitations under the License.
|
||||
.mx_MessageActionBar_optionsButton::after {
|
||||
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');
|
||||
}
|
||||
|
@ -214,10 +214,6 @@ $left-gutter: 64px;
|
||||
color: $accent-fg-color;
|
||||
}
|
||||
|
||||
.mx_EventTile_notSent {
|
||||
color: $event-notsent-color;
|
||||
}
|
||||
|
||||
.mx_EventTile_receiptSent,
|
||||
.mx_EventTile_receiptSending {
|
||||
// We don't use `position: relative` on the element because then it won't line
|
||||
|
@ -17,7 +17,7 @@ limitations under the License.
|
||||
|
||||
.mx_CallView {
|
||||
border-radius: 8px;
|
||||
background-color: $voipcall-plinth-color;
|
||||
background-color: $dark-panel-bg-color;
|
||||
padding-left: 8px;
|
||||
padding-right: 8px;
|
||||
// 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;
|
||||
padding-bottom: 8px;
|
||||
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;
|
||||
|
||||
.mx_CallView_voice {
|
||||
|
3
res/img/element-icons/retry.svg
Normal file
3
res/img/element-icons/retry.svg
Normal 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 |
3
res/img/element-icons/trashcan.svg
Normal file
3
res/img/element-icons/trashcan.svg
Normal 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 |
@ -63,6 +63,8 @@ $input-invalid-border-color: $warning-color;
|
||||
|
||||
$field-focused-label-bg-color: $bg-color;
|
||||
|
||||
$resend-button-divider-color: #b9bec64a; // muted-text with a 4A opacity.
|
||||
|
||||
// scrollbars
|
||||
$scrollbar-thumb-color: rgba(255, 255, 255, 0.2);
|
||||
$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;
|
||||
|
||||
// this probably shouldn't have it's own colour
|
||||
$voipcall-plinth-color: #21262c;
|
||||
$voipcall-plinth-color: #394049;
|
||||
|
||||
// ********************
|
||||
|
||||
|
@ -61,6 +61,8 @@ $input-invalid-border-color: $warning-color;
|
||||
|
||||
$field-focused-label-bg-color: $bg-color;
|
||||
|
||||
$resend-button-divider-color: $muted-fg-color;
|
||||
|
||||
// scrollbars
|
||||
$scrollbar-thumb-color: rgba(255, 255, 255, 0.2);
|
||||
$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;
|
||||
|
||||
// this probably shouldn't have it's own colour
|
||||
$voipcall-plinth-color: #f2f5f8;
|
||||
$voipcall-plinth-color: #394049;
|
||||
|
||||
// ********************
|
||||
|
||||
|
@ -97,6 +97,8 @@ $input-invalid-border-color: $warning-color;
|
||||
|
||||
$field-focused-label-bg-color: #ffffff;
|
||||
|
||||
$resend-button-divider-color: $input-darker-bg-color;
|
||||
|
||||
$button-bg-color: $accent-color;
|
||||
$button-fg-color: white;
|
||||
|
||||
@ -174,7 +176,7 @@ $composer-e2e-icon-color: #91a1c0;
|
||||
$header-divider-color: #91a1c0;
|
||||
|
||||
// this probably shouldn't have it's own colour
|
||||
$voipcall-plinth-color: #f2f5f8;
|
||||
$voipcall-plinth-color: #F4F6FA;
|
||||
|
||||
// ********************
|
||||
|
||||
|
@ -91,6 +91,8 @@ $field-focused-label-bg-color: #ffffff;
|
||||
$button-bg-color: $accent-color;
|
||||
$button-fg-color: white;
|
||||
|
||||
$resend-button-divider-color: $input-darker-bg-color;
|
||||
|
||||
// apart from login forms, which have stronger border
|
||||
$strong-input-border-color: #c7c7c7;
|
||||
|
||||
@ -165,7 +167,7 @@ $composer-e2e-icon-color: #91A1C0;
|
||||
$header-divider-color: #91A1C0;
|
||||
|
||||
// this probably shouldn't have it's own colour
|
||||
$voipcall-plinth-color: #f2f5f8;
|
||||
$voipcall-plinth-color: #F4F6FA;
|
||||
|
||||
// ********************
|
||||
|
||||
|
@ -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");
|
||||
}
|
@ -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}`);
|
@ -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");
|
||||
}
|
||||
}
|
8
src/@types/global.d.ts
vendored
8
src/@types/global.d.ts
vendored
@ -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");
|
||||
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 {SpaceStoreClass} from "../stores/SpaceStore";
|
||||
import {VoiceRecording} from "../voice/VoiceRecording";
|
||||
import TypingStore from "../stores/TypingStore";
|
||||
import { EventIndexPeg } from "../indexing/EventIndexPeg";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
@ -72,11 +74,15 @@ declare global {
|
||||
mxVoipUserMapper: VoipUserMapper;
|
||||
mxSpaceStore: SpaceStoreClass;
|
||||
mxVoiceRecorder: typeof VoiceRecording;
|
||||
mxTypingStore: TypingStore;
|
||||
mxEventIndexPeg: EventIndexPeg;
|
||||
}
|
||||
|
||||
interface Document {
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/Document/hasStorageAccess
|
||||
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
|
||||
// previously so let's continue to support them for now
|
||||
|
@ -27,11 +27,7 @@ export type ResizeMethod = "crop" | "scale";
|
||||
export function avatarUrlForMember(member: RoomMember, width: number, height: number, resizeMethod: ResizeMethod) {
|
||||
let url: string;
|
||||
if (member?.getMxcAvatarUrl()) {
|
||||
url = mediaFromMxc(member.getMxcAvatarUrl()).getThumbnailOfSourceHttp(
|
||||
Math.floor(width * window.devicePixelRatio),
|
||||
Math.floor(height * window.devicePixelRatio),
|
||||
resizeMethod,
|
||||
);
|
||||
url = mediaFromMxc(member.getMxcAvatarUrl()).getThumbnailOfSourceHttp(width, height, resizeMethod);
|
||||
}
|
||||
if (!url) {
|
||||
// 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) {
|
||||
if (!user.avatarUrl) return null;
|
||||
return mediaFromMxc(user.avatarUrl).getThumbnailOfSourceHttp(
|
||||
Math.floor(width * window.devicePixelRatio),
|
||||
Math.floor(height * window.devicePixelRatio),
|
||||
resizeMethod,
|
||||
);
|
||||
return mediaFromMxc(user.avatarUrl).getThumbnailOfSourceHttp(width, height, resizeMethod);
|
||||
}
|
||||
|
||||
function isValidHexColor(color: string): boolean {
|
||||
|
@ -258,7 +258,7 @@ export default abstract class BasePlatform {
|
||||
return null;
|
||||
}
|
||||
|
||||
setLanguage(preferredLangs: string[]) {}
|
||||
async setLanguage(preferredLangs: string[]) {}
|
||||
|
||||
setSpellCheckLanguages(preferredLangs: string[]) {}
|
||||
|
||||
|
@ -673,7 +673,7 @@ export default class CallHandler {
|
||||
call.placeScreenSharingCall(
|
||||
remoteElement,
|
||||
localElement,
|
||||
async () : Promise<DesktopCapturerSource> => {
|
||||
async (): Promise<DesktopCapturerSource> => {
|
||||
const {finished} = Modal.createDialog(DesktopCapturerSourcePicker);
|
||||
const [source] = await finished;
|
||||
return source;
|
||||
|
@ -231,8 +231,10 @@ export class KeyBindingsManager {
|
||||
/**
|
||||
* Finds a matching KeyAction for a given KeyboardEvent
|
||||
*/
|
||||
private getAction<T extends string>(getters: KeyBindingGetter<T>[], ev: KeyboardEvent | React.KeyboardEvent)
|
||||
: T | undefined {
|
||||
private getAction<T extends string>(
|
||||
getters: KeyBindingGetter<T>[],
|
||||
ev: KeyboardEvent | React.KeyboardEvent,
|
||||
): T | undefined {
|
||||
for (const getter of getters) {
|
||||
const bindings = getter();
|
||||
const binding = bindings.find(it => isKeyComboMatch(ev, it.keyCombo, isMac));
|
||||
|
@ -1,9 +1,6 @@
|
||||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2017 Vector Creations Ltd
|
||||
Copyright 2018 New Vector Ltd
|
||||
Copyright 2015-2021 The Matrix.org Foundation C.I.C.
|
||||
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");
|
||||
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
|
||||
/* eslint-disable camelcase */
|
||||
interface ILoginParams {
|
||||
identifier?: string;
|
||||
identifier?: object;
|
||||
password?: string;
|
||||
token?: string;
|
||||
device_id?: string;
|
||||
|
@ -21,11 +21,11 @@ import { EventStatus } from 'matrix-js-sdk/src/models/event';
|
||||
|
||||
export default class Resend {
|
||||
static resendUnsentEvents(room) {
|
||||
room.getPendingEvents().filter(function(ev) {
|
||||
return Promise.all(room.getPendingEvents().filter(function(ev) {
|
||||
return ev.status === EventStatus.NOT_SENT;
|
||||
}).forEach(function(event) {
|
||||
Resend.resend(event);
|
||||
});
|
||||
}).map(function(event) {
|
||||
return Resend.resend(event);
|
||||
}));
|
||||
}
|
||||
|
||||
static cancelUnsentEvents(room) {
|
||||
@ -38,7 +38,7 @@ export default class Resend {
|
||||
|
||||
static resend(event) {
|
||||
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({
|
||||
action: 'message_sent',
|
||||
event: event,
|
||||
|
@ -1,6 +1,5 @@
|
||||
/*
|
||||
Copyright 2016 OpenMarket Ltd
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2016, 2019, 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.
|
||||
@ -17,13 +16,14 @@ limitations under the License.
|
||||
|
||||
import url from 'url';
|
||||
import SettingsStore from "./settings/SettingsStore";
|
||||
import { Service, startTermsFlow, TermsNotSignedError } from './Terms';
|
||||
import { Service, startTermsFlow, TermsInteractionCallback, TermsNotSignedError } from './Terms';
|
||||
import {MatrixClientPeg} from "./MatrixClientPeg";
|
||||
import request from "browser-request";
|
||||
|
||||
import SdkConfig from "./SdkConfig";
|
||||
import {WidgetType} from "./widgets/WidgetType";
|
||||
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
|
||||
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.
|
||||
|
||||
export default class ScalarAuthClient {
|
||||
constructor(apiUrl, uiUrl) {
|
||||
this.apiUrl = apiUrl;
|
||||
this.uiUrl = uiUrl;
|
||||
private scalarToken: string;
|
||||
private termsInteractionCallback: TermsInteractionCallback;
|
||||
private isDefaultManager: boolean;
|
||||
|
||||
constructor(private apiUrl: string, private uiUrl: string) {
|
||||
this.scalarToken = null;
|
||||
// `undefined` to allow `startTermsFlow` to fallback to a default
|
||||
// callback if this is unset.
|
||||
@ -46,7 +48,7 @@ export default class ScalarAuthClient {
|
||||
this.isDefaultManager = apiUrl === configApiUrl && configUiUrl === uiUrl;
|
||||
}
|
||||
|
||||
_writeTokenToStore() {
|
||||
private writeTokenToStore() {
|
||||
window.localStorage.setItem("mx_scalar_token_at_" + this.apiUrl, this.scalarToken);
|
||||
if (this.isDefaultManager) {
|
||||
// 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);
|
||||
if (!token && this.isDefaultManager) {
|
||||
token = window.localStorage.getItem("mx_scalar_token");
|
||||
@ -64,33 +66,33 @@ export default class ScalarAuthClient {
|
||||
return token;
|
||||
}
|
||||
|
||||
_readToken() {
|
||||
private readToken(): string {
|
||||
if (this.scalarToken) return this.scalarToken;
|
||||
return this._readTokenFromStore();
|
||||
return this.readTokenFromStore();
|
||||
}
|
||||
|
||||
setTermsInteractionCallback(callback) {
|
||||
this.termsInteractionCallback = callback;
|
||||
}
|
||||
|
||||
connect() {
|
||||
connect(): Promise<void> {
|
||||
return this.getScalarToken().then((tok) => {
|
||||
this.scalarToken = tok;
|
||||
});
|
||||
}
|
||||
|
||||
hasCredentials() {
|
||||
hasCredentials(): boolean {
|
||||
return this.scalarToken != null; // undef or null
|
||||
}
|
||||
|
||||
// Returns a promise that resolves to a scalar_token string
|
||||
getScalarToken() {
|
||||
const token = this._readToken();
|
||||
getScalarToken(): Promise<string> {
|
||||
const token = this.readToken();
|
||||
|
||||
if (!token) {
|
||||
return this.registerForToken();
|
||||
} else {
|
||||
return this._checkToken(token).catch((e) => {
|
||||
return this.checkToken(token).catch((e) => {
|
||||
if (e instanceof TermsNotSignedError) {
|
||||
// retrying won't help this
|
||||
throw e;
|
||||
@ -100,7 +102,7 @@ export default class ScalarAuthClient {
|
||||
}
|
||||
}
|
||||
|
||||
_getAccountName(token) {
|
||||
private getAccountName(token: string): Promise<string> {
|
||||
const url = this.apiUrl + "/account";
|
||||
|
||||
return new Promise(function(resolve, reject) {
|
||||
@ -125,8 +127,8 @@ export default class ScalarAuthClient {
|
||||
});
|
||||
}
|
||||
|
||||
_checkToken(token) {
|
||||
return this._getAccountName(token).then(userId => {
|
||||
private checkToken(token: string): Promise<string> {
|
||||
return this.getAccountName(token).then(userId => {
|
||||
const me = MatrixClientPeg.get().getUserId();
|
||||
if (userId !== me) {
|
||||
throw new Error("Scalar token is owned by someone else: " + me);
|
||||
@ -154,7 +156,7 @@ export default class ScalarAuthClient {
|
||||
parsedImRestUrl.pathname = '';
|
||||
return startTermsFlow([new Service(
|
||||
SERVICE_TYPES.IM,
|
||||
parsedImRestUrl.format(),
|
||||
url.format(parsedImRestUrl),
|
||||
token,
|
||||
)], this.termsInteractionCallback).then(() => {
|
||||
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
|
||||
return MatrixClientPeg.get().getOpenIdToken().then((tokenObject) => {
|
||||
// Now we can send that to scalar and exchange it for a scalar token
|
||||
return this.exchangeForScalarToken(tokenObject);
|
||||
}).then((token) => {
|
||||
// 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) => {
|
||||
this.scalarToken = token;
|
||||
this._writeTokenToStore();
|
||||
this.writeTokenToStore();
|
||||
return token;
|
||||
});
|
||||
}
|
||||
|
||||
exchangeForScalarToken(openidTokenObject) {
|
||||
exchangeForScalarToken(openidTokenObject: any): Promise<string> {
|
||||
const scalarRestUrl = this.apiUrl;
|
||||
|
||||
return new Promise(function(resolve, reject) {
|
||||
@ -194,7 +196,7 @@ export default class ScalarAuthClient {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else if (response.statusCode / 100 !== 2) {
|
||||
reject({statusCode: response.statusCode});
|
||||
reject(new Error(`Scalar request failed: ${response.statusCode}`));
|
||||
} else if (!body || !body.scalar_token) {
|
||||
reject(new Error("Missing scalar_token in response"));
|
||||
} else {
|
||||
@ -204,7 +206,7 @@ export default class ScalarAuthClient {
|
||||
});
|
||||
}
|
||||
|
||||
getScalarPageTitle(url) {
|
||||
getScalarPageTitle(url: string): Promise<string> {
|
||||
let scalarPageLookupUrl = this.apiUrl + '/widgets/title_lookup';
|
||||
scalarPageLookupUrl = this.getStarterLink(scalarPageLookupUrl);
|
||||
scalarPageLookupUrl += '&curl=' + encodeURIComponent(url);
|
||||
@ -218,7 +220,7 @@ export default class ScalarAuthClient {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else if (response.statusCode / 100 !== 2) {
|
||||
reject({statusCode: response.statusCode});
|
||||
reject(new Error(`Scalar request failed: ${response.statusCode}`));
|
||||
} else if (!body) {
|
||||
reject(new Error("Missing page title in response"));
|
||||
} else {
|
||||
@ -240,10 +242,10 @@ export default class ScalarAuthClient {
|
||||
* @param {string} widgetId The widget ID to disable assets for
|
||||
* @return {Promise} Resolves on completion
|
||||
*/
|
||||
disableWidgetAssets(widgetType: WidgetType, widgetId) {
|
||||
disableWidgetAssets(widgetType: WidgetType, widgetId: string): Promise<void> {
|
||||
let url = this.apiUrl + '/widgets/set_assets_state';
|
||||
url = this.getStarterLink(url);
|
||||
return new Promise((resolve, reject) => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
request({
|
||||
method: 'GET', // XXX: Actions shouldn't be GET requests
|
||||
uri: url,
|
||||
@ -257,7 +259,7 @@ export default class ScalarAuthClient {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else if (response.statusCode / 100 !== 2) {
|
||||
reject({statusCode: response.statusCode});
|
||||
reject(new Error(`Scalar request failed: ${response.statusCode}`));
|
||||
} else if (!body) {
|
||||
reject(new Error("Failed to set widget assets state"));
|
||||
} 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 roomName = room.name;
|
||||
let url = this.uiUrl;
|
||||
@ -284,7 +286,7 @@ export default class ScalarAuthClient {
|
||||
return url;
|
||||
}
|
||||
|
||||
getStarterLink(starterLinkUrl) {
|
||||
getStarterLink(starterLinkUrl: string): string {
|
||||
return starterLinkUrl + "?scalar_token=" + encodeURIComponent(this.scalarToken);
|
||||
}
|
||||
}
|
@ -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");
|
||||
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 {MatrixClientPeg} from './MatrixClientPeg';
|
||||
import * as sdk from './';
|
||||
import * as sdk from '.';
|
||||
import Modal from './Modal';
|
||||
|
||||
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} accessToken The user's access token for the service
|
||||
*/
|
||||
constructor(serviceType, baseUrl, accessToken) {
|
||||
this.serviceType = serviceType;
|
||||
this.baseUrl = baseUrl;
|
||||
this.accessToken = accessToken;
|
||||
constructor(public serviceType: string, public baseUrl: string, public accessToken: string) {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
*
|
||||
@ -51,8 +68,8 @@ export class Service {
|
||||
* if they cancel.
|
||||
*/
|
||||
export async function startTermsFlow(
|
||||
services,
|
||||
interactionCallback = dialogTermsInteractionCallback,
|
||||
services: Service[],
|
||||
interactionCallback: TermsInteractionCallback = dialogTermsInteractionCallback,
|
||||
) {
|
||||
const termsPromises = services.map(
|
||||
(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 }; });
|
||||
|
||||
// fetch the set of agreed policy URLs from account data
|
||||
@ -158,10 +175,13 @@ export async function startTermsFlow(
|
||||
}
|
||||
|
||||
export function dialogTermsInteractionCallback(
|
||||
policiesAndServicePairs,
|
||||
agreedUrls,
|
||||
extraClassNames,
|
||||
) {
|
||||
policiesAndServicePairs: {
|
||||
service: Service,
|
||||
policies: { [policy: string]: Policy },
|
||||
}[],
|
||||
agreedUrls: string[],
|
||||
extraClassNames?: string,
|
||||
): Promise<string[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
console.log("Terms that need agreement", policiesAndServicePairs);
|
||||
const TermsDialog = sdk.getComponent("views.dialogs.TermsDialog");
|
@ -45,7 +45,7 @@ export function eventTriggersUnreadCount(ev) {
|
||||
}
|
||||
|
||||
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.
|
||||
// N.B. this is NOT a read marker (RM, aka "read up to marker"),
|
||||
|
@ -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");
|
||||
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 * as sdk from '../../../../index';
|
||||
import PropTypes from 'prop-types';
|
||||
import { _t } from '../../../../languageHandler';
|
||||
import SdkConfig from '../../../../SdkConfig';
|
||||
import SettingsStore from "../../../../settings/SettingsStore";
|
||||
@ -26,14 +25,23 @@ import {formatBytes, formatCountLong} from "../../../../utils/FormattingUtils";
|
||||
import EventIndexPeg from "../../../../indexing/EventIndexPeg";
|
||||
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.
|
||||
*/
|
||||
export default class ManageEventIndexDialog extends React.Component {
|
||||
static propTypes = {
|
||||
onFinished: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default class ManageEventIndexDialog extends React.Component<IProps, IState> {
|
||||
constructor(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 crawlingRoomsCount = 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",
|
||||
import("./DisableEventIndexDialog"),
|
||||
null, null, /* priority = */ false, /* static = */ true,
|
||||
);
|
||||
};
|
||||
|
||||
_onCrawlerSleepTimeChange = (e) => {
|
||||
private onCrawlerSleepTimeChange = (e) => {
|
||||
this.setState({crawlerSleepTime: e.target.value});
|
||||
SettingsStore.setValue("crawlerSleepTime", null, SettingLevel.DEVICE, e.target.value);
|
||||
};
|
||||
@ -144,7 +152,7 @@ export default class ManageEventIndexDialog extends React.Component {
|
||||
crawlerState = _t("Not currently indexing messages for any room.");
|
||||
} else {
|
||||
crawlerState = (
|
||||
_t("Currently indexing: %(currentRoom)s", { currentRoom: this.state.currentRoom })
|
||||
_t("Currently indexing: %(currentRoom)s", { currentRoom: this.state.currentRoom })
|
||||
);
|
||||
}
|
||||
|
||||
@ -169,7 +177,7 @@ export default class ManageEventIndexDialog extends React.Component {
|
||||
label={_t('Message downloading sleep time(ms)')}
|
||||
type='number'
|
||||
value={this.state.crawlerSleepTime}
|
||||
onChange={this._onCrawlerSleepTimeChange} />
|
||||
onChange={this.onCrawlerSleepTimeChange} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@ -188,7 +196,7 @@ export default class ManageEventIndexDialog extends React.Component {
|
||||
onPrimaryButtonClick={this.props.onFinished}
|
||||
primaryButtonClass="primary"
|
||||
cancelButton={_t("Disable")}
|
||||
onCancel={this._onDisable}
|
||||
onCancel={this.onDisable}
|
||||
cancelButtonClass="danger"
|
||||
/>
|
||||
</BaseDialog>
|
@ -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");
|
||||
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 Resend from '../../Resend';
|
||||
import dis from '../../dispatcher/dispatcher';
|
||||
import {messageForResourceLimitError, messageForSendError} from '../../utils/ErrorUtils';
|
||||
import {messageForResourceLimitError} from '../../utils/ErrorUtils';
|
||||
import {Action} from "../../dispatcher/actions";
|
||||
import {replaceableComponent} from "../../utils/replaceableComponent";
|
||||
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_EXPANDED = 1;
|
||||
const STATUS_BAR_EXPANDED_LARGE = 2;
|
||||
|
||||
function getUnsentMessages(room) {
|
||||
export function getUnsentMessages(room) {
|
||||
if (!room) { return []; }
|
||||
return room.getPendingEvents().filter(function(ev) {
|
||||
return ev.status === EventStatus.NOT_SENT;
|
||||
@ -76,6 +80,7 @@ export default class RoomStatusBar extends React.Component {
|
||||
syncState: MatrixClientPeg.get().getSyncState(),
|
||||
syncStateData: MatrixClientPeg.get().getSyncStateData(),
|
||||
unsentMessages: getUnsentMessages(this.props.room),
|
||||
isResending: false,
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
@ -109,7 +114,10 @@ export default class RoomStatusBar extends React.Component {
|
||||
};
|
||||
|
||||
_onResendAllClick = () => {
|
||||
Resend.resendUnsentEvents(this.props.room);
|
||||
Resend.resendUnsentEvents(this.props.room).then(() => {
|
||||
this.setState({isResending: false});
|
||||
});
|
||||
this.setState({isResending: true});
|
||||
dis.fire(Action.FocusComposer);
|
||||
};
|
||||
|
||||
@ -120,9 +128,10 @@ export default class RoomStatusBar extends React.Component {
|
||||
|
||||
_onRoomLocalEchoUpdated = (event, room, oldEventId, oldStatus) => {
|
||||
if (room.roomId !== this.props.room.roomId) return;
|
||||
|
||||
const messages = getUnsentMessages(this.props.room);
|
||||
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() {
|
||||
if (this._shouldShowConnectionError()) {
|
||||
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_HIDDEN;
|
||||
@ -162,7 +171,6 @@ export default class RoomStatusBar extends React.Component {
|
||||
|
||||
_getUnsentMessageContent() {
|
||||
const unsentMessages = this.state.unsentMessages;
|
||||
if (!unsentMessages.length) return null;
|
||||
|
||||
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.",
|
||||
),
|
||||
});
|
||||
} 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 {
|
||||
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> " +
|
||||
"now. You can also select individual messages to resend or cancel.",
|
||||
{ count: unsentMessages.length },
|
||||
{
|
||||
'resendText': (sub) =>
|
||||
<a className="mx_RoomStatusBar_resend_link" key="resend" onClick={this._onResendAllClick}>{ sub }</a>,
|
||||
'cancelText': (sub) =>
|
||||
<a className="mx_RoomStatusBar_resend_link" key="cancel" onClick={this._onCancelAllClick}>{ sub }</a>,
|
||||
},
|
||||
);
|
||||
let buttonRow = <>
|
||||
<AccessibleButton onClick={this._onCancelAllClick} className="mx_RoomStatusBar_unsentCancelAllBtn">
|
||||
{_t("Delete all")}
|
||||
</AccessibleButton>
|
||||
<AccessibleButton onClick={this._onResendAllClick} className="mx_RoomStatusBar_unsentResendAllBtn">
|
||||
{_t("Retry all")}
|
||||
</AccessibleButton>
|
||||
</>;
|
||||
if (this.state.isResending) {
|
||||
buttonRow = <>
|
||||
<InlineSpinner w={20} h={20} />
|
||||
{/* span for css */}
|
||||
<span>{_t("Sending")}</span>
|
||||
</>;
|
||||
}
|
||||
|
||||
return <div className="mx_RoomStatusBar_connectionLostBar">
|
||||
<img src={require("../../../res/img/feather-customised/warning-triangle.svg")} width="24" height="24" title={_t("Warning")} alt="" />
|
||||
<div>
|
||||
<div className="mx_RoomStatusBar_connectionLostBar_title">
|
||||
{ title }
|
||||
</div>
|
||||
<div className="mx_RoomStatusBar_connectionLostBar_desc">
|
||||
{ content }
|
||||
return <>
|
||||
<div className="mx_RoomStatusBar mx_RoomStatusBar_unsentMessages">
|
||||
<div role="alert">
|
||||
<div className="mx_RoomStatusBar_unsentBadge">
|
||||
<NotificationBadge
|
||||
notification={StaticNotificationState.RED_EXCLAMATION}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mx_RoomStatusBar_unsentTitle">
|
||||
{ title }
|
||||
</div>
|
||||
<div className="mx_RoomStatusBar_unsentDescription">
|
||||
{ _t("You can select all or individual messages to retry or delete") }
|
||||
</div>
|
||||
</div>
|
||||
<div className="mx_RoomStatusBar_unsentButtonBar">
|
||||
{buttonRow}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>;
|
||||
</>;
|
||||
}
|
||||
|
||||
// return suitable content for the main (text) part of the status bar.
|
||||
_getContent() {
|
||||
render() {
|
||||
if (this._shouldShowConnectionError()) {
|
||||
return (
|
||||
<div className="mx_RoomStatusBar_connectionLostBar">
|
||||
<img src={require("../../../res/img/feather-customised/warning-triangle.svg")} width="24" height="24" title="/!\ " alt="/!\ " />
|
||||
<div>
|
||||
<div className="mx_RoomStatusBar_connectionLostBar_title">
|
||||
{ _t('Connectivity to the server has been lost.') }
|
||||
</div>
|
||||
<div className="mx_RoomStatusBar_connectionLostBar_desc">
|
||||
{ _t('Sent messages will be stored until your connection has returned.') }
|
||||
<div className="mx_RoomStatusBar">
|
||||
<div role="alert">
|
||||
<div className="mx_RoomStatusBar_connectionLostBar">
|
||||
<img src={require("../../../res/img/feather-customised/warning-triangle.svg")} width="24"
|
||||
height="24" title="/!\ " alt="/!\ " />
|
||||
<div>
|
||||
<div className="mx_RoomStatusBar_connectionLostBar_title">
|
||||
{_t('Connectivity to the server has been lost.')}
|
||||
</div>
|
||||
<div className="mx_RoomStatusBar_connectionLostBar_desc">
|
||||
{_t('Sent messages will be stored until your connection has returned.')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (this.state.unsentMessages.length > 0) {
|
||||
if (this.state.unsentMessages.length > 0 || this.state.isResending) {
|
||||
return this._getUnsentMessageContent();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
render() {
|
||||
const content = this._getContent();
|
||||
|
||||
return (
|
||||
<div className="mx_RoomStatusBar">
|
||||
<div role="alert">
|
||||
{ content }
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -136,7 +136,7 @@ const Tile: React.FC<ITileProps> = ({
|
||||
|
||||
let url: string;
|
||||
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 });
|
||||
@ -312,11 +312,12 @@ export const HierarchyLevel = ({
|
||||
|
||||
// mutate argument refreshToken to force a reload
|
||||
export const useSpaceSummary = (cli: MatrixClient, space: Room, refreshToken?: any): [
|
||||
null,
|
||||
ISpaceSummaryRoom[],
|
||||
Map<string, Map<string, ISpaceSummaryEvent>>,
|
||||
Map<string, Set<string>>,
|
||||
Map<string, Set<string>>,
|
||||
] | [] => {
|
||||
Map<string, Map<string, ISpaceSummaryEvent>>?,
|
||||
Map<string, Set<string>>?,
|
||||
Map<string, Set<string>>?,
|
||||
] | [Error] => {
|
||||
// TODO pagination
|
||||
return useAsyncMemo(async () => {
|
||||
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) {
|
||||
console.error(e); // TODO
|
||||
return [e];
|
||||
}
|
||||
|
||||
return [];
|
||||
}, [space, refreshToken], []);
|
||||
}, [space, refreshToken], [undefined]);
|
||||
};
|
||||
|
||||
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 [rooms, parentChildMap, viaMap, childParentMap] = useSpaceSummary(cli, space, refreshToken);
|
||||
const [summaryError, rooms, parentChildMap, viaMap, childParentMap] = useSpaceSummary(cli, space, refreshToken);
|
||||
|
||||
const roomsMap = useMemo(() => {
|
||||
if (!rooms) return null;
|
||||
@ -397,6 +397,10 @@ export const SpaceHierarchy: React.FC<IHierarchyProps> = ({
|
||||
const [removing, setRemoving] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
if (summaryError) {
|
||||
return <p>{_t("Your server does not support showing space hierarchies.")}</p>;
|
||||
}
|
||||
|
||||
let content;
|
||||
if (roomsMap) {
|
||||
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 }
|
||||
</AutoHideScrollbar>
|
||||
</>;
|
||||
} else if (!rooms) {
|
||||
content = <Spinner />;
|
||||
} else {
|
||||
content = <p>{_t("Your server does not support showing space hierarchies.")}</p>;
|
||||
content = <Spinner />;
|
||||
}
|
||||
|
||||
// TODO loading state/error state
|
||||
|
@ -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");
|
||||
you may not use this file except in compliance with the License.
|
||||
@ -94,7 +94,7 @@ interface IState {
|
||||
// be seeing.
|
||||
serverIsAlive: boolean;
|
||||
serverErrorIsFatal: boolean;
|
||||
serverDeadError: string;
|
||||
serverDeadError?: ReactNode;
|
||||
}
|
||||
|
||||
/*
|
||||
|
@ -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");
|
||||
you may not use this file except in compliance with the License.
|
||||
@ -95,7 +95,7 @@ interface IState {
|
||||
// be seeing.
|
||||
serverIsAlive: boolean;
|
||||
serverErrorIsFatal: boolean;
|
||||
serverDeadError: string;
|
||||
serverDeadError?: ReactNode;
|
||||
|
||||
// Our matrix client - part of state because we can't render the UI auth
|
||||
// component without it.
|
||||
|
@ -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");
|
||||
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 PropTypes from 'prop-types';
|
||||
import {_t} from '../../../languageHandler';
|
||||
import * as sdk from '../../../index';
|
||||
import dis from '../../../dispatcher/dispatcher';
|
||||
import * as Lifecycle from '../../../Lifecycle';
|
||||
import Modal from '../../../Modal';
|
||||
import {MatrixClientPeg} from "../../../MatrixClientPeg";
|
||||
import {sendLoginRequest} from "../../../Login";
|
||||
import {ISSOFlow, LoginFlow, sendLoginRequest} from "../../../Login";
|
||||
import AuthPage from "../../views/auth/AuthPage";
|
||||
import {SSO_HOMESERVER_URL_KEY, SSO_ID_SERVER_URL_KEY} from "../../../BasePlatform";
|
||||
import SSOButtons from "../../views/elements/SSOButtons";
|
||||
@ -42,26 +41,38 @@ const FLOWS_TO_VIEWS = {
|
||||
"m.login.sso": LOGIN_VIEW.SSO,
|
||||
};
|
||||
|
||||
@replaceableComponent("structures.auth.SoftLogout")
|
||||
export default class SoftLogout extends React.Component {
|
||||
static propTypes = {
|
||||
// Query parameters from MatrixChat
|
||||
realQueryParams: PropTypes.object, // {loginToken}
|
||||
|
||||
// Called when the SSO login completes
|
||||
onTokenLoginCompleted: PropTypes.func,
|
||||
interface IProps {
|
||||
// Query parameters from MatrixChat
|
||||
realQueryParams: {
|
||||
loginToken?: string;
|
||||
};
|
||||
fragmentAfterLogin?: string;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
// Called when the SSO login completes
|
||||
onTokenLoginCompleted: () => void,
|
||||
}
|
||||
|
||||
interface IState {
|
||||
loginView: number;
|
||||
keyBackupNeeded: boolean;
|
||||
busy: boolean;
|
||||
password: string;
|
||||
errorText: string;
|
||||
flows: LoginFlow[];
|
||||
}
|
||||
|
||||
@replaceableComponent("structures.auth.SoftLogout")
|
||||
export default class SoftLogout extends React.Component<IProps, IState> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
loginView: LOGIN_VIEW.LOADING,
|
||||
keyBackupNeeded: true, // assume we do while we figure it out (see componentDidMount)
|
||||
|
||||
busy: false,
|
||||
password: "",
|
||||
errorText: "",
|
||||
flows: [],
|
||||
};
|
||||
}
|
||||
|
||||
@ -72,7 +83,7 @@ export default class SoftLogout extends React.Component {
|
||||
return;
|
||||
}
|
||||
|
||||
this._initLogin();
|
||||
this.initLogin();
|
||||
|
||||
const cli = MatrixClientPeg.get();
|
||||
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 hasAllParams = queryParams && queryParams['loginToken'];
|
||||
if (hasAllParams) {
|
||||
@ -189,7 +200,7 @@ export default class SoftLogout extends React.Component {
|
||||
});
|
||||
}
|
||||
|
||||
_renderSignInSection() {
|
||||
private renderSignInSection() {
|
||||
if (this.state.loginView === LOGIN_VIEW.LOADING) {
|
||||
const Spinner = sdk.getComponent("elements.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)
|
||||
|
||||
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 (
|
||||
<div>
|
||||
@ -289,7 +300,7 @@ export default class SoftLogout extends React.Component {
|
||||
|
||||
<h3>{_t("Sign in")}</h3>
|
||||
<div>
|
||||
{this._renderSignInSection()}
|
||||
{this.renderSignInSection()}
|
||||
</div>
|
||||
|
||||
<h3>{_t("Clear personal data")}</h3>
|
@ -68,8 +68,8 @@ export default class MemberAvatar extends React.Component<IProps, IState> {
|
||||
let imageUrl = null;
|
||||
if (props.member.getMxcAvatarUrl()) {
|
||||
imageUrl = mediaFromMxc(props.member.getMxcAvatarUrl()).getThumbnailOfSourceHttp(
|
||||
Math.floor(props.width * window.devicePixelRatio),
|
||||
Math.floor(props.height * window.devicePixelRatio),
|
||||
props.width,
|
||||
props.height,
|
||||
props.resizeMethod,
|
||||
);
|
||||
}
|
||||
|
@ -93,8 +93,8 @@ export default class RoomAvatar extends React.Component<IProps, IState> {
|
||||
let oobAvatar = null;
|
||||
if (props.oobData.avatarUrl) {
|
||||
oobAvatar = mediaFromMxc(props.oobData.avatarUrl).getThumbnailOfSourceHttp(
|
||||
Math.floor(props.width * window.devicePixelRatio),
|
||||
Math.floor(props.height * window.devicePixelRatio),
|
||||
props.width,
|
||||
props.height,
|
||||
props.resizeMethod,
|
||||
);
|
||||
}
|
||||
@ -109,12 +109,7 @@ export default class RoomAvatar extends React.Component<IProps, IState> {
|
||||
private static getRoomAvatarUrl(props: IProps): string {
|
||||
if (!props.room) return null;
|
||||
|
||||
return Avatar.avatarUrlForRoom(
|
||||
props.room,
|
||||
Math.floor(props.width * window.devicePixelRatio),
|
||||
Math.floor(props.height * window.devicePixelRatio),
|
||||
props.resizeMethod,
|
||||
);
|
||||
return Avatar.avatarUrlForRoom(props.room, props.width, props.height, props.resizeMethod);
|
||||
}
|
||||
|
||||
private onRoomAvatarClick = () => {
|
||||
|
@ -1,8 +1,6 @@
|
||||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2018 New Vector Ltd
|
||||
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");
|
||||
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 {replaceableComponent} from "../../../utils/replaceableComponent";
|
||||
|
||||
function canCancel(eventStatus) {
|
||||
export function canCancel(eventStatus) {
|
||||
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());
|
||||
}
|
||||
|
||||
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 = () => {
|
||||
for (const reaction of this._getUnsentReactions()) {
|
||||
Resend.resend(reaction);
|
||||
@ -170,29 +153,6 @@ export default class MessageContextMenu extends React.Component {
|
||||
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 = () => {
|
||||
if (this.props.onCloseDialog) this.props.onCloseDialog();
|
||||
dis.dispatch({
|
||||
@ -285,20 +245,9 @@ export default class MessageContextMenu extends React.Component {
|
||||
const me = cli.getUserId();
|
||||
const mxEvent = this.props.mxEvent;
|
||||
const eventStatus = mxEvent.status;
|
||||
const editStatus = mxEvent.replacingEvent() && mxEvent.replacingEvent().status;
|
||||
const redactStatus = mxEvent.localRedactionEvent() && mxEvent.localRedactionEvent().status;
|
||||
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 resendRedactionButton;
|
||||
let redactButton;
|
||||
let cancelButton;
|
||||
let forwardButton;
|
||||
let pinButton;
|
||||
let unhidePreviewButton;
|
||||
@ -309,22 +258,6 @@ export default class MessageContextMenu extends React.Component {
|
||||
// status is SENT before remote-echo, null after
|
||||
const isSent = !eventStatus || eventStatus === EventStatus.SENT;
|
||||
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) {
|
||||
resendReactionsButton = (
|
||||
<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) {
|
||||
redactButton = (
|
||||
<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)) {
|
||||
forwardButton = (
|
||||
<MenuItem className="mx_MessageContextMenu_field" onClick={this.onForwardClick}>
|
||||
@ -455,12 +372,8 @@ export default class MessageContextMenu extends React.Component {
|
||||
|
||||
return (
|
||||
<div className="mx_MessageContextMenu">
|
||||
{ resendButton }
|
||||
{ resendEditButton }
|
||||
{ resendReactionsButton }
|
||||
{ resendRedactionButton }
|
||||
{ redactButton }
|
||||
{ cancelButton }
|
||||
{ forwardButton }
|
||||
{ pinButton }
|
||||
{ viewSourceButton }
|
||||
|
@ -41,11 +41,11 @@ interface IProps extends IDialogProps {
|
||||
}
|
||||
|
||||
const Entry = ({ room, checked, onChange }) => {
|
||||
return <div className="mx_AddExistingToSpaceDialog_entry">
|
||||
return <label className="mx_AddExistingToSpaceDialog_entry">
|
||||
<RoomAvatar room={room} height={32} width={32} />
|
||||
<span className="mx_AddExistingToSpaceDialog_entry_name">{ room.name }</span>
|
||||
<StyledCheckbox onChange={(e) => onChange(e.target.checked)} checked={checked} />
|
||||
</div>;
|
||||
</label>;
|
||||
};
|
||||
|
||||
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)) {
|
||||
arr[0].push(room);
|
||||
}
|
||||
} else if (!existingRoomsSet.has(room) && joinRule !== "public") {
|
||||
// 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);
|
||||
} else if (!existingRoomsSet.has(room)) {
|
||||
if (!DMRoomMap.shared().getUserIdForRoomId(room.roomId)) {
|
||||
arr[1].push(room);
|
||||
} else if (joinRule !== "public") {
|
||||
// Only show DMs for non-public spaces as they make very little sense in spaces other than "Just Me" ones.
|
||||
arr[2].push(room);
|
||||
}
|
||||
}
|
||||
return arr;
|
||||
}, [[], [], []]);
|
||||
@ -130,6 +134,7 @@ const AddExistingToSpaceDialog: React.FC<IProps> = ({ matrixClient: cli, space,
|
||||
placeholder={ _t("Filter your rooms and spaces") }
|
||||
onSearch={setQuery}
|
||||
autoComplete={true}
|
||||
autoFocus={true}
|
||||
/>
|
||||
<AutoHideScrollbar className="mx_AddExistingToSpaceDialog_content" id="mx_AddExistingToSpaceDialog">
|
||||
{ rooms.length > 0 ? (
|
||||
|
@ -130,7 +130,7 @@ export default class IncomingSasDialog extends React.Component {
|
||||
const oppProfile = this.state.opponentProfile;
|
||||
if (oppProfile) {
|
||||
const url = oppProfile.avatar_url
|
||||
? mediaFromMxc(oppProfile.avatar_url).getSquareThumbnailHttp(Math.floor(48 * window.devicePixelRatio))
|
||||
? mediaFromMxc(oppProfile.avatar_url).getSquareThumbnailHttp(48)
|
||||
: null;
|
||||
profile = <div className="mx_IncomingSasDialog_opponentProfile">
|
||||
<BaseAvatar name={oppProfile.displayname}
|
||||
|
@ -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");
|
||||
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);
|
||||
|
||||
const stateForError = AutoDiscoveryUtils.authComponentStateForError(e);
|
||||
if (stateForError.isFatalError) {
|
||||
if (stateForError.serverErrorIsFatal) {
|
||||
let error = _t("Unable to validate homeserver");
|
||||
if (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 it’s a good place for many.");
|
||||
}
|
||||
|
||||
let defaultServerName = this.defaultServer.hsName;
|
||||
let defaultServerName: React.ReactNode = this.defaultServer.hsName;
|
||||
if (this.defaultServer.hsNameIsDifferent) {
|
||||
defaultServerName = (
|
||||
<TextWithTooltip class="mx_Login_underlinedServerName" tooltip={this.defaultServer.hsUrl}>
|
||||
|
@ -34,16 +34,15 @@ import {RoomPermalinkCreator} from "../../../utils/permalinks/Permalinks"
|
||||
import {MatrixEvent} from "matrix-js-sdk/src/models/event";
|
||||
import {normalizeWheelEvent} from "../../../utils/Mouse";
|
||||
|
||||
const MIN_ZOOM = 100;
|
||||
const MAX_ZOOM = 300;
|
||||
// Max scale to keep gaps around the image
|
||||
const MAX_SCALE = 0.95;
|
||||
// This is used for the buttons
|
||||
const ZOOM_STEP = 10;
|
||||
const ZOOM_STEP = 0.10;
|
||||
// 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
|
||||
const ZOOM_DISTANCE = 10;
|
||||
|
||||
|
||||
interface IProps {
|
||||
src: string, // the source of the image being displayed
|
||||
name?: string, // the main title ('name') for the image
|
||||
@ -62,8 +61,10 @@ interface IProps {
|
||||
}
|
||||
|
||||
interface IState {
|
||||
rotation: number,
|
||||
zoom: number,
|
||||
minZoom: number,
|
||||
maxZoom: number,
|
||||
rotation: number,
|
||||
translationX: number,
|
||||
translationY: number,
|
||||
moving: boolean,
|
||||
@ -75,8 +76,10 @@ export default class ImageView extends React.Component<IProps, IState> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
zoom: 0,
|
||||
minZoom: MAX_SCALE,
|
||||
maxZoom: MAX_SCALE,
|
||||
rotation: 0,
|
||||
zoom: MIN_ZOOM,
|
||||
translationX: 0,
|
||||
translationY: 0,
|
||||
moving: false,
|
||||
@ -87,6 +90,8 @@ export default class ImageView extends React.Component<IProps, IState> {
|
||||
// XXX: Refs to functional components
|
||||
private contextMenuButton = createRef<any>();
|
||||
private focusLock = createRef<any>();
|
||||
private imageWrapper = createRef<HTMLDivElement>();
|
||||
private image = createRef<HTMLImageElement>();
|
||||
|
||||
private initX = 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
|
||||
// needs to be passive in order to work with Chromium
|
||||
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() {
|
||||
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) => {
|
||||
if (ev.key === Key.ESCAPE) {
|
||||
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 = () => {
|
||||
const cur = this.state.rotation;
|
||||
const rotationDegrees = cur - 90;
|
||||
@ -150,31 +205,6 @@ export default class ImageView extends React.Component<IProps, IState> {
|
||||
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 = () => {
|
||||
const a = document.createElement("a");
|
||||
a.href = this.props.src;
|
||||
@ -217,8 +247,8 @@ export default class ImageView extends React.Component<IProps, IState> {
|
||||
if (ev.button !== 0) return;
|
||||
|
||||
// Zoom in if we are completely zoomed out
|
||||
if (this.state.zoom === MIN_ZOOM) {
|
||||
this.setState({zoom: MAX_ZOOM});
|
||||
if (this.state.zoom === this.state.minZoom) {
|
||||
this.setState({zoom: this.state.maxZoom});
|
||||
return;
|
||||
}
|
||||
|
||||
@ -251,7 +281,7 @@ export default class ImageView extends React.Component<IProps, IState> {
|
||||
Math.abs(this.state.translationY - this.previousY) < ZOOM_DISTANCE
|
||||
) {
|
||||
this.setState({
|
||||
zoom: MIN_ZOOM,
|
||||
zoom: this.state.minZoom,
|
||||
translationX: 0,
|
||||
translationY: 0,
|
||||
});
|
||||
@ -286,17 +316,20 @@ export default class ImageView extends React.Component<IProps, IState> {
|
||||
|
||||
render() {
|
||||
const showEventMeta = !!this.props.mxEvent;
|
||||
const zoomingDisabled = this.state.maxZoom === this.state.minZoom;
|
||||
|
||||
let cursor;
|
||||
if (this.state.moving) {
|
||||
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";
|
||||
} else {
|
||||
cursor = "zoom-out";
|
||||
}
|
||||
const rotationDegrees = this.state.rotation + "deg";
|
||||
const zoomPercentage = this.state.zoom/100;
|
||||
const zoom = this.state.zoom;
|
||||
const translatePixelsX = this.state.translationX + "px";
|
||||
const translatePixelsY = this.state.translationY + "px";
|
||||
// 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",
|
||||
transform: `translateX(${translatePixelsX})
|
||||
translateY(${translatePixelsY})
|
||||
scale(${zoomPercentage})
|
||||
scale(${zoom})
|
||||
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 (
|
||||
<FocusLock
|
||||
returnFocus={true}
|
||||
@ -403,16 +455,8 @@ export default class ImageView extends React.Component<IProps, IState> {
|
||||
title={_t("Rotate Left")}
|
||||
onClick={ this.onRotateCounterClockwiseClick }>
|
||||
</AccessibleTooltipButton>
|
||||
<AccessibleTooltipButton
|
||||
className="mx_ImageView_button mx_ImageView_button_zoomOut"
|
||||
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>
|
||||
{zoomOutButton}
|
||||
{zoomInButton}
|
||||
<AccessibleTooltipButton
|
||||
className="mx_ImageView_button mx_ImageView_button_download"
|
||||
title={_t("Download")}
|
||||
@ -427,11 +471,14 @@ export default class ImageView extends React.Component<IProps, IState> {
|
||||
{this.renderContextMenu()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mx_ImageView_image_wrapper">
|
||||
<div
|
||||
className="mx_ImageView_image_wrapper"
|
||||
ref={this.imageWrapper}>
|
||||
<img
|
||||
src={this.props.src}
|
||||
title={this.props.name}
|
||||
style={style}
|
||||
ref={this.image}
|
||||
className="mx_ImageView_image"
|
||||
draggable={true}
|
||||
onMouseDown={this.onStartMoving}
|
||||
|
@ -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");
|
||||
you may not use this file except in compliance with the License.
|
||||
@ -67,7 +67,7 @@ const ServerPicker = ({ title, dialogTitle, serverConfig, onServerConfigChange }
|
||||
</AccessibleButton>;
|
||||
}
|
||||
|
||||
let serverName = serverConfig.isNameResolvable ? serverConfig.hsName : serverConfig.hsUrl;
|
||||
let serverName: React.ReactNode = serverConfig.isNameResolvable ? serverConfig.hsName : serverConfig.hsUrl;
|
||||
if (serverConfig.hsNameIsDifferent) {
|
||||
serverName = <TextWithTooltip class="mx_Login_underlinedServerName" tooltip={serverConfig.hsUrl}>
|
||||
{serverConfig.hsName}
|
||||
|
@ -160,7 +160,6 @@ export default class EditHistoryMessage extends React.PureComponent {
|
||||
"mx_EventTile": true,
|
||||
// Note: we keep the `sending` state class for tests, not for our styles
|
||||
"mx_EventTile_sending": isSending,
|
||||
"mx_EventTile_notSent": this.state.sendStatus === 'not_sent',
|
||||
});
|
||||
return (
|
||||
<li>
|
||||
|
@ -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
|
||||
// thumbnail resolution will be unnecessarily reduced.
|
||||
// custom timeline widths seems preferable.
|
||||
const pixelRatio = window.devicePixelRatio;
|
||||
const thumbWidth = Math.round(800 * pixelRatio);
|
||||
const thumbHeight = Math.round(600 * pixelRatio);
|
||||
const thumbWidth = 800;
|
||||
const thumbHeight = 600;
|
||||
|
||||
const content = this.props.mxEvent.getContent();
|
||||
const media = mediaFromContent(content);
|
||||
@ -218,7 +217,7 @@ export default class MImageBody extends React.Component {
|
||||
const info = content.info;
|
||||
if (
|
||||
this._isGif() ||
|
||||
pixelRatio === 1.0 ||
|
||||
window.devicePixelRatio === 1.0 ||
|
||||
(!info || !info.w || !info.h || !info.size)
|
||||
) {
|
||||
return media.getThumbnailOfSourceHttp(thumbWidth, thumbHeight);
|
||||
|
@ -29,6 +29,8 @@ import RoomContext from "../../../contexts/RoomContext";
|
||||
import Toolbar from "../../../accessibility/Toolbar";
|
||||
import {RovingAccessibleTooltipButton, useRovingTabIndex} from "../../../accessibility/RovingTabIndex";
|
||||
import {replaceableComponent} from "../../../utils/replaceableComponent";
|
||||
import {canCancel} from "../context_menus/MessageContextMenu";
|
||||
import Resend from "../../../Resend";
|
||||
|
||||
const OptionsButton = ({mxEvent, getTile, getReplyThread, permalinkCreator, onFocusChange}) => {
|
||||
const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu();
|
||||
@ -169,45 +171,118 @@ export default class MessageActionBar extends React.PureComponent {
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
let reactButton;
|
||||
let replyButton;
|
||||
let editButton;
|
||||
/**
|
||||
* Runs a given fn on the set of possible events to test. The first event
|
||||
* that passes the checkFn will have fn executed on it. Both functions take
|
||||
* 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)) {
|
||||
if (this.context.canReact) {
|
||||
reactButton = (
|
||||
<ReactButton mxEvent={this.props.mxEvent} reactions={this.props.reactions} onFocusChange={this.onFocusChange} />
|
||||
);
|
||||
}
|
||||
if (this.context.canReply) {
|
||||
replyButton = <RovingAccessibleTooltipButton
|
||||
className="mx_MessageActionBar_maskButton mx_MessageActionBar_replyButton"
|
||||
title={_t("Reply")}
|
||||
onClick={this.onReplyClick}
|
||||
/>;
|
||||
const mxEvent = this.props.mxEvent;
|
||||
const editEvent = mxEvent.replacingEvent();
|
||||
const redactEvent = mxEvent.localRedactionEvent();
|
||||
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),
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
const toolbarOpts = [];
|
||||
if (canEditContent(this.props.mxEvent)) {
|
||||
editButton = <RovingAccessibleTooltipButton
|
||||
toolbarOpts.push(<RovingAccessibleTooltipButton
|
||||
className="mx_MessageActionBar_maskButton mx_MessageActionBar_editButton"
|
||||
title={_t("Edit")}
|
||||
onClick={this.onEditClick}
|
||||
/>;
|
||||
key="edit"
|
||||
/>);
|
||||
}
|
||||
|
||||
// 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">
|
||||
{reactButton}
|
||||
{replyButton}
|
||||
{editButton}
|
||||
<OptionsButton
|
||||
const cancelSendingButton = <RovingAccessibleTooltipButton
|
||||
className="mx_MessageActionBar_maskButton mx_MessageActionBar_cancelButton"
|
||||
title={_t("Delete")}
|
||||
onClick={this.onCancelClick}
|
||||
key="cancel"
|
||||
/>;
|
||||
|
||||
// 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}
|
||||
getReplyThread={this.props.getReplyThread}
|
||||
getTile={this.props.getTile}
|
||||
permalinkCreator={this.props.permalinkCreator}
|
||||
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>;
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
/*
|
||||
Copyright 2015-2021 The Matrix.org Foundation C.I.C.
|
||||
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");
|
||||
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.
|
||||
*/
|
||||
|
||||
import React, {createRef} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
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 { _t } from '../../../languageHandler';
|
||||
@ -27,7 +29,7 @@ import * as TextForEvent from "../../../TextForEvent";
|
||||
import * as sdk from "../../../index";
|
||||
import dis from '../../../dispatcher/dispatcher';
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import {Layout, LayoutPropType} from "../../../settings/Layout";
|
||||
import {Layout} from "../../../settings/Layout";
|
||||
import {formatTime} from "../../../DateUtils";
|
||||
import {MatrixClientPeg} from '../../../MatrixClientPeg';
|
||||
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 {replaceableComponent} from "../../../utils/replaceableComponent";
|
||||
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 = {
|
||||
[EventType.RoomMessage]: 'messages.MessageEvent',
|
||||
@ -169,101 +175,130 @@ const MAX_READ_AVATARS = 5;
|
||||
// | '--------------------------------------' |
|
||||
// '----------------------------------------------------------'
|
||||
|
||||
interface IReadReceiptProps {
|
||||
userId: string;
|
||||
roomMember: RoomMember;
|
||||
ts: number;
|
||||
}
|
||||
|
||||
interface IProps {
|
||||
// the MatrixEvent to show
|
||||
mxEvent: MatrixEvent;
|
||||
|
||||
// true if mxEvent is redacted. This is a prop because using mxEvent.isRedacted()
|
||||
// might not be enough when deciding shouldComponentUpdate - prevProps.mxEvent
|
||||
// references the same this.props.mxEvent.
|
||||
isRedacted?: boolean;
|
||||
|
||||
// true if this is a continuation of the previous event (which has the
|
||||
// effect of not showing another avatar/displayname
|
||||
continuation?: boolean;
|
||||
|
||||
// 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
|
||||
// targeting)
|
||||
lastInSection?: boolean;
|
||||
|
||||
// True if the event is the last successful (sent) event.
|
||||
lastSuccessful?: boolean;
|
||||
|
||||
// true if this is search context (which has the effect of greying out
|
||||
// the text
|
||||
contextual?: boolean;
|
||||
|
||||
// a list of words to highlight, ordered by longest first
|
||||
highlights?: string[];
|
||||
|
||||
// link URL for the highlights
|
||||
highlightLink?: string;
|
||||
|
||||
// should show URL previews for this event
|
||||
showUrlPreview?: boolean;
|
||||
|
||||
// is this the focused event
|
||||
isSelectedEvent?: boolean;
|
||||
|
||||
// callback called when dynamic content in events are loaded
|
||||
onHeightChanged?: () => void;
|
||||
|
||||
// a list of read-receipts we should show. Each object has a 'roomMember' and 'ts'.
|
||||
readReceipts?: IReadReceiptProps[];
|
||||
|
||||
// opaque readreceipt info for each userId; used by ReadReceiptMarker
|
||||
// to manage its animations. Should be an empty object when the room
|
||||
// first loads
|
||||
readReceiptMap?: any;
|
||||
|
||||
// A function which is used to check if the parent panel is being
|
||||
// unmounted, to avoid unnecessary work. Should return true if we
|
||||
// are being unmounted.
|
||||
checkUnmounting?: () => boolean;
|
||||
|
||||
// the status of this event - ie, mxEvent.status. Denormalised to here so
|
||||
// that we can tell when it changes.
|
||||
eventSendStatus?: string;
|
||||
|
||||
// the shape of the tile. by default, the layout is intended for the
|
||||
// normal room timeline. alternative values are: "file_list", "file_grid"
|
||||
// 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
|
||||
// boiilerplatey. So just make the necessary render decisions conditional
|
||||
// for now.
|
||||
tileShape?: 'notif' | 'file_grid' | 'reply' | 'reply_preview';
|
||||
|
||||
// show twelve hour timestamps
|
||||
isTwelveHour?: boolean;
|
||||
|
||||
// helper function to access relations for this event
|
||||
getRelationsForEvent?: (eventId: string, relationType: string, eventType: string) => Relations;
|
||||
|
||||
// whether to show reactions for this event
|
||||
showReactions?: boolean;
|
||||
|
||||
// which layout to use
|
||||
layout: Layout;
|
||||
|
||||
// whether or not to show flair at all
|
||||
enableFlair?: boolean;
|
||||
|
||||
// whether or not to show read receipts
|
||||
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 {
|
||||
static propTypes = {
|
||||
/* the MatrixEvent to show */
|
||||
mxEvent: PropTypes.object.isRequired,
|
||||
|
||||
/* true if mxEvent is redacted. This is a prop because using mxEvent.isRedacted()
|
||||
* might not be enough when deciding shouldComponentUpdate - prevProps.mxEvent
|
||||
* references the same this.props.mxEvent.
|
||||
*/
|
||||
isRedacted: PropTypes.bool,
|
||||
|
||||
/* true if this is a continuation of the previous event (which has the
|
||||
* effect of not showing another avatar/displayname
|
||||
*/
|
||||
continuation: PropTypes.bool,
|
||||
|
||||
/* true if this is the last event in the timeline (which has the effect
|
||||
* of always showing the timestamp)
|
||||
*/
|
||||
last: PropTypes.bool,
|
||||
|
||||
// true if the event is the last event in a section (adds a css class for
|
||||
// targeting)
|
||||
lastInSection: PropTypes.bool,
|
||||
|
||||
// True if the event is the last successful (sent) event.
|
||||
isLastSuccessful: PropTypes.bool,
|
||||
|
||||
/* true if this is search context (which has the effect of greying out
|
||||
* the text
|
||||
*/
|
||||
contextual: PropTypes.bool,
|
||||
|
||||
/* a list of words to highlight, ordered by longest first */
|
||||
highlights: PropTypes.array,
|
||||
|
||||
/* link URL for the highlights */
|
||||
highlightLink: PropTypes.string,
|
||||
|
||||
/* should show URL previews for this event */
|
||||
showUrlPreview: PropTypes.bool,
|
||||
|
||||
/* is this the focused event */
|
||||
isSelectedEvent: PropTypes.bool,
|
||||
|
||||
/* callback called when dynamic content in events are loaded */
|
||||
onHeightChanged: PropTypes.func,
|
||||
|
||||
/* a list of read-receipts we should show. Each object has a 'roomMember' and 'ts'. */
|
||||
readReceipts: PropTypes.arrayOf(PropTypes.object),
|
||||
|
||||
/* opaque readreceipt info for each userId; used by ReadReceiptMarker
|
||||
* to manage its animations. Should be an empty object when the room
|
||||
* first loads
|
||||
*/
|
||||
readReceiptMap: PropTypes.object,
|
||||
|
||||
/* A function which is used to check if the parent panel is being
|
||||
* unmounted, to avoid unnecessary work. Should return true if we
|
||||
* are being unmounted.
|
||||
*/
|
||||
checkUnmounting: PropTypes.func,
|
||||
|
||||
/* the status of this event - ie, mxEvent.status. Denormalised to here so
|
||||
* that we can tell when it changes. */
|
||||
eventSendStatus: PropTypes.string,
|
||||
|
||||
/* the shape of the tile. by default, the layout is intended for the
|
||||
* normal room timeline. alternative values are: "file_list", "file_grid"
|
||||
* 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
|
||||
* boiilerplatey. So just make the necessary render decisions conditional
|
||||
* for now.
|
||||
*/
|
||||
tileShape: PropTypes.string,
|
||||
|
||||
// show twelve hour timestamps
|
||||
isTwelveHour: PropTypes.bool,
|
||||
|
||||
// helper function to access relations for this event
|
||||
getRelationsForEvent: PropTypes.func,
|
||||
|
||||
// whether to show reactions for this event
|
||||
showReactions: PropTypes.bool,
|
||||
|
||||
// which layout to use
|
||||
layout: LayoutPropType,
|
||||
|
||||
// whether or not to show flair at all
|
||||
enableFlair: PropTypes.bool,
|
||||
|
||||
// whether or not to show read receipts
|
||||
showReadReceipts: PropTypes.bool,
|
||||
};
|
||||
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 = {
|
||||
// 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
|
||||
this._suppressReadReceiptAnimation = true;
|
||||
|
||||
this._tile = createRef();
|
||||
this._replyThread = createRef();
|
||||
this.suppressReadReceiptAnimation = true;
|
||||
|
||||
// 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
|
||||
// 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
|
||||
// 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'
|
||||
* or 'sent' receipt, for example.
|
||||
* @returns {boolean}
|
||||
* @private
|
||||
*/
|
||||
get _isEligibleForSpecialReceipt() {
|
||||
private get isEligibleForSpecialReceipt() {
|
||||
// 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.mxEvent) return false;
|
||||
@ -338,9 +369,9 @@ export default class EventTile extends React.Component {
|
||||
return true;
|
||||
}
|
||||
|
||||
get _shouldShowSentReceipt() {
|
||||
private get shouldShowSentReceipt() {
|
||||
// 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.
|
||||
if (!this.props.lastSuccessful) return false;
|
||||
@ -358,9 +389,9 @@ export default class EventTile extends React.Component {
|
||||
return true;
|
||||
}
|
||||
|
||||
get _shouldShowSendingReceipt() {
|
||||
private get shouldShowSendingReceipt() {
|
||||
// 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
|
||||
// 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
|
||||
// eslint-disable-next-line camelcase
|
||||
UNSAFE_componentWillMount() {
|
||||
this._verifyEvent(this.props.mxEvent);
|
||||
this.verifyEvent(this.props.mxEvent);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this._suppressReadReceiptAnimation = false;
|
||||
this.suppressReadReceiptAnimation = false;
|
||||
const client = this.context;
|
||||
client.on("deviceVerificationChanged", this.onDeviceVerificationChanged);
|
||||
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) {
|
||||
this.props.mxEvent.on("Event.relationsCreated", this._onReactionsCreated);
|
||||
this.props.mxEvent.on("Event.relationsCreated", this.onReactionsCreated);
|
||||
}
|
||||
|
||||
if (this._shouldShowSentReceipt || this._shouldShowSendingReceipt) {
|
||||
client.on("Room.receipt", this._onRoomReceipt);
|
||||
this._isListeningForReceipts = true;
|
||||
if (this.shouldShowSentReceipt || this.shouldShowSendingReceipt) {
|
||||
client.on("Room.receipt", this.onRoomReceipt);
|
||||
this.isListeningForReceipts = true;
|
||||
}
|
||||
}
|
||||
|
||||
@ -399,7 +430,7 @@ export default class EventTile extends React.Component {
|
||||
// re-check the sender verification as outgoing events progress through
|
||||
// the send process.
|
||||
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 !this._propsEqual(this.props, nextProps);
|
||||
return !this.propsEqual(this.props, nextProps);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
const client = this.context;
|
||||
client.removeListener("deviceVerificationChanged", this.onDeviceVerificationChanged);
|
||||
client.removeListener("userTrustStatusChanged", this.onUserVerificationChanged);
|
||||
client.removeListener("Room.receipt", this._onRoomReceipt);
|
||||
this._isListeningForReceipts = false;
|
||||
this.props.mxEvent.removeListener("Event.decrypted", this._onDecrypted);
|
||||
client.removeListener("Room.receipt", this.onRoomReceipt);
|
||||
this.isListeningForReceipts = false;
|
||||
this.props.mxEvent.removeListener("Event.decrypted", this.onDecrypted);
|
||||
if (this.props.showReactions) {
|
||||
this.props.mxEvent.removeListener("Event.relationsCreated", this._onReactionsCreated);
|
||||
this.props.mxEvent.removeListener("Event.relationsCreated", this.onReactionsCreated);
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps, prevState, snapshot) {
|
||||
// If we're not listening for receipts and expect to be, register a listener.
|
||||
if (!this._isListeningForReceipts && (this._shouldShowSentReceipt || this._shouldShowSendingReceipt)) {
|
||||
this.context.on("Room.receipt", this._onRoomReceipt);
|
||||
this._isListeningForReceipts = true;
|
||||
if (!this.isListeningForReceipts && (this.shouldShowSentReceipt || this.shouldShowSendingReceipt)) {
|
||||
this.context.on("Room.receipt", this.onRoomReceipt);
|
||||
this.isListeningForReceipts = true;
|
||||
}
|
||||
}
|
||||
|
||||
_onRoomReceipt = (ev, room) => {
|
||||
private onRoomReceipt = (ev, room) => {
|
||||
// ignore events for other rooms
|
||||
const tileRoom = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId());
|
||||
if (room !== tileRoom) return;
|
||||
|
||||
if (!this._shouldShowSentReceipt && !this._shouldShowSendingReceipt && !this._isListeningForReceipts) {
|
||||
if (!this.shouldShowSentReceipt && !this.shouldShowSendingReceipt && !this.isListeningForReceipts) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -444,36 +475,36 @@ export default class EventTile extends React.Component {
|
||||
// the getters we use here to determine what needs rendering.
|
||||
this.forceUpdate(() => {
|
||||
// Per elsewhere in this file, we can remove the listener once we will have no further purpose for it.
|
||||
if (!this._shouldShowSentReceipt && !this._shouldShowSendingReceipt) {
|
||||
this.context.removeListener("Room.receipt", this._onRoomReceipt);
|
||||
this._isListeningForReceipts = false;
|
||||
if (!this.shouldShowSentReceipt && !this.shouldShowSendingReceipt) {
|
||||
this.context.removeListener("Room.receipt", this.onRoomReceipt);
|
||||
this.isListeningForReceipts = false;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/** called when the event is decrypted after we show it.
|
||||
*/
|
||||
_onDecrypted = () => {
|
||||
private onDecrypted = () => {
|
||||
// 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)
|
||||
this._verifyEvent(this.props.mxEvent);
|
||||
this.verifyEvent(this.props.mxEvent);
|
||||
this.forceUpdate();
|
||||
};
|
||||
|
||||
onDeviceVerificationChanged = (userId, device) => {
|
||||
private onDeviceVerificationChanged = (userId, device) => {
|
||||
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()) {
|
||||
this._verifyEvent(this.props.mxEvent);
|
||||
this.verifyEvent(this.props.mxEvent);
|
||||
}
|
||||
};
|
||||
|
||||
async _verifyEvent(mxEvent) {
|
||||
private async verifyEvent(mxEvent) {
|
||||
if (!mxEvent.isEncrypted()) {
|
||||
return;
|
||||
}
|
||||
@ -527,7 +558,7 @@ export default class EventTile extends React.Component {
|
||||
}, this.props.onHeightChanged); // Decryption may have caused a change in size
|
||||
}
|
||||
|
||||
_propsEqual(objA, objB) {
|
||||
private propsEqual(objA, objB) {
|
||||
const keysA = Object.keys(objA);
|
||||
const keysB = Object.keys(objB);
|
||||
|
||||
@ -594,7 +625,7 @@ export default class EventTile extends React.Component {
|
||||
};
|
||||
|
||||
getReadAvatars() {
|
||||
if (this._shouldShowSentReceipt || this._shouldShowSendingReceipt) {
|
||||
if (this.shouldShowSentReceipt || this.shouldShowSendingReceipt) {
|
||||
return <SentReceipt messageState={this.props.mxEvent.getAssociatedStatus()} />;
|
||||
}
|
||||
|
||||
@ -641,7 +672,7 @@ export default class EventTile extends React.Component {
|
||||
leftOffset={left} hidden={hidden}
|
||||
readReceiptInfo={readReceiptInfo}
|
||||
checkUnmounting={this.props.checkUnmounting}
|
||||
suppressAnimation={this._suppressReadReceiptAnimation}
|
||||
suppressAnimation={this.suppressReadReceiptAnimation}
|
||||
onClick={this.toggleAllReadAvatars}
|
||||
timestamp={receipt.ts}
|
||||
showTwelveHour={this.props.isTwelveHour}
|
||||
@ -698,7 +729,7 @@ export default class EventTile extends React.Component {
|
||||
});
|
||||
};
|
||||
|
||||
_renderE2EPadlock() {
|
||||
private renderE2EPadlock() {
|
||||
const ev = this.props.mxEvent;
|
||||
|
||||
// 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 = () => {
|
||||
if (
|
||||
@ -769,11 +800,11 @@ export default class EventTile extends React.Component {
|
||||
return this.props.getRelationsForEvent(eventId, "m.annotation", "m.reaction");
|
||||
};
|
||||
|
||||
_onReactionsCreated = (relationType, eventType) => {
|
||||
private onReactionsCreated = (relationType, eventType) => {
|
||||
if (relationType !== "m.annotation" || eventType !== "m.reaction") {
|
||||
return;
|
||||
}
|
||||
this.props.mxEvent.removeListener("Event.relationsCreated", this._onReactionsCreated);
|
||||
this.props.mxEvent.removeListener("Event.relationsCreated", this.onReactionsCreated);
|
||||
this.setState({
|
||||
reactions: this.getReactions(),
|
||||
});
|
||||
@ -838,7 +869,6 @@ export default class EventTile extends React.Component {
|
||||
mx_EventTile_12hr: this.props.isTwelveHour,
|
||||
// Note: we keep the `sending` state class for tests, not for our styles
|
||||
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_selected: this.props.isSelectedEvent,
|
||||
mx_EventTile_continuation: this.props.tileShape ? '' : this.props.continuation,
|
||||
@ -895,7 +925,7 @@ export default class EventTile extends React.Component {
|
||||
// so that the correct avatar is shown as the text is
|
||||
// `$target accepted the invitation for $email`
|
||||
if (this.props.mxEvent.getContent().third_party_invite) {
|
||||
member = this.props.mxEvent.target;
|
||||
member = this.props.mxEvent.target;
|
||||
} else {
|
||||
member = this.props.mxEvent.sender;
|
||||
}
|
||||
@ -912,8 +942,9 @@ export default class EventTile extends React.Component {
|
||||
if (needsSenderProfile) {
|
||||
if (!this.props.tileShape || this.props.tileShape === 'reply' || this.props.tileShape === 'reply_preview') {
|
||||
sender = <SenderProfile onClick={this.onSenderProfileClick}
|
||||
mxEvent={this.props.mxEvent}
|
||||
enableFlair={this.props.enableFlair} />;
|
||||
mxEvent={this.props.mxEvent}
|
||||
enableFlair={this.props.enableFlair}
|
||||
/>;
|
||||
} else {
|
||||
sender = <SenderProfile mxEvent={this.props.mxEvent} enableFlair={this.props.enableFlair} />;
|
||||
}
|
||||
@ -976,18 +1007,18 @@ export default class EventTile extends React.Component {
|
||||
}
|
||||
|
||||
const linkedTimestamp = <a
|
||||
href={permalink}
|
||||
onClick={this.onPermalinkClicked}
|
||||
aria-label={formatTime(new Date(this.props.mxEvent.getTs()), this.props.isTwelveHour)}
|
||||
>
|
||||
{ timestamp }
|
||||
</a>;
|
||||
href={permalink}
|
||||
onClick={this.onPermalinkClicked}
|
||||
aria-label={formatTime(new Date(this.props.mxEvent.getTs()), this.props.isTwelveHour)}
|
||||
>
|
||||
{ timestamp }
|
||||
</a>;
|
||||
|
||||
const useIRCLayout = this.props.layout == Layout.IRC;
|
||||
const groupTimestamp = !useIRCLayout ? linkedTimestamp : null;
|
||||
const ircTimestamp = useIRCLayout ? linkedTimestamp : null;
|
||||
const groupPadlock = !useIRCLayout && !isBubbleMessage && this._renderE2EPadlock();
|
||||
const ircPadlock = useIRCLayout && !isBubbleMessage && this._renderE2EPadlock();
|
||||
const groupPadlock = !useIRCLayout && !isBubbleMessage && this.renderE2EPadlock();
|
||||
const ircPadlock = useIRCLayout && !isBubbleMessage && this.renderE2EPadlock();
|
||||
|
||||
let msgOption;
|
||||
if (this.props.showReadReceipts) {
|
||||
@ -1018,12 +1049,13 @@ export default class EventTile extends React.Component {
|
||||
</a>
|
||||
</div>
|
||||
<div className="mx_EventTile_line">
|
||||
<EventTileType ref={this._tile}
|
||||
mxEvent={this.props.mxEvent}
|
||||
highlights={this.props.highlights}
|
||||
highlightLink={this.props.highlightLink}
|
||||
showUrlPreview={this.props.showUrlPreview}
|
||||
onHeightChanged={this.props.onHeightChanged} />
|
||||
<EventTileType ref={this.tile}
|
||||
mxEvent={this.props.mxEvent}
|
||||
highlights={this.props.highlights}
|
||||
highlightLink={this.props.highlightLink}
|
||||
showUrlPreview={this.props.showUrlPreview}
|
||||
onHeightChanged={this.props.onHeightChanged}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@ -1032,13 +1064,14 @@ export default class EventTile extends React.Component {
|
||||
return (
|
||||
<div className={classes} aria-live={ariaLive} aria-atomic="true">
|
||||
<div className="mx_EventTile_line">
|
||||
<EventTileType ref={this._tile}
|
||||
mxEvent={this.props.mxEvent}
|
||||
highlights={this.props.highlights}
|
||||
highlightLink={this.props.highlightLink}
|
||||
showUrlPreview={this.props.showUrlPreview}
|
||||
tileShape={this.props.tileShape}
|
||||
onHeightChanged={this.props.onHeightChanged} />
|
||||
<EventTileType ref={this.tile}
|
||||
mxEvent={this.props.mxEvent}
|
||||
highlights={this.props.highlights}
|
||||
highlightLink={this.props.highlightLink}
|
||||
showUrlPreview={this.props.showUrlPreview}
|
||||
tileShape={this.props.tileShape}
|
||||
onHeightChanged={this.props.onHeightChanged}
|
||||
/>
|
||||
</div>
|
||||
<a
|
||||
className="mx_EventTile_senderDetailsLink"
|
||||
@ -1062,7 +1095,7 @@ export default class EventTile extends React.Component {
|
||||
this.props.mxEvent,
|
||||
this.props.onHeightChanged,
|
||||
this.props.permalinkCreator,
|
||||
this._replyThread,
|
||||
this.replyThread,
|
||||
);
|
||||
}
|
||||
return (
|
||||
@ -1075,13 +1108,14 @@ export default class EventTile extends React.Component {
|
||||
{ groupTimestamp }
|
||||
{ groupPadlock }
|
||||
{ thread }
|
||||
<EventTileType ref={this._tile}
|
||||
mxEvent={this.props.mxEvent}
|
||||
highlights={this.props.highlights}
|
||||
highlightLink={this.props.highlightLink}
|
||||
onHeightChanged={this.props.onHeightChanged}
|
||||
replacingEventId={this.props.replacingEventId}
|
||||
showUrlPreview={false} />
|
||||
<EventTileType ref={this.tile}
|
||||
mxEvent={this.props.mxEvent}
|
||||
highlights={this.props.highlights}
|
||||
highlightLink={this.props.highlightLink}
|
||||
onHeightChanged={this.props.onHeightChanged}
|
||||
replacingEventId={this.props.replacingEventId}
|
||||
showUrlPreview={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@ -1091,7 +1125,7 @@ export default class EventTile extends React.Component {
|
||||
this.props.mxEvent,
|
||||
this.props.onHeightChanged,
|
||||
this.props.permalinkCreator,
|
||||
this._replyThread,
|
||||
this.replyThread,
|
||||
this.props.layout,
|
||||
);
|
||||
|
||||
@ -1105,15 +1139,16 @@ export default class EventTile extends React.Component {
|
||||
{ groupTimestamp }
|
||||
{ groupPadlock }
|
||||
{ thread }
|
||||
<EventTileType ref={this._tile}
|
||||
mxEvent={this.props.mxEvent}
|
||||
replacingEventId={this.props.replacingEventId}
|
||||
editState={this.props.editState}
|
||||
highlights={this.props.highlights}
|
||||
highlightLink={this.props.highlightLink}
|
||||
showUrlPreview={this.props.showUrlPreview}
|
||||
permalinkCreator={this.props.permalinkCreator}
|
||||
onHeightChanged={this.props.onHeightChanged} />
|
||||
<EventTileType ref={this.tile}
|
||||
mxEvent={this.props.mxEvent}
|
||||
replacingEventId={this.props.replacingEventId}
|
||||
editState={this.props.editState}
|
||||
highlights={this.props.highlights}
|
||||
highlightLink={this.props.highlightLink}
|
||||
showUrlPreview={this.props.showUrlPreview}
|
||||
permalinkCreator={this.props.permalinkCreator}
|
||||
onHeightChanged={this.props.onHeightChanged}
|
||||
/>
|
||||
{ keyRequestInfo }
|
||||
{ reactionsRow }
|
||||
{ actionBar }
|
||||
@ -1182,18 +1217,26 @@ function E2ePadlockUnknown(props) {
|
||||
|
||||
function E2ePadlockUnauthenticated(props) {
|
||||
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 {
|
||||
static propTypes = {
|
||||
icon: PropTypes.string.isRequired,
|
||||
title: PropTypes.string.isRequired,
|
||||
};
|
||||
interface IE2ePadlockProps {
|
||||
icon: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
interface IE2ePadlockState {
|
||||
hover: boolean;
|
||||
}
|
||||
|
||||
class E2ePadlock extends React.Component<IE2ePadlockProps, IE2ePadlockState> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
hover: false,
|
||||
@ -1211,14 +1254,13 @@ class E2ePadlock extends React.Component {
|
||||
render() {
|
||||
let tooltip = null;
|
||||
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}`;
|
||||
return (
|
||||
<div
|
||||
className={classes}
|
||||
onClick={this.onClick}
|
||||
onMouseEnter={this.onHoverStart}
|
||||
onMouseLeave={this.onHoverEnd}
|
||||
>{tooltip}</div>
|
||||
@ -1235,8 +1277,8 @@ interface ISentReceiptState {
|
||||
}
|
||||
|
||||
class SentReceipt extends React.PureComponent<ISentReceiptProps, ISentReceiptState> {
|
||||
constructor() {
|
||||
super();
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
hover: false,
|
||||
@ -1253,11 +1295,19 @@ class SentReceipt extends React.PureComponent<ISentReceiptProps, ISentReceiptSta
|
||||
|
||||
render() {
|
||||
const isSent = !this.props.messageState || this.props.messageState === 'sent';
|
||||
const isFailed = this.props.messageState === 'not_sent';
|
||||
const receiptClasses = classNames({
|
||||
'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;
|
||||
if (this.state.hover) {
|
||||
let label = _t("Sending your message...");
|
||||
@ -1265,6 +1315,8 @@ class SentReceipt extends React.PureComponent<ISentReceiptProps, ISentReceiptSta
|
||||
label = _t("Encrypting your message...");
|
||||
} else if (isSent) {
|
||||
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
|
||||
// with the read receipt.
|
||||
@ -1273,6 +1325,7 @@ class SentReceipt extends React.PureComponent<ISentReceiptProps, ISentReceiptSta
|
||||
|
||||
return <span className="mx_EventTile_readAvatars">
|
||||
<span className={receiptClasses} onMouseEnter={this.onHoverStart} onMouseLeave={this.onHoverEnd}>
|
||||
{nonCssBadge}
|
||||
{tooltip}
|
||||
</span>
|
||||
</span>;
|
@ -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");
|
||||
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
|
||||
limitations under the License.
|
||||
*/
|
||||
import React, {createRef} from 'react';
|
||||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import {MatrixClientPeg} from '../../../MatrixClientPeg';
|
||||
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 { ActionPayload } from "../../../dispatcher/payloads";
|
||||
import Stickerpicker from './Stickerpicker';
|
||||
import { makeRoomPermalink } from '../../../utils/permalinks/Permalinks';
|
||||
import { makeRoomPermalink, RoomPermalinkCreator } from '../../../utils/permalinks/Permalinks';
|
||||
import ContentMessages from '../../../ContentMessages';
|
||||
import E2EIcon from './E2EIcon';
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
@ -35,19 +38,26 @@ import VoiceRecordComposerTile from "./VoiceRecordComposerTile";
|
||||
import {VoiceRecordingStore} from "../../../stores/VoiceRecordingStore";
|
||||
import {RecordingState} from "../../../voice/VoiceRecording";
|
||||
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');
|
||||
return <div className="mx_MessageComposer_avatar">
|
||||
<MemberStatusMessageAvatar member={props.me} width={24} height={24} />
|
||||
</div>;
|
||||
}
|
||||
|
||||
ComposerAvatar.propTypes = {
|
||||
me: PropTypes.object.isRequired,
|
||||
};
|
||||
interface ISendButtonProps {
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
function SendButton(props) {
|
||||
function SendButton(props: ISendButtonProps) {
|
||||
return (
|
||||
<AccessibleTooltipButton
|
||||
className="mx_MessageComposer_sendMessage"
|
||||
@ -57,10 +67,6 @@ function SendButton(props) {
|
||||
);
|
||||
}
|
||||
|
||||
SendButton.propTypes = {
|
||||
onClick: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
const EmojiButton = ({addEmoji}) => {
|
||||
const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu();
|
||||
|
||||
@ -68,7 +74,7 @@ const EmojiButton = ({addEmoji}) => {
|
||||
if (menuDisplayed) {
|
||||
const buttonRect = button.current.getBoundingClientRect();
|
||||
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} />
|
||||
</ContextMenu>;
|
||||
}
|
||||
@ -98,39 +104,39 @@ const EmojiButton = ({addEmoji}) => {
|
||||
</React.Fragment>;
|
||||
};
|
||||
|
||||
class UploadButton extends React.Component {
|
||||
static propTypes = {
|
||||
roomId: PropTypes.string.isRequired,
|
||||
}
|
||||
interface IUploadButtonProps {
|
||||
roomId: string;
|
||||
}
|
||||
|
||||
class UploadButton extends React.Component<IUploadButtonProps> {
|
||||
private uploadInput = React.createRef<HTMLInputElement>();
|
||||
private dispatcherRef: string;
|
||||
|
||||
constructor(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() {
|
||||
dis.unregister(this._dispatcherRef);
|
||||
dis.unregister(this.dispatcherRef);
|
||||
}
|
||||
|
||||
onAction = payload => {
|
||||
private onAction = (payload: ActionPayload) => {
|
||||
if (payload.action === "upload_file") {
|
||||
this.onUploadClick();
|
||||
}
|
||||
};
|
||||
|
||||
onUploadClick(ev) {
|
||||
private onUploadClick = () => {
|
||||
if (MatrixClientPeg.get().isGuest()) {
|
||||
dis.dispatch({action: 'require_registration'});
|
||||
return;
|
||||
}
|
||||
this._uploadInput.current.click();
|
||||
this.uploadInput.current.click();
|
||||
}
|
||||
|
||||
onUploadFileInputChange(ev) {
|
||||
private onUploadFileInputChange = (ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (ev.target.files.length === 0) return;
|
||||
|
||||
// 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')}
|
||||
>
|
||||
<input
|
||||
ref={this._uploadInput}
|
||||
ref={this.uploadInput}
|
||||
type="file"
|
||||
style={uploadInputStyle}
|
||||
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")
|
||||
export default class MessageComposer extends React.Component {
|
||||
export default class MessageComposer extends React.Component<IProps, IState> {
|
||||
private dispatcherRef: string;
|
||||
private messageComposerInput: SendMessageComposer;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.onInputStateChanged = this.onInputStateChanged.bind(this);
|
||||
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;
|
||||
VoiceRecordingStore.instance.on(UPDATE_EVENT, this.onVoiceStoreUpdate);
|
||||
|
||||
this.state = {
|
||||
tombstone: this._getRoomTombstone(),
|
||||
tombstone: this.getRoomTombstone(),
|
||||
canSendMessages: this.props.room.maySendMessage(),
|
||||
isComposerEmpty: true,
|
||||
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') {
|
||||
// add a timeout for the reply preview to be rendered, so
|
||||
// that the ScrollPanel listening to the resizeNotifier can
|
||||
@ -203,13 +230,7 @@ export default class MessageComposer extends React.Component {
|
||||
}
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
this.dispatcherRef = dis.register(this.onAction);
|
||||
MatrixClientPeg.get().on("RoomState.events", this._onRoomStateEvents);
|
||||
this._waitForOwnMember();
|
||||
}
|
||||
|
||||
_waitForOwnMember() {
|
||||
private waitForOwnMember() {
|
||||
// if we have the member already, do that
|
||||
const me = this.props.room.getMember(MatrixClientPeg.get().getUserId());
|
||||
if (me) {
|
||||
@ -227,34 +248,28 @@ export default class MessageComposer extends React.Component {
|
||||
|
||||
componentWillUnmount() {
|
||||
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);
|
||||
}
|
||||
|
||||
_onRoomStateEvents(ev, state) {
|
||||
private onRoomStateEvents = (ev, state) => {
|
||||
if (ev.getRoomId() !== this.props.room.roomId) return;
|
||||
|
||||
if (ev.getType() === 'm.room.tombstone') {
|
||||
this.setState({tombstone: this._getRoomTombstone()});
|
||||
this.setState({tombstone: this.getRoomTombstone()});
|
||||
}
|
||||
if (ev.getType() === 'm.room.power_levels') {
|
||||
this.setState({canSendMessages: this.props.room.maySendMessage()});
|
||||
}
|
||||
}
|
||||
|
||||
_getRoomTombstone() {
|
||||
private getRoomTombstone() {
|
||||
return this.props.room.currentState.getStateEvents('m.room.tombstone', '');
|
||||
}
|
||||
|
||||
onInputStateChanged(inputState) {
|
||||
// Merge the new input state with old to support partial updates
|
||||
inputState = Object.assign({}, this.state.inputState, inputState);
|
||||
this.setState({inputState});
|
||||
}
|
||||
|
||||
_onTombstoneClick(ev) {
|
||||
private onTombstoneClick = (ev) => {
|
||||
ev.preventDefault();
|
||||
|
||||
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.e2eStatus) {
|
||||
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;
|
||||
this.setState({haveRecording: !!recording});
|
||||
if (recording) {
|
||||
@ -386,7 +401,7 @@ export default class MessageComposer extends React.Component {
|
||||
const continuesLink = replacementRoomId ? (
|
||||
<a href={makeRoomPermalink(replacementRoomId)}
|
||||
className="mx_MessageComposer_roomReplaced_link"
|
||||
onClick={this._onTombstoneClick}
|
||||
onClick={this.onTombstoneClick}
|
||||
>
|
||||
{_t("The conversation continues here.")}
|
||||
</a>
|
||||
@ -394,7 +409,9 @@ export default class MessageComposer extends React.Component {
|
||||
|
||||
controls.push(<div className="mx_MessageComposer_replaced_wrapper" key="room_replaced">
|
||||
<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">
|
||||
{_t("This room has been replaced and is no longer active.")}
|
||||
</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,
|
||||
};
|
@ -30,7 +30,7 @@ interface IProps {
|
||||
* 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.
|
||||
*/
|
||||
forceCount: boolean;
|
||||
forceCount?: boolean;
|
||||
|
||||
/**
|
||||
* The room ID, if any, the badge represents.
|
||||
|
@ -1,7 +1,5 @@
|
||||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2017, 2018 Vector Creations Ltd
|
||||
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2015-2018, 2020, 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.
|
||||
@ -37,7 +35,6 @@ import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||
import GroupAvatar from "../avatars/GroupAvatar";
|
||||
import ExtraTile from "./ExtraTile";
|
||||
import { StaticNotificationState } from "../../../stores/notifications/StaticNotificationState";
|
||||
import { NotificationColor } from "../../../stores/notifications/NotificationColor";
|
||||
import { Action } from "../../../dispatcher/actions";
|
||||
import { ViewRoomDeltaPayload } from "../../../dispatcher/payloads/ViewRoomDeltaPayload";
|
||||
import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore";
|
||||
@ -492,7 +489,7 @@ export default class RoomList extends React.PureComponent<IProps, IState> {
|
||||
isSelected={false}
|
||||
displayName={g.name}
|
||||
avatar={avatar}
|
||||
notificationState={StaticNotificationState.forSymbol("!", NotificationColor.Red)}
|
||||
notificationState={StaticNotificationState.RED_EXCLAMATION}
|
||||
onClick={openGroup}
|
||||
key={`temporaryGroupTile_${g.groupId}`}
|
||||
/>
|
||||
|
@ -763,7 +763,9 @@ export default class RoomSublist extends React.Component<IProps, IState> {
|
||||
'mx_RoomSublist': true,
|
||||
'mx_RoomSublist_hasMenuOpen': !!this.state.contextMenuPosition,
|
||||
'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;
|
||||
|
@ -1,8 +1,6 @@
|
||||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2017 New Vector Ltd
|
||||
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");
|
||||
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 { Room } from "matrix-js-sdk/src/models/room";
|
||||
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||
import classNames from "classnames";
|
||||
import { RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex";
|
||||
import AccessibleButton, { ButtonEvent } from "../../views/elements/AccessibleButton";
|
||||
@ -51,7 +50,9 @@ import IconizedContextMenu, {
|
||||
IconizedContextMenuRadio,
|
||||
} from "../context_menus/IconizedContextMenu";
|
||||
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 {
|
||||
room: Room;
|
||||
@ -67,6 +68,7 @@ interface IState {
|
||||
notificationsMenuPosition: PartialDOMRect;
|
||||
generalMenuPosition: PartialDOMRect;
|
||||
messagePreview?: string;
|
||||
hasUnsentEvents: boolean;
|
||||
}
|
||||
|
||||
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,
|
||||
notificationsMenuPosition: null,
|
||||
generalMenuPosition: null,
|
||||
hasUnsentEvents: this.countUnsentEvents() > 0,
|
||||
|
||||
// generatePreview() will return nothing if the user has previews disabled
|
||||
messagePreview: this.generatePreview(),
|
||||
@ -101,6 +104,10 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
|
||||
this.roomProps = EchoChamber.forRoom(this.props.room);
|
||||
}
|
||||
|
||||
private countUnsentEvents(): number {
|
||||
return getUnsentMessages(this.props.room).length;
|
||||
}
|
||||
|
||||
private onRoomNameUpdate = (room) => {
|
||||
this.forceUpdate();
|
||||
}
|
||||
@ -109,6 +116,11 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
|
||||
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) => {
|
||||
if (property === CachedRoomKey.NotificationVolume) this.onNotificationUpdate();
|
||||
// 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),
|
||||
this.onCommunityUpdate,
|
||||
);
|
||||
MatrixClientPeg.get().on("Room.localEchoUpdated", this.onLocalEchoUpdated);
|
||||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
@ -191,6 +204,7 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
|
||||
CommunityPrototypeStore.getUpdateEventName(this.props.room.roomId),
|
||||
this.onCommunityUpdate,
|
||||
);
|
||||
MatrixClientPeg.get()?.removeListener("Room.localEchoUpdated", this.onLocalEchoUpdated);
|
||||
}
|
||||
|
||||
private onAction = (payload: ActionPayload) => {
|
||||
@ -554,17 +568,30 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
|
||||
/>;
|
||||
|
||||
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
|
||||
badge = (
|
||||
<div className="mx_RoomTile_badgeContainer" aria-hidden="true">
|
||||
<NotificationBadge
|
||||
notification={this.notificationState}
|
||||
forceCount={false}
|
||||
roomId={this.props.room.roomId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
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 = (
|
||||
<div className="mx_RoomTile_badgeContainer" aria-hidden="true">
|
||||
<NotificationBadge
|
||||
notification={this.notificationState}
|
||||
forceCount={false}
|
||||
roomId={this.props.room.roomId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let messagePreview = null;
|
||||
|
@ -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");
|
||||
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 PropTypes from 'prop-types';
|
||||
import {MatrixClientPeg} from "../../../MatrixClientPeg";
|
||||
import {MatrixEvent} from "matrix-js-sdk/src/models/event";
|
||||
import {Room} from "matrix-js-sdk/src/models/room";
|
||||
import {_t} from "../../../languageHandler";
|
||||
import dis from "../../../dispatcher/dispatcher";
|
||||
import * as sdk from "../../../index";
|
||||
@ -27,11 +27,22 @@ import RoomAvatar from "../avatars/RoomAvatar";
|
||||
import RoomName from "../elements/RoomName";
|
||||
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")
|
||||
export default class ThirdPartyMemberInfo extends React.Component {
|
||||
static propTypes = {
|
||||
event: PropTypes.instanceOf(MatrixEvent).isRequired,
|
||||
};
|
||||
export default class ThirdPartyMemberInfo extends React.Component<IProps, IState> {
|
||||
private room: Room;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
@ -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");
|
||||
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 SeshatResetDialog from '../dialogs/SeshatResetDialog';
|
||||
|
||||
interface IState {
|
||||
enabling: boolean;
|
||||
eventIndexSize: number;
|
||||
roomCount: number;
|
||||
eventIndexingEnabled: boolean;
|
||||
}
|
||||
|
||||
@replaceableComponent("views.settings.EventIndexPanel")
|
||||
export default class EventIndexPanel extends React.Component {
|
||||
constructor() {
|
||||
super();
|
||||
export default class EventIndexPanel extends React.Component<{}, IState> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
enabling: false,
|
||||
@ -68,7 +75,7 @@ export default class EventIndexPanel extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
async componentDidMount(): void {
|
||||
componentDidMount(): void {
|
||||
this.updateState();
|
||||
}
|
||||
|
||||
@ -102,8 +109,10 @@ export default class EventIndexPanel extends React.Component {
|
||||
});
|
||||
}
|
||||
|
||||
_onManage = async () => {
|
||||
private onManage = async () => {
|
||||
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'),
|
||||
{
|
||||
onFinished: () => {},
|
||||
@ -111,7 +120,7 @@ export default class EventIndexPanel extends React.Component {
|
||||
);
|
||||
}
|
||||
|
||||
_onEnable = async () => {
|
||||
private onEnable = async () => {
|
||||
this.setState({
|
||||
enabling: true,
|
||||
});
|
||||
@ -123,14 +132,13 @@ export default class EventIndexPanel extends React.Component {
|
||||
await this.updateState();
|
||||
}
|
||||
|
||||
_confirmEventStoreReset = () => {
|
||||
const self = this;
|
||||
private confirmEventStoreReset = () => {
|
||||
const { close } = Modal.createDialog(SeshatResetDialog, {
|
||||
onFinished: async (success) => {
|
||||
if (success) {
|
||||
await SettingsStore.setValue('enableEventIndexing', null, SettingLevel.DEVICE, false);
|
||||
await EventIndexPeg.deleteEventIndex();
|
||||
await self._onEnable();
|
||||
await this.onEnable();
|
||||
close();
|
||||
}
|
||||
},
|
||||
@ -145,20 +153,19 @@ export default class EventIndexPanel extends React.Component {
|
||||
if (EventIndexPeg.get() !== null) {
|
||||
eventIndexingSettings = (
|
||||
<div>
|
||||
<div className='mx_SettingsTab_subsectionText'>
|
||||
{_t("Securely cache encrypted messages locally for them " +
|
||||
"to appear in search results, using %(size)s to store messages from %(rooms)s rooms.",
|
||||
{
|
||||
size: formatBytes(this.state.eventIndexSize, 0),
|
||||
// This drives the singular / plural string
|
||||
// selection for "room" / "rooms" only.
|
||||
count: this.state.roomCount,
|
||||
rooms: formatCountLong(this.state.roomCount),
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
<div className='mx_SettingsTab_subsectionText'>{_t(
|
||||
"Securely cache encrypted messages locally for them " +
|
||||
"to appear in search results, using %(size)s to store messages from %(rooms)s rooms.",
|
||||
{
|
||||
size: formatBytes(this.state.eventIndexSize, 0),
|
||||
// This drives the singular / plural string
|
||||
// selection for "room" / "rooms" only.
|
||||
count: this.state.roomCount,
|
||||
rooms: formatCountLong(this.state.roomCount),
|
||||
},
|
||||
)}</div>
|
||||
<div>
|
||||
<AccessibleButton kind="primary" onClick={this._onManage}>
|
||||
<AccessibleButton kind="primary" onClick={this.onManage}>
|
||||
{_t("Manage")}
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
@ -167,13 +174,13 @@ export default class EventIndexPanel extends React.Component {
|
||||
} else if (!this.state.eventIndexingEnabled && EventIndexPeg.supportIsInstalled()) {
|
||||
eventIndexingSettings = (
|
||||
<div>
|
||||
<div className='mx_SettingsTab_subsectionText'>
|
||||
{_t( "Securely cache encrypted messages locally for them to " +
|
||||
"appear in search results.")}
|
||||
</div>
|
||||
<div className='mx_SettingsTab_subsectionText'>{_t(
|
||||
"Securely cache encrypted messages locally for them to " +
|
||||
"appear in search results.",
|
||||
)}</div>
|
||||
<div>
|
||||
<AccessibleButton kind="primary" disabled={this.state.enabling}
|
||||
onClick={this._onEnable}>
|
||||
onClick={this.onEnable}>
|
||||
{_t("Enable")}
|
||||
</AccessibleButton>
|
||||
{this.state.enabling ? <InlineSpinner /> : <div />}
|
||||
@ -188,40 +195,36 @@ export default class EventIndexPanel extends React.Component {
|
||||
);
|
||||
|
||||
eventIndexingSettings = (
|
||||
<div className='mx_SettingsTab_subsectionText'>
|
||||
<div className='mx_SettingsTab_subsectionText'>{_t(
|
||||
"%(brand)s is missing some components required for securely " +
|
||||
"caching encrypted messages locally. If you'd like to " +
|
||||
"experiment with this feature, build a custom %(brand)s Desktop " +
|
||||
"with <nativeLink>search components added</nativeLink>.",
|
||||
{
|
||||
_t( "%(brand)s is missing some components required for securely " +
|
||||
"caching encrypted messages locally. If you'd like to " +
|
||||
"experiment with this feature, build a custom %(brand)s Desktop " +
|
||||
"with <nativeLink>search components added</nativeLink>.",
|
||||
{
|
||||
brand,
|
||||
},
|
||||
{
|
||||
'nativeLink': (sub) => <a href={nativeLink} target="_blank"
|
||||
rel="noreferrer noopener">{sub}</a>,
|
||||
},
|
||||
)
|
||||
}
|
||||
</div>
|
||||
brand,
|
||||
},
|
||||
{
|
||||
nativeLink: sub => <a href={nativeLink}
|
||||
target="_blank" rel="noreferrer noopener"
|
||||
>{sub}</a>,
|
||||
},
|
||||
)}</div>
|
||||
);
|
||||
} else if (!EventIndexPeg.platformHasSupport()) {
|
||||
eventIndexingSettings = (
|
||||
<div className='mx_SettingsTab_subsectionText'>
|
||||
<div className='mx_SettingsTab_subsectionText'>{_t(
|
||||
"%(brand)s can't securely cache encrypted messages locally " +
|
||||
"while running in a web browser. Use <desktopLink>%(brand)s Desktop</desktopLink> " +
|
||||
"for encrypted messages to appear in search results.",
|
||||
{
|
||||
_t( "%(brand)s can't securely cache encrypted messages locally " +
|
||||
"while running in a web browser. Use <desktopLink>%(brand)s Desktop</desktopLink> " +
|
||||
"for encrypted messages to appear in search results.",
|
||||
{
|
||||
brand,
|
||||
},
|
||||
{
|
||||
'desktopLink': (sub) => <a href="https://element.io/get-started"
|
||||
target="_blank" rel="noreferrer noopener">{sub}</a>,
|
||||
},
|
||||
)
|
||||
}
|
||||
</div>
|
||||
brand,
|
||||
},
|
||||
{
|
||||
desktopLink: sub => <a href="https://element.io/get-started"
|
||||
target="_blank" rel="noreferrer noopener"
|
||||
>{sub}</a>,
|
||||
},
|
||||
)}</div>
|
||||
);
|
||||
} else {
|
||||
eventIndexingSettings = (
|
||||
@ -233,19 +236,18 @@ export default class EventIndexPanel extends React.Component {
|
||||
}
|
||||
</p>
|
||||
{EventIndexPeg.error && (
|
||||
<details>
|
||||
<summary>{_t("Advanced")}</summary>
|
||||
<code>
|
||||
{EventIndexPeg.error.message}
|
||||
</code>
|
||||
<p>
|
||||
<AccessibleButton key="delete" kind="danger" onClick={this._confirmEventStoreReset}>
|
||||
{_t("Reset")}
|
||||
</AccessibleButton>
|
||||
</p>
|
||||
</details>
|
||||
<details>
|
||||
<summary>{_t("Advanced")}</summary>
|
||||
<code>
|
||||
{EventIndexPeg.error.message}
|
||||
</code>
|
||||
<p>
|
||||
<AccessibleButton key="delete" kind="danger" onClick={this.confirmEventStoreReset}>
|
||||
{_t("Reset")}
|
||||
</AccessibleButton>
|
||||
</p>
|
||||
</details>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
@ -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");
|
||||
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 React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {_t} from "../../../languageHandler";
|
||||
import * as sdk from '../../../index';
|
||||
import {MatrixClientPeg} from "../../../MatrixClientPeg";
|
||||
@ -28,6 +27,7 @@ import {abbreviateUrl, unabbreviateUrl} from "../../../utils/UrlUtils";
|
||||
import { getDefaultIdentityServerUrl, doesIdentityServerHaveTerms } from '../../../utils/IdentityServerUtils';
|
||||
import {timeout} from "../../../utils/promise";
|
||||
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.
|
||||
const REACHABILITY_TIMEOUT = 10000; // ms
|
||||
@ -59,16 +59,28 @@ async function checkIdentityServerUrl(u) {
|
||||
}
|
||||
}
|
||||
|
||||
@replaceableComponent("views.settings.SetIdServer")
|
||||
export default class SetIdServer extends React.Component {
|
||||
static propTypes = {
|
||||
// Whether or not the ID server is missing terms. This affects the text
|
||||
// shown to the user.
|
||||
missingTerms: PropTypes.bool,
|
||||
};
|
||||
interface IProps {
|
||||
// Whether or not the ID server is missing terms. This affects the text
|
||||
// shown to the user.
|
||||
missingTerms: boolean;
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
interface IState {
|
||||
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 = '';
|
||||
if (!MatrixClientPeg.get().getIdentityServerUrl() && getDefaultIdentityServerUrl()) {
|
||||
@ -96,7 +108,7 @@ export default class SetIdServer extends React.Component {
|
||||
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
|
||||
// when changing their identity server on another device.
|
||||
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;
|
||||
|
||||
this.setState({idServer: u});
|
||||
};
|
||||
|
||||
_getTooltip = () => {
|
||||
private getTooltip = () => {
|
||||
if (this.state.checking) {
|
||||
const InlineSpinner = sdk.getComponent('views.elements.InlineSpinner');
|
||||
return <div>
|
||||
@ -126,11 +138,11 @@ export default class SetIdServer extends React.Component {
|
||||
}
|
||||
};
|
||||
|
||||
_idServerChangeEnabled = () => {
|
||||
private idServerChangeEnabled = () => {
|
||||
return !!this.state.idServer && !this.state.busy;
|
||||
};
|
||||
|
||||
_saveIdServer = (fullUrl) => {
|
||||
private saveIdServer = (fullUrl) => {
|
||||
// Account data change will update localstorage, client, etc through dispatcher
|
||||
MatrixClientPeg.get().setAccountData("m.identity_server", {
|
||||
base_url: fullUrl,
|
||||
@ -143,7 +155,7 @@ export default class SetIdServer extends React.Component {
|
||||
});
|
||||
};
|
||||
|
||||
_checkIdServer = async (e) => {
|
||||
private checkIdServer = async (e) => {
|
||||
e.preventDefault();
|
||||
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.
|
||||
const hasTerms = await doesIdentityServerHaveTerms(fullUrl);
|
||||
if (!hasTerms) {
|
||||
const [confirmed] = await this._showNoTermsWarning(fullUrl);
|
||||
const [confirmed] = await this.showNoTermsWarning(fullUrl);
|
||||
save = confirmed;
|
||||
}
|
||||
|
||||
// Show a general warning, possibly with details about any bound
|
||||
// 3PIDs that would be left behind.
|
||||
if (save && currentClientIdServer && fullUrl !== currentClientIdServer) {
|
||||
const [confirmed] = await this._showServerChangeWarning({
|
||||
const [confirmed] = await this.showServerChangeWarning({
|
||||
title: _t("Change identity server"),
|
||||
unboundMessage: _t(
|
||||
"Disconnect from the identity server <current /> and " +
|
||||
@ -189,7 +201,7 @@ export default class SetIdServer extends React.Component {
|
||||
}
|
||||
|
||||
if (save) {
|
||||
this._saveIdServer(fullUrl);
|
||||
this.saveIdServer(fullUrl);
|
||||
}
|
||||
} catch (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 { finished } = Modal.createTrackedDialog('No Terms Warning', '', QuestionDialog, {
|
||||
title: _t("Identity server has no terms of service"),
|
||||
@ -223,10 +235,10 @@ export default class SetIdServer extends React.Component {
|
||||
return finished;
|
||||
}
|
||||
|
||||
_onDisconnectClicked = async () => {
|
||||
private onDisconnectClicked = async () => {
|
||||
this.setState({disconnectBusy: true});
|
||||
try {
|
||||
const [confirmed] = await this._showServerChangeWarning({
|
||||
const [confirmed] = await this.showServerChangeWarning({
|
||||
title: _t("Disconnect identity server"),
|
||||
unboundMessage: _t(
|
||||
"Disconnect from the identity server <idserver />?", {},
|
||||
@ -235,14 +247,14 @@ export default class SetIdServer extends React.Component {
|
||||
button: _t("Disconnect"),
|
||||
});
|
||||
if (confirmed) {
|
||||
this._disconnectIdServer();
|
||||
this.disconnectIdServer();
|
||||
}
|
||||
} finally {
|
||||
this.setState({disconnectBusy: false});
|
||||
}
|
||||
};
|
||||
|
||||
async _showServerChangeWarning({ title, unboundMessage, button }) {
|
||||
private async showServerChangeWarning({ title, unboundMessage, button }) {
|
||||
const { currentClientIdServer } = this.state;
|
||||
|
||||
let threepids = [];
|
||||
@ -318,7 +330,7 @@ export default class SetIdServer extends React.Component {
|
||||
return finished;
|
||||
}
|
||||
|
||||
_disconnectIdServer = () => {
|
||||
private disconnectIdServer = () => {
|
||||
// Account data change will update localstorage, client, etc through dispatcher
|
||||
MatrixClientPeg.get().setAccountData("m.identity_server", {
|
||||
base_url: null, // clear
|
||||
@ -371,7 +383,7 @@ export default class SetIdServer extends React.Component {
|
||||
|
||||
let discoSection;
|
||||
if (idServerUrl) {
|
||||
let discoButtonContent = _t("Disconnect");
|
||||
let discoButtonContent: React.ReactNode = _t("Disconnect");
|
||||
let discoBodyText = _t(
|
||||
"Disconnecting from your identity server will mean you " +
|
||||
"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>
|
||||
<span className="mx_SettingsTab_subsectionText">{discoBodyText}</span>
|
||||
<AccessibleButton onClick={this._onDisconnectClicked} kind="danger_sm">
|
||||
<AccessibleButton onClick={this.onDisconnectClicked} kind="danger_sm">
|
||||
{discoButtonContent}
|
||||
</AccessibleButton>
|
||||
</div>;
|
||||
}
|
||||
|
||||
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">
|
||||
{sectionTitle}
|
||||
</span>
|
||||
@ -411,15 +423,15 @@ export default class SetIdServer extends React.Component {
|
||||
autoComplete="off"
|
||||
placeholder={this.state.defaultIdServer}
|
||||
value={this.state.idServer}
|
||||
onChange={this._onIdentityServerChanged}
|
||||
tooltipContent={this._getTooltip()}
|
||||
onChange={this.onIdentityServerChanged}
|
||||
tooltipContent={this.getTooltip()}
|
||||
tooltipClassName="mx_SetIdServer_tooltip"
|
||||
disabled={this.state.busy}
|
||||
forceValidity={this.state.error ? false : null}
|
||||
/>
|
||||
<AccessibleButton type="submit" kind="primary_sm"
|
||||
onClick={this._checkIdServer}
|
||||
disabled={!this._idServerChangeEnabled()}
|
||||
onClick={this.checkIdServer}
|
||||
disabled={!this.idServerChangeEnabled()}
|
||||
>{_t("Change")}</AccessibleButton>
|
||||
{discoSection}
|
||||
</form>
|
@ -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");
|
||||
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 PropTypes from 'prop-types';
|
||||
import {_t, _td} from "../../../../../languageHandler";
|
||||
import {MatrixClientPeg} from "../../../../../MatrixClientPeg";
|
||||
import * as sdk from "../../../../..";
|
||||
@ -23,6 +22,9 @@ import AccessibleButton from "../../../elements/AccessibleButton";
|
||||
import Modal from "../../../../../Modal";
|
||||
import {replaceableComponent} from "../../../../../utils/replaceableComponent";
|
||||
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 = {
|
||||
// These will be translated for us later.
|
||||
@ -63,15 +65,15 @@ function parseIntWithDefault(val, def) {
|
||||
return isNaN(res) ? def : res;
|
||||
}
|
||||
|
||||
export class BannedUser extends React.Component {
|
||||
static propTypes = {
|
||||
canUnban: PropTypes.bool,
|
||||
member: PropTypes.object.isRequired, // js-sdk RoomMember
|
||||
by: PropTypes.string.isRequired,
|
||||
reason: PropTypes.string,
|
||||
};
|
||||
interface IBannedUserProps {
|
||||
canUnban?: boolean;
|
||||
member: RoomMember;
|
||||
by: string;
|
||||
reason?: 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) => {
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
console.error("Failed to unban: " + err);
|
||||
@ -87,8 +89,10 @@ export class BannedUser extends React.Component {
|
||||
|
||||
if (this.props.canUnban) {
|
||||
unbanButton = (
|
||||
<AccessibleButton kind='danger_sm' onClick={this._onUnbanClick}
|
||||
className='mx_RolesRoomSettingsTab_unbanBtn'>
|
||||
<AccessibleButton className='mx_RolesRoomSettingsTab_unbanBtn'
|
||||
kind='danger_sm'
|
||||
onClick={this.onUnbanClick}
|
||||
>
|
||||
{ _t('Unban') }
|
||||
</AccessibleButton>
|
||||
);
|
||||
@ -107,29 +111,29 @@ export class BannedUser extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
@replaceableComponent("views.settings.tabs.room.RolesRoomSettingsTab")
|
||||
export default class RolesRoomSettingsTab extends React.Component {
|
||||
static propTypes = {
|
||||
roomId: PropTypes.string.isRequired,
|
||||
};
|
||||
interface IProps {
|
||||
roomId: string;
|
||||
}
|
||||
|
||||
componentDidMount(): void {
|
||||
MatrixClientPeg.get().on("RoomState.members", this._onRoomMembership);
|
||||
@replaceableComponent("views.settings.tabs.room.RolesRoomSettingsTab")
|
||||
export default class RolesRoomSettingsTab extends React.Component<IProps> {
|
||||
componentDidMount() {
|
||||
MatrixClientPeg.get().on("RoomState.members", this.onRoomMembership);
|
||||
}
|
||||
|
||||
componentWillUnmount(): void {
|
||||
componentWillUnmount() {
|
||||
const client = MatrixClientPeg.get();
|
||||
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;
|
||||
this.forceUpdate();
|
||||
};
|
||||
|
||||
_populateDefaultPlEvents(eventsSection, stateLevel, eventsLevel) {
|
||||
private populateDefaultPlEvents(eventsSection: Record<string, number>, stateLevel: number, eventsLevel: number) {
|
||||
for (const desiredEvent of Object.keys(plEventsToShow)) {
|
||||
if (!(desiredEvent in eventsSection)) {
|
||||
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 room = client.getRoom(this.props.roomId);
|
||||
const plEvent = room.currentState.getStateEvents('m.room.power_levels', '');
|
||||
@ -148,7 +152,7 @@ export default class RolesRoomSettingsTab extends React.Component {
|
||||
|
||||
const eventsLevelPrefix = "event_levels_";
|
||||
|
||||
value = parseInt(value);
|
||||
const value = parseInt(inputValue);
|
||||
|
||||
if (powerLevelKey.startsWith(eventsLevelPrefix)) {
|
||||
// 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 room = client.getRoom(this.props.roomId);
|
||||
const plEvent = room.currentState.getStateEvents('m.room.power_levels', '');
|
||||
@ -266,7 +270,7 @@ export default class RolesRoomSettingsTab extends React.Component {
|
||||
currentUserLevel = defaultUserLevel;
|
||||
}
|
||||
|
||||
this._populateDefaultPlEvents(
|
||||
this.populateDefaultPlEvents(
|
||||
eventsLevels,
|
||||
parseIntWithDefault(plContent.state_default, powerLevelDescriptors.state_default.defaultValue),
|
||||
parseIntWithDefault(plContent.events_default, powerLevelDescriptors.events_default.defaultValue),
|
||||
@ -288,7 +292,7 @@ export default class RolesRoomSettingsTab extends React.Component {
|
||||
label={user}
|
||||
key={user}
|
||||
powerLevelKey={user} // Will be sent as the second parameter to `onChange`
|
||||
onChange={this._onUserPowerLevelChanged}
|
||||
onChange={this.onUserPowerLevelChanged}
|
||||
/>,
|
||||
);
|
||||
} else if (userLevels[user] < defaultUserLevel) { // muted
|
||||
@ -299,7 +303,7 @@ export default class RolesRoomSettingsTab extends React.Component {
|
||||
label={user}
|
||||
key={user}
|
||||
powerLevelKey={user} // Will be sent as the second parameter to `onChange`
|
||||
onChange={this._onUserPowerLevelChanged}
|
||||
onChange={this.onUserPowerLevelChanged}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
@ -345,8 +349,9 @@ export default class RolesRoomSettingsTab extends React.Component {
|
||||
if (sender) bannedBy = sender.name;
|
||||
return (
|
||||
<BannedUser key={member.userId} canUnban={canBanUsers}
|
||||
member={member} reason={banEvent.reason}
|
||||
by={bannedBy} />
|
||||
member={member} reason={banEvent.reason}
|
||||
by={bannedBy}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
@ -373,7 +378,7 @@ export default class RolesRoomSettingsTab extends React.Component {
|
||||
usersDefault={defaultUserLevel}
|
||||
disabled={!canChangeLevels || currentUserLevel < value}
|
||||
powerLevelKey={key} // Will be sent as the second parameter to `onChange`
|
||||
onChange={this._onPowerLevelsChanged}
|
||||
onChange={this.onPowerLevelsChanged}
|
||||
/>
|
||||
</div>;
|
||||
});
|
||||
@ -398,7 +403,7 @@ export default class RolesRoomSettingsTab extends React.Component {
|
||||
usersDefault={defaultUserLevel}
|
||||
disabled={!canChangeLevels || currentUserLevel < eventsLevels[eventType]}
|
||||
powerLevelKey={"event_levels_" + eventType}
|
||||
onChange={this._onPowerLevelsChanged}
|
||||
onChange={this.onPowerLevelsChanged}
|
||||
/>
|
||||
</div>
|
||||
);
|
@ -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");
|
||||
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 PropTypes from 'prop-types';
|
||||
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||
import {_t} from "../../../../../languageHandler";
|
||||
import {MatrixClientPeg} from "../../../../../MatrixClientPeg";
|
||||
import * as sdk from "../../../../..";
|
||||
@ -26,64 +26,92 @@ import StyledRadioGroup from '../../../elements/StyledRadioGroup';
|
||||
import {SettingLevel} from "../../../../../settings/SettingLevel";
|
||||
import SettingsStore from "../../../../../settings/SettingsStore";
|
||||
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")
|
||||
export default class SecurityRoomSettingsTab extends React.Component {
|
||||
static propTypes = {
|
||||
roomId: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
export default class SecurityRoomSettingsTab extends React.Component<IProps, IState> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
joinRule: "invite",
|
||||
guestAccess: "can_join",
|
||||
history: "shared",
|
||||
joinRule: JoinRule.Invite,
|
||||
guestAccess: GuestAccess.CanJoin,
|
||||
history: HistoryVisibility.Shared,
|
||||
hasAliases: false,
|
||||
encrypted: false,
|
||||
};
|
||||
}
|
||||
|
||||
// TODO: [REACT-WARNING] Move this to constructor
|
||||
async UNSAFE_componentWillMount(): void { // eslint-disable-line camelcase
|
||||
MatrixClientPeg.get().on("RoomState.events", this._onStateEvent);
|
||||
async UNSAFE_componentWillMount() { // eslint-disable-line camelcase
|
||||
MatrixClientPeg.get().on("RoomState.events", this.onStateEvent);
|
||||
|
||||
const room = MatrixClientPeg.get().getRoom(this.props.roomId);
|
||||
const state = room.currentState;
|
||||
|
||||
const joinRule = this._pullContentPropertyFromEvent(
|
||||
const joinRule: JoinRule = this.pullContentPropertyFromEvent(
|
||||
state.getStateEvents("m.room.join_rules", ""),
|
||||
'join_rule',
|
||||
'invite',
|
||||
JoinRule.Invite,
|
||||
);
|
||||
const guestAccess = this._pullContentPropertyFromEvent(
|
||||
const guestAccess: GuestAccess = this.pullContentPropertyFromEvent(
|
||||
state.getStateEvents("m.room.guest_access", ""),
|
||||
'guest_access',
|
||||
'forbidden',
|
||||
GuestAccess.Forbidden,
|
||||
);
|
||||
const history = this._pullContentPropertyFromEvent(
|
||||
const history: HistoryVisibility = this.pullContentPropertyFromEvent(
|
||||
state.getStateEvents("m.room.history_visibility", ""),
|
||||
'history_visibility',
|
||||
'shared',
|
||||
HistoryVisibility.Shared,
|
||||
);
|
||||
const encrypted = MatrixClientPeg.get().isRoomEncrypted(this.props.roomId);
|
||||
this.setState({joinRule, guestAccess, history, encrypted});
|
||||
const hasAliases = await this._hasAliases();
|
||||
const hasAliases = await this.hasAliases();
|
||||
this.setState({hasAliases});
|
||||
}
|
||||
|
||||
_pullContentPropertyFromEvent(event, key, defaultValue) {
|
||||
private pullContentPropertyFromEvent<T>(event: MatrixEvent, key: string, defaultValue: T): T {
|
||||
if (!event || !event.getContent()) return defaultValue;
|
||||
return event.getContent()[key] || defaultValue;
|
||||
}
|
||||
|
||||
componentWillUnmount(): void {
|
||||
MatrixClientPeg.get().removeListener("RoomState.events", this._onStateEvent);
|
||||
componentWillUnmount() {
|
||||
MatrixClientPeg.get().removeListener("RoomState.events", this.onStateEvent);
|
||||
}
|
||||
|
||||
_onStateEvent = (e) => {
|
||||
private onStateEvent = (e: MatrixEvent) => {
|
||||
const refreshWhenTypes = [
|
||||
'm.room.join_rules',
|
||||
'm.room.guest_access',
|
||||
@ -93,7 +121,7 @@ export default class SecurityRoomSettingsTab extends React.Component {
|
||||
if (refreshWhenTypes.includes(e.getType())) this.forceUpdate();
|
||||
};
|
||||
|
||||
_onEncryptionChange = (e) => {
|
||||
private onEncryptionChange = (e: React.ChangeEvent) => {
|
||||
Modal.createTrackedDialog('Enable encryption', '', QuestionDialog, {
|
||||
title: _t('Enable encryption?'),
|
||||
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>",
|
||||
{},
|
||||
{
|
||||
'a': (sub) => {
|
||||
return <a rel='noreferrer noopener' target='_blank'
|
||||
href='https://element.io/help#encryption'>{sub}</a>;
|
||||
},
|
||||
a: sub => <a href="https://element.io/help#encryption"
|
||||
rel="noreferrer noopener" target="_blank"
|
||||
>{sub}</a>,
|
||||
},
|
||||
),
|
||||
onFinished: (confirm) => {
|
||||
@ -127,12 +154,12 @@ export default class SecurityRoomSettingsTab extends React.Component {
|
||||
});
|
||||
};
|
||||
|
||||
_fixGuestAccess = (e) => {
|
||||
private fixGuestAccess = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const joinRule = "invite";
|
||||
const guestAccess = "can_join";
|
||||
const joinRule = JoinRule.Invite;
|
||||
const guestAccess = GuestAccess.CanJoin;
|
||||
|
||||
const beforeJoinRule = this.state.joinRule;
|
||||
const beforeGuestAccess = this.state.guestAccess;
|
||||
@ -149,7 +176,7 @@ export default class SecurityRoomSettingsTab extends React.Component {
|
||||
});
|
||||
};
|
||||
|
||||
_onRoomAccessRadioToggle = (roomAccess) => {
|
||||
private onRoomAccessRadioToggle = (roomAccess: string) => {
|
||||
// join_rule
|
||||
// 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
|
||||
// guest or not. In practice, guest_access should probably have
|
||||
// been implemented as part of the join_rules enum.
|
||||
let joinRule = "invite";
|
||||
let guestAccess = "can_join";
|
||||
let joinRule = JoinRule.Invite;
|
||||
let guestAccess = GuestAccess.CanJoin;
|
||||
|
||||
switch (roomAccess) {
|
||||
case "invite_only":
|
||||
// no change - use defaults above
|
||||
break;
|
||||
case "public_no_guests":
|
||||
joinRule = "public";
|
||||
guestAccess = "forbidden";
|
||||
joinRule = JoinRule.Public;
|
||||
guestAccess = GuestAccess.Forbidden;
|
||||
break;
|
||||
case "public_with_guests":
|
||||
joinRule = "public";
|
||||
guestAccess = "can_join";
|
||||
joinRule = JoinRule.Public;
|
||||
guestAccess = GuestAccess.CanJoin;
|
||||
break;
|
||||
}
|
||||
|
||||
@ -195,7 +222,7 @@ export default class SecurityRoomSettingsTab extends React.Component {
|
||||
});
|
||||
};
|
||||
|
||||
_onHistoryRadioToggle = (history) => {
|
||||
private onHistoryRadioToggle = (history: HistoryVisibility) => {
|
||||
const beforeHistory = this.state.history;
|
||||
this.setState({history: history});
|
||||
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);
|
||||
};
|
||||
|
||||
async _hasAliases() {
|
||||
private async hasAliases(): Promise<boolean> {
|
||||
const cli = MatrixClientPeg.get();
|
||||
if (await cli.doesServerSupportUnstableFeature("org.matrix.msc2432")) {
|
||||
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 room = client.getRoom(this.props.roomId);
|
||||
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} />
|
||||
<span>
|
||||
{_t("Guests cannot join this room even if explicitly invited.")}
|
||||
<a href="" onClick={this._fixGuestAccess}>{_t("Click here to fix")}</a>
|
||||
<a href="" onClick={this.fixGuestAccess}>{_t("Click here to fix")}</a>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
@ -265,7 +292,7 @@ export default class SecurityRoomSettingsTab extends React.Component {
|
||||
<StyledRadioGroup
|
||||
name="roomVis"
|
||||
value={joinRule}
|
||||
onChange={this._onRoomAccessRadioToggle}
|
||||
onChange={this.onRoomAccessRadioToggle}
|
||||
definitions={[
|
||||
{
|
||||
value: "invite_only",
|
||||
@ -291,7 +318,7 @@ export default class SecurityRoomSettingsTab extends React.Component {
|
||||
);
|
||||
}
|
||||
|
||||
_renderHistory() {
|
||||
private renderHistory() {
|
||||
const client = MatrixClientPeg.get();
|
||||
const history = this.state.history;
|
||||
const state = client.getRoom(this.props.roomId).currentState;
|
||||
@ -306,25 +333,25 @@ export default class SecurityRoomSettingsTab extends React.Component {
|
||||
<StyledRadioGroup
|
||||
name="historyVis"
|
||||
value={history}
|
||||
onChange={this._onHistoryRadioToggle}
|
||||
onChange={this.onHistoryRadioToggle}
|
||||
definitions={[
|
||||
{
|
||||
value: "world_readable",
|
||||
value: HistoryVisibility.WorldReadable,
|
||||
disabled: !canChangeHistory,
|
||||
label: _t("Anyone"),
|
||||
},
|
||||
{
|
||||
value: "shared",
|
||||
value: HistoryVisibility.Shared,
|
||||
disabled: !canChangeHistory,
|
||||
label: _t('Members only (since the point in time of selecting this option)'),
|
||||
},
|
||||
{
|
||||
value: "invited",
|
||||
value: HistoryVisibility.Invited,
|
||||
disabled: !canChangeHistory,
|
||||
label: _t('Members only (since they were invited)'),
|
||||
},
|
||||
{
|
||||
value: "joined",
|
||||
value: HistoryVisibility.Joined,
|
||||
disabled: !canChangeHistory,
|
||||
label: _t('Members only (since they joined)'),
|
||||
},
|
||||
@ -348,7 +375,7 @@ export default class SecurityRoomSettingsTab extends React.Component {
|
||||
encryptionSettings = <SettingsFlag
|
||||
name="blacklistUnverifiedDevices"
|
||||
level={SettingLevel.ROOM_DEVICE}
|
||||
onChange={this._updateBlacklistDevicesFlag}
|
||||
onChange={this.updateBlacklistDevicesFlag}
|
||||
roomId={this.props.roomId}
|
||||
/>;
|
||||
}
|
||||
@ -356,7 +383,7 @@ export default class SecurityRoomSettingsTab extends React.Component {
|
||||
let historySection = (<>
|
||||
<span className='mx_SettingsTab_subheading'>{_t("Who can read history?")}</span>
|
||||
<div className='mx_SettingsTab_section mx_SettingsTab_subsectionText'>
|
||||
{this._renderHistory()}
|
||||
{this.renderHistory()}
|
||||
</div>
|
||||
</>);
|
||||
if (!SettingsStore.getValue(UIFeature.RoomHistorySettings)) {
|
||||
@ -373,15 +400,16 @@ export default class SecurityRoomSettingsTab extends React.Component {
|
||||
<div className='mx_SettingsTab_subsectionText'>
|
||||
<span>{_t("Once enabled, encryption cannot be disabled.")}</span>
|
||||
</div>
|
||||
<LabelledToggleSwitch value={isEncrypted} onChange={this._onEncryptionChange}
|
||||
label={_t("Encrypted")} disabled={!canEnableEncryption} />
|
||||
<LabelledToggleSwitch value={isEncrypted} onChange={this.onEncryptionChange}
|
||||
label={_t("Encrypted")} disabled={!canEnableEncryption}
|
||||
/>
|
||||
</div>
|
||||
{encryptionSettings}
|
||||
</div>
|
||||
|
||||
<span className='mx_SettingsTab_subheading'>{_t("Who can access this room?")}</span>
|
||||
<div className='mx_SettingsTab_section mx_SettingsTab_subsectionText'>
|
||||
{this._renderRoomAccess()}
|
||||
{this.renderRoomAccess()}
|
||||
</div>
|
||||
|
||||
{historySection}
|
@ -192,7 +192,11 @@ export default class GeneralUserSettingsTab extends React.Component {
|
||||
|
||||
SettingsStore.setValue("language", null, SettingLevel.DEVICE, newLanguage);
|
||||
this.setState({language: newLanguage});
|
||||
PlatformPeg.get().reload();
|
||||
const platform = PlatformPeg.get();
|
||||
if (platform) {
|
||||
platform.setLanguage(newLanguage);
|
||||
platform.reload();
|
||||
}
|
||||
};
|
||||
|
||||
_onSpellCheckLanguagesChange = (languages) => {
|
||||
|
@ -1,6 +1,5 @@
|
||||
/*
|
||||
Copyright 2019 New Vector Ltd
|
||||
Copyright 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");
|
||||
you may not use this file except in compliance with the License.
|
||||
@ -16,7 +15,6 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {_t, getCurrentLanguage} from "../../../../../languageHandler";
|
||||
import {MatrixClientPeg} from "../../../../../MatrixClientPeg";
|
||||
import AccessibleButton from "../../../elements/AccessibleButton";
|
||||
@ -24,24 +22,28 @@ import AccessibleTooltipButton from '../../../elements/AccessibleTooltipButton';
|
||||
import SdkConfig from "../../../../../SdkConfig";
|
||||
import createRoom from "../../../../../createRoom";
|
||||
import Modal from "../../../../../Modal";
|
||||
import * as sdk from "../../../../../";
|
||||
import * as sdk from "../../../../..";
|
||||
import PlatformPeg from "../../../../../PlatformPeg";
|
||||
import * as KeyboardShortcuts from "../../../../../accessibility/KeyboardShortcuts";
|
||||
import UpdateCheckButton from "../../UpdateCheckButton";
|
||||
import {replaceableComponent} from "../../../../../utils/replaceableComponent";
|
||||
import {copyPlaintext} from "../../../../../utils/strings";
|
||||
import { replaceableComponent } from "../../../../../utils/replaceableComponent";
|
||||
import { copyPlaintext } from "../../../../../utils/strings";
|
||||
import * as ContextMenu from "../../../../structures/ContextMenu";
|
||||
import {toRightOf} from "../../../../structures/ContextMenu";
|
||||
import { toRightOf } from "../../../../structures/ContextMenu";
|
||||
|
||||
interface IProps {
|
||||
closeSettingsFn: () => {};
|
||||
}
|
||||
|
||||
interface IState {
|
||||
appVersion: string;
|
||||
canUpdate: boolean;
|
||||
}
|
||||
|
||||
@replaceableComponent("views.settings.tabs.user.HelpUserSettingsTab")
|
||||
export default class HelpUserSettingsTab extends React.Component {
|
||||
static propTypes = {
|
||||
closeSettingsFn: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
export default class HelpUserSettingsTab extends React.Component<IProps, IState> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
appVersion: null,
|
||||
@ -58,7 +60,7 @@ export default class HelpUserSettingsTab extends React.Component {
|
||||
});
|
||||
}
|
||||
|
||||
_onClearCacheAndReload = (e) => {
|
||||
private onClearCacheAndReload = (e) => {
|
||||
if (!PlatformPeg.get()) return;
|
||||
|
||||
// Dev note: please keep this log line, it's useful when troubleshooting a MatrixClient suddenly
|
||||
@ -70,7 +72,7 @@ export default class HelpUserSettingsTab extends React.Component {
|
||||
});
|
||||
};
|
||||
|
||||
_onBugReport = (e) => {
|
||||
private onBugReport = (e) => {
|
||||
const BugReportDialog = sdk.getComponent("dialogs.BugReportDialog");
|
||||
if (!BugReportDialog) {
|
||||
return;
|
||||
@ -78,7 +80,7 @@ export default class HelpUserSettingsTab extends React.Component {
|
||||
Modal.createTrackedDialog('Bug Report Dialog', '', BugReportDialog, {});
|
||||
};
|
||||
|
||||
_onStartBotChat = (e) => {
|
||||
private onStartBotChat = (e) => {
|
||||
this.props.closeSettingsFn();
|
||||
createRoom({
|
||||
dmUserId: SdkConfig.get().welcomeUserId,
|
||||
@ -86,7 +88,7 @@ export default class HelpUserSettingsTab extends React.Component {
|
||||
});
|
||||
};
|
||||
|
||||
_showSpoiler = (event) => {
|
||||
private showSpoiler = (event) => {
|
||||
const target = event.target;
|
||||
target.innerHTML = target.getAttribute('data-spoiler');
|
||||
|
||||
@ -98,7 +100,7 @@ export default class HelpUserSettingsTab extends React.Component {
|
||||
selection.addRange(range);
|
||||
};
|
||||
|
||||
_renderLegal() {
|
||||
private renderLegal() {
|
||||
const tocLinks = SdkConfig.get().terms_and_conditions_links;
|
||||
if (!tocLinks) return null;
|
||||
|
||||
@ -119,7 +121,7 @@ export default class HelpUserSettingsTab extends React.Component {
|
||||
);
|
||||
}
|
||||
|
||||
_renderCredits() {
|
||||
private renderCredits() {
|
||||
// Note: This is not translated because it is legal text.
|
||||
// Also, is ugly but necessary.
|
||||
return (
|
||||
@ -127,28 +129,28 @@ export default class HelpUserSettingsTab extends React.Component {
|
||||
<span className='mx_SettingsTab_subheading'>{_t("Credits")}</span>
|
||||
<ul>
|
||||
<li>
|
||||
The <a href="themes/element/img/backgrounds/lake.jpg" rel="noreferrer noopener" target="_blank">
|
||||
default cover photo</a> is ©
|
||||
<a href="https://www.flickr.com/golan" rel="noreferrer noopener" target="_blank">Jesús Roncero</a>{' '}
|
||||
used under the terms of
|
||||
<a href="https://creativecommons.org/licenses/by-sa/4.0/" rel="noreferrer noopener" target="_blank">
|
||||
CC-BY-SA 4.0</a>.
|
||||
The <a href="themes/element/img/backgrounds/lake.jpg" rel="noreferrer noopener"
|
||||
target="_blank">default cover photo</a> is ©
|
||||
<a href="https://www.flickr.com/golan" rel="noreferrer noopener"
|
||||
target="_blank">Jesús Roncero</a> used under the terms of
|
||||
<a href="https://creativecommons.org/licenses/by-sa/4.0/" rel="noreferrer noopener"
|
||||
target="_blank">CC-BY-SA 4.0</a>.
|
||||
</li>
|
||||
<li>
|
||||
The <a href="https://github.com/matrix-org/twemoji-colr" rel="noreferrer noopener"
|
||||
target="_blank"> twemoji-colr</a> font is ©
|
||||
<a href="https://mozilla.org" rel="noreferrer noopener" target="_blank">Mozilla Foundation</a>{' '}
|
||||
used under the terms of
|
||||
<a href="http://www.apache.org/licenses/LICENSE-2.0" rel="noreferrer noopener" target="_blank">
|
||||
Apache 2.0</a>.
|
||||
target="_blank">twemoji-colr</a> font is ©
|
||||
<a href="https://mozilla.org" rel="noreferrer noopener"
|
||||
target="_blank">Mozilla Foundation</a> used under the terms of
|
||||
<a href="http://www.apache.org/licenses/LICENSE-2.0" rel="noreferrer noopener"
|
||||
target="_blank">Apache 2.0</a>.
|
||||
</li>
|
||||
<li>
|
||||
The <a href="https://twemoji.twitter.com/" rel="noreferrer noopener" target="_blank">
|
||||
Twemoji</a> emoji art is ©
|
||||
<a href="https://twemoji.twitter.com/" rel="noreferrer noopener" target="_blank">Twitter, Inc and other
|
||||
contributors</a> used under the terms of
|
||||
<a href="https://creativecommons.org/licenses/by/4.0/" rel="noreferrer noopener" target="_blank">
|
||||
CC-BY 4.0</a>.
|
||||
The <a href="https://twemoji.twitter.com/" rel="noreferrer noopener"
|
||||
target="_blank">Twemoji</a> emoji art is ©
|
||||
<a href="https://twemoji.twitter.com/" rel="noreferrer noopener"
|
||||
target="_blank">Twitter, Inc and other contributors</a> used under the terms of
|
||||
<a href="https://creativecommons.org/licenses/by/4.0/" rel="noreferrer noopener"
|
||||
target="_blank">CC-BY 4.0</a>.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@ -207,7 +209,7 @@ export default class HelpUserSettingsTab extends React.Component {
|
||||
},
|
||||
)}
|
||||
<div>
|
||||
<AccessibleButton onClick={this._onStartBotChat} kind='primary'>
|
||||
<AccessibleButton onClick={this.onStartBotChat} kind='primary'>
|
||||
{_t("Chat with %(brand)s Bot", { brand })}
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
@ -231,28 +233,27 @@ export default class HelpUserSettingsTab extends React.Component {
|
||||
<div className="mx_SettingsTab_section">
|
||||
<span className='mx_SettingsTab_subheading'>{_t('Bug reporting')}</span>
|
||||
<div className='mx_SettingsTab_subsectionText'>
|
||||
{
|
||||
_t( "If you've submitted a bug via GitHub, debug logs can help " +
|
||||
"us track down the problem. Debug logs contain application " +
|
||||
"usage data including your username, the IDs or aliases of " +
|
||||
"the rooms or groups you have visited and the usernames of " +
|
||||
"other users. They do not contain messages.",
|
||||
)
|
||||
}
|
||||
{_t(
|
||||
"If you've submitted a bug via GitHub, debug logs can help " +
|
||||
"us track down the problem. Debug logs contain application " +
|
||||
"usage data including your username, the IDs or aliases of " +
|
||||
"the rooms or groups you have visited and the usernames of " +
|
||||
"other users. They do not contain messages.",
|
||||
)}
|
||||
<div className='mx_HelpUserSettingsTab_debugButton'>
|
||||
<AccessibleButton onClick={this._onBugReport} kind='primary'>
|
||||
<AccessibleButton onClick={this.onBugReport} kind='primary'>
|
||||
{_t("Submit debug logs")}
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
{
|
||||
_t( "To report a Matrix-related security issue, please read the Matrix.org " +
|
||||
"<a>Security Disclosure Policy</a>.", {},
|
||||
{
|
||||
'a': (sub) =>
|
||||
<a href="https://matrix.org/security-disclosure-policy/"
|
||||
rel="noreferrer noopener" target="_blank">{sub}</a>,
|
||||
})
|
||||
}
|
||||
{_t(
|
||||
"To report a Matrix-related security issue, please read the Matrix.org " +
|
||||
"<a>Security Disclosure Policy</a>.", {},
|
||||
{
|
||||
a: sub => <a href="https://matrix.org/security-disclosure-policy/"
|
||||
rel="noreferrer noopener" target="_blank"
|
||||
>{sub}</a>,
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@ -279,8 +280,8 @@ export default class HelpUserSettingsTab extends React.Component {
|
||||
{updateButton}
|
||||
</div>
|
||||
</div>
|
||||
{this._renderLegal()}
|
||||
{this._renderCredits()}
|
||||
{this.renderLegal()}
|
||||
{this.renderCredits()}
|
||||
<div className='mx_SettingsTab_section mx_HelpUserSettingsTab_versions'>
|
||||
<span className='mx_SettingsTab_subheading'>{_t("Advanced")}</span>
|
||||
<div className='mx_SettingsTab_subsectionText'>
|
||||
@ -301,7 +302,7 @@ export default class HelpUserSettingsTab extends React.Component {
|
||||
</div>
|
||||
</details><br />
|
||||
<div className='mx_HelpUserSettingsTab_debugButton'>
|
||||
<AccessibleButton onClick={this._onClearCacheAndReload} kind='danger'>
|
||||
<AccessibleButton onClick={this.onClearCacheAndReload} kind='danger'>
|
||||
{_t("Clear cache and reload")}
|
||||
</AccessibleButton>
|
||||
</div>
|
@ -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");
|
||||
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 {replaceableComponent} from "../../../../../utils/replaceableComponent";
|
||||
|
||||
interface IState {
|
||||
busy: boolean;
|
||||
newPersonalRule: string;
|
||||
newList: string;
|
||||
}
|
||||
|
||||
@replaceableComponent("views.settings.tabs.user.MjolnirUserSettingsTab")
|
||||
export default class MjolnirUserSettingsTab extends React.Component {
|
||||
constructor() {
|
||||
super();
|
||||
export default class MjolnirUserSettingsTab extends React.Component<{}, IState> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
busy: false,
|
||||
@ -37,15 +43,15 @@ export default class MjolnirUserSettingsTab extends React.Component {
|
||||
};
|
||||
}
|
||||
|
||||
_onPersonalRuleChanged = (e) => {
|
||||
private onPersonalRuleChanged = (e) => {
|
||||
this.setState({newPersonalRule: e.target.value});
|
||||
};
|
||||
|
||||
_onNewListChanged = (e) => {
|
||||
private onNewListChanged = (e) => {
|
||||
this.setState({newList: e.target.value});
|
||||
};
|
||||
|
||||
_onAddPersonalRule = async (e) => {
|
||||
private onAddPersonalRule = async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
@ -72,7 +78,7 @@ export default class MjolnirUserSettingsTab extends React.Component {
|
||||
}
|
||||
};
|
||||
|
||||
_onSubscribeList = async (e) => {
|
||||
private onSubscribeList = async (e) => {
|
||||
e.preventDefault();
|
||||
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});
|
||||
try {
|
||||
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});
|
||||
try {
|
||||
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 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 list = Mjolnir.sharedInstance().getPersonalList();
|
||||
@ -174,7 +180,7 @@ export default class MjolnirUserSettingsTab extends React.Component {
|
||||
<li key={rule.entity} className="mx_MjolnirUserSettingsTab_listItem">
|
||||
<AccessibleButton
|
||||
kind="danger_sm"
|
||||
onClick={() => this._removePersonalRule(rule)}
|
||||
onClick={() => this.removePersonalRule(rule)}
|
||||
disabled={this.state.busy}
|
||||
>
|
||||
{_t("Remove")}
|
||||
@ -192,7 +198,7 @@ export default class MjolnirUserSettingsTab extends React.Component {
|
||||
);
|
||||
}
|
||||
|
||||
_renderSubscribedBanLists() {
|
||||
private renderSubscribedBanLists() {
|
||||
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
|
||||
|
||||
const personalList = Mjolnir.sharedInstance().getPersonalList();
|
||||
@ -209,14 +215,14 @@ export default class MjolnirUserSettingsTab extends React.Component {
|
||||
<li key={list.roomId} className="mx_MjolnirUserSettingsTab_listItem">
|
||||
<AccessibleButton
|
||||
kind="danger_sm"
|
||||
onClick={() => this._unsubscribeFromList(list)}
|
||||
onClick={() => this.unsubscribeFromList(list)}
|
||||
disabled={this.state.busy}
|
||||
>
|
||||
{_t("Unsubscribe")}
|
||||
</AccessibleButton>
|
||||
<AccessibleButton
|
||||
kind="primary_sm"
|
||||
onClick={() => this._viewListRules(list)}
|
||||
onClick={() => this.viewListRules(list)}
|
||||
disabled={this.state.busy}
|
||||
>
|
||||
{_t("View rules")}
|
||||
@ -271,21 +277,21 @@ export default class MjolnirUserSettingsTab extends React.Component {
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{this._renderPersonalBanListRules()}
|
||||
{this.renderPersonalBanListRules()}
|
||||
</div>
|
||||
<div>
|
||||
<form onSubmit={this._onAddPersonalRule} autoComplete="off">
|
||||
<form onSubmit={this.onAddPersonalRule} autoComplete="off">
|
||||
<Field
|
||||
type="text"
|
||||
label={_t("Server or user ID to ignore")}
|
||||
placeholder={_t("eg: @bot:* or example.org")}
|
||||
value={this.state.newPersonalRule}
|
||||
onChange={this._onPersonalRuleChanged}
|
||||
onChange={this.onPersonalRuleChanged}
|
||||
/>
|
||||
<AccessibleButton
|
||||
type="submit"
|
||||
kind="primary"
|
||||
onClick={this._onAddPersonalRule}
|
||||
onClick={this.onAddPersonalRule}
|
||||
disabled={this.state.busy}
|
||||
>
|
||||
{_t("Ignore")}
|
||||
@ -303,20 +309,20 @@ export default class MjolnirUserSettingsTab extends React.Component {
|
||||
)}</span>
|
||||
</div>
|
||||
<div>
|
||||
{this._renderSubscribedBanLists()}
|
||||
{this.renderSubscribedBanLists()}
|
||||
</div>
|
||||
<div>
|
||||
<form onSubmit={this._onSubscribeList} autoComplete="off">
|
||||
<form onSubmit={this.onSubscribeList} autoComplete="off">
|
||||
<Field
|
||||
type="text"
|
||||
label={_t("Room ID or address of ban list")}
|
||||
value={this.state.newList}
|
||||
onChange={this._onNewListChanged}
|
||||
onChange={this.onNewListChanged}
|
||||
/>
|
||||
<AccessibleButton
|
||||
type="submit"
|
||||
kind="primary"
|
||||
onClick={this._onSubscribeList}
|
||||
onClick={this.onSubscribeList}
|
||||
disabled={this.state.busy}
|
||||
>
|
||||
{_t("Subscribe")}
|
@ -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>
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@ -23,10 +23,24 @@ import Field from "../../../elements/Field";
|
||||
import * as sdk from "../../../../..";
|
||||
import PlatformPeg from "../../../../../PlatformPeg";
|
||||
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")
|
||||
export default class PreferencesUserSettingsTab extends React.Component {
|
||||
export default class PreferencesUserSettingsTab extends React.Component<{}, IState> {
|
||||
static ROOM_LIST_SETTINGS = [
|
||||
'breadcrumbs',
|
||||
];
|
||||
@ -68,8 +82,8 @@ export default class PreferencesUserSettingsTab extends React.Component {
|
||||
// Autocomplete delay (niche text box)
|
||||
];
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
autoLaunch: false,
|
||||
@ -89,7 +103,7 @@ export default class PreferencesUserSettingsTab extends React.Component {
|
||||
};
|
||||
}
|
||||
|
||||
async componentDidMount(): void {
|
||||
async componentDidMount() {
|
||||
const platform = PlatformPeg.get();
|
||||
|
||||
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}));
|
||||
};
|
||||
|
||||
_onWarnBeforeExitChange = (checked) => {
|
||||
private onWarnBeforeExitChange = (checked: boolean) => {
|
||||
PlatformPeg.get().setWarnBeforeExit(checked).then(() => this.setState({warnBeforeExit: checked}));
|
||||
}
|
||||
|
||||
_onAlwaysShowMenuBarChange = (checked) => {
|
||||
private onAlwaysShowMenuBarChange = (checked: boolean) => {
|
||||
PlatformPeg.get().setAutoHideMenuBarEnabled(!checked).then(() => this.setState({alwaysShowMenuBar: checked}));
|
||||
};
|
||||
|
||||
_onMinimizeToTrayChange = (checked) => {
|
||||
private onMinimizeToTrayChange = (checked: boolean) => {
|
||||
PlatformPeg.get().setMinimizeToTrayEnabled(checked).then(() => this.setState({minimizeToTray: checked}));
|
||||
};
|
||||
|
||||
_onAutocompleteDelayChange = (e) => {
|
||||
private onAutocompleteDelayChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
this.setState({autocompleteDelay: 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});
|
||||
SettingsStore.setValue("readMarkerInViewThresholdMs", null, SettingLevel.DEVICE, e.target.value);
|
||||
};
|
||||
|
||||
_onReadMarkerOutOfViewThresholdMs = (e) => {
|
||||
private onReadMarkerOutOfViewThresholdMs = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
this.setState({readMarkerOutOfViewThresholdMs: 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");
|
||||
return settingIds.filter(SettingsStore.isEnabled).map(i => {
|
||||
return <SettingsFlag key={i} name={i} level={SettingLevel.ACCOUNT} />;
|
||||
@ -171,7 +185,7 @@ export default class PreferencesUserSettingsTab extends React.Component {
|
||||
if (this.state.autoLaunchSupported) {
|
||||
autoLaunchOption = <LabelledToggleSwitch
|
||||
value={this.state.autoLaunch}
|
||||
onChange={this._onAutoLaunchChange}
|
||||
onChange={this.onAutoLaunchChange}
|
||||
label={_t('Start automatically after system login')} />;
|
||||
}
|
||||
|
||||
@ -179,7 +193,7 @@ export default class PreferencesUserSettingsTab extends React.Component {
|
||||
if (this.state.warnBeforeExitSupported) {
|
||||
warnBeforeExitOption = <LabelledToggleSwitch
|
||||
value={this.state.warnBeforeExit}
|
||||
onChange={this._onWarnBeforeExitChange}
|
||||
onChange={this.onWarnBeforeExitChange}
|
||||
label={_t('Warn before quitting')} />;
|
||||
}
|
||||
|
||||
@ -187,7 +201,7 @@ export default class PreferencesUserSettingsTab extends React.Component {
|
||||
if (this.state.alwaysShowMenuBarSupported) {
|
||||
autoHideMenuOption = <LabelledToggleSwitch
|
||||
value={this.state.alwaysShowMenuBar}
|
||||
onChange={this._onAlwaysShowMenuBarChange}
|
||||
onChange={this.onAlwaysShowMenuBarChange}
|
||||
label={_t('Always show the window menu bar')} />;
|
||||
}
|
||||
|
||||
@ -195,7 +209,7 @@ export default class PreferencesUserSettingsTab extends React.Component {
|
||||
if (this.state.minimizeToTraySupported) {
|
||||
minimizeToTrayOption = <LabelledToggleSwitch
|
||||
value={this.state.minimizeToTray}
|
||||
onChange={this._onMinimizeToTrayChange}
|
||||
onChange={this.onMinimizeToTrayChange}
|
||||
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">
|
||||
<span className="mx_SettingsTab_subheading">{_t("Room list")}</span>
|
||||
{this._renderGroup(PreferencesUserSettingsTab.ROOM_LIST_SETTINGS)}
|
||||
{this.renderGroup(PreferencesUserSettingsTab.ROOM_LIST_SETTINGS)}
|
||||
</div>
|
||||
|
||||
<div className="mx_SettingsTab_section">
|
||||
<span className="mx_SettingsTab_subheading">{_t("Composer")}</span>
|
||||
{this._renderGroup(PreferencesUserSettingsTab.COMPOSER_SETTINGS)}
|
||||
{this.renderGroup(PreferencesUserSettingsTab.COMPOSER_SETTINGS)}
|
||||
</div>
|
||||
|
||||
<div className="mx_SettingsTab_section">
|
||||
<span className="mx_SettingsTab_subheading">{_t("Timeline")}</span>
|
||||
{this._renderGroup(PreferencesUserSettingsTab.TIMELINE_SETTINGS)}
|
||||
{this.renderGroup(PreferencesUserSettingsTab.TIMELINE_SETTINGS)}
|
||||
</div>
|
||||
|
||||
<div className="mx_SettingsTab_section">
|
||||
<span className="mx_SettingsTab_subheading">{_t("General")}</span>
|
||||
{this._renderGroup(PreferencesUserSettingsTab.GENERAL_SETTINGS)}
|
||||
{this.renderGroup(PreferencesUserSettingsTab.GENERAL_SETTINGS)}
|
||||
{minimizeToTrayOption}
|
||||
{autoHideMenuOption}
|
||||
{autoLaunchOption}
|
||||
@ -229,17 +243,17 @@ export default class PreferencesUserSettingsTab extends React.Component {
|
||||
label={_t('Autocomplete delay (ms)')}
|
||||
type='number'
|
||||
value={this.state.autocompleteDelay}
|
||||
onChange={this._onAutocompleteDelayChange} />
|
||||
onChange={this.onAutocompleteDelayChange} />
|
||||
<Field
|
||||
label={_t('Read Marker lifetime (ms)')}
|
||||
type='number'
|
||||
value={this.state.readMarkerInViewThresholdMs}
|
||||
onChange={this._onReadMarkerInViewThresholdMs} />
|
||||
onChange={this.onReadMarkerInViewThresholdMs} />
|
||||
<Field
|
||||
label={_t('Read Marker off-screen lifetime (ms)')}
|
||||
type='number'
|
||||
value={this.state.readMarkerOutOfViewThresholdMs}
|
||||
onChange={this._onReadMarkerOutOfViewThresholdMs} />
|
||||
onChange={this.onReadMarkerOutOfViewThresholdMs} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
@ -96,6 +96,9 @@ export class Media {
|
||||
*/
|
||||
public getThumbnailHttp(width: number, height: number, mode: ResizeMethod = "scale"): string | null | undefined {
|
||||
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);
|
||||
}
|
||||
|
||||
@ -107,6 +110,9 @@ export class Media {
|
||||
* @returns {string} The HTTP URL which points to the thumbnail.
|
||||
*/
|
||||
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);
|
||||
}
|
||||
|
||||
@ -117,6 +123,7 @@ export class Media {
|
||||
* @returns {string} An HTTP URL for the thumbnail.
|
||||
*/
|
||||
public getSquareThumbnailHttp(dim: number): string {
|
||||
dim = Math.floor(dim * window.devicePixelRatio); // scale using the device pixel ratio to keep images clear
|
||||
if (this.hasThumbnail) {
|
||||
return this.getThumbnailHttp(dim, dim, 'crop');
|
||||
}
|
||||
|
@ -341,11 +341,7 @@ class RoomPillPart extends PillPart {
|
||||
|
||||
setAvatar(node: HTMLElement) {
|
||||
let initialLetter = "";
|
||||
let avatarUrl = Avatar.avatarUrlForRoom(
|
||||
this.room,
|
||||
16 * window.devicePixelRatio,
|
||||
16 * window.devicePixelRatio,
|
||||
"crop");
|
||||
let avatarUrl = Avatar.avatarUrlForRoom(this.room, 16, 16, "crop");
|
||||
if (!avatarUrl) {
|
||||
initialLetter = Avatar.getInitialLetter(this.room ? this.room.name : 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 defaultAvatarUrl = Avatar.defaultAvatarUrlForString(this.member.userId);
|
||||
const avatarUrl = Avatar.avatarUrlForMember(
|
||||
this.member,
|
||||
16 * window.devicePixelRatio,
|
||||
16 * window.devicePixelRatio,
|
||||
"crop");
|
||||
const avatarUrl = Avatar.avatarUrlForMember(this.member, 16, 16, "crop");
|
||||
let initialLetter = "";
|
||||
if (avatarUrl === defaultAvatarUrl) {
|
||||
initialLetter = Avatar.getInitialLetter(name);
|
||||
|
@ -658,7 +658,6 @@
|
||||
"No homeserver URL provided": "No homeserver URL provided",
|
||||
"Unexpected error resolving homeserver configuration": "Unexpected error resolving homeserver 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 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.",
|
||||
@ -1454,6 +1453,7 @@
|
||||
"Sending your message...": "Sending your message...",
|
||||
"Encrypting your message...": "Encrypting your message...",
|
||||
"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",
|
||||
"Scroll to most recent messages": "Scroll to most recent messages",
|
||||
"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.",
|
||||
"Error decrypting audio": "Error decrypting audio",
|
||||
"React": "React",
|
||||
"Reply": "Reply",
|
||||
"Edit": "Edit",
|
||||
"Retry": "Retry",
|
||||
"Reply": "Reply",
|
||||
"Message Actions": "Message Actions",
|
||||
"Attachment": "Attachment",
|
||||
"Error decrypting attachment": "Error decrypting attachment",
|
||||
@ -1924,10 +1925,10 @@
|
||||
"%(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|one": "%(count)s person you know has already joined",
|
||||
"Rotate Right": "Rotate Right",
|
||||
"Rotate Left": "Rotate Left",
|
||||
"Zoom out": "Zoom out",
|
||||
"Zoom in": "Zoom in",
|
||||
"Rotate Right": "Rotate Right",
|
||||
"Rotate Left": "Rotate Left",
|
||||
"Download": "Download",
|
||||
"Information": "Information",
|
||||
"View message": "View message",
|
||||
@ -2397,7 +2398,6 @@
|
||||
"Confirm encryption setup": "Confirm encryption setup",
|
||||
"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",
|
||||
"Retry": "Retry",
|
||||
"Restoring keys from backup": "Restoring keys from backup",
|
||||
"Fetching keys from server...": "Fetching keys from server...",
|
||||
"%(completed)s of %(total)s keys restored": "%(completed)s of %(total)s keys restored",
|
||||
@ -2426,10 +2426,7 @@
|
||||
"Reject invitation": "Reject 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",
|
||||
"Resend edit": "Resend edit",
|
||||
"Resend %(unsentCount)s reaction(s)": "Resend %(unsentCount)s reaction(s)",
|
||||
"Resend removal": "Resend removal",
|
||||
"Cancel Sending": "Cancel Sending",
|
||||
"Forward Message": "Forward Message",
|
||||
"Pin Message": "Pin Message",
|
||||
"Unhide Preview": "Unhide Preview",
|
||||
@ -2617,10 +2614,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 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.",
|
||||
"%(count)s of your messages have not been sent.|other": "Some of your messages have not been sent.",
|
||||
"%(count)s of your messages have not been sent.|one": "Your message was not sent.",
|
||||
"%(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.",
|
||||
"%(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.",
|
||||
"Some of your messages have not been sent": "Some of your messages have not been sent",
|
||||
"Delete all": "Delete all",
|
||||
"Retry all": "Retry all",
|
||||
"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.",
|
||||
"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?",
|
||||
@ -2640,6 +2638,7 @@
|
||||
"%(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",
|
||||
"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|one": "%(count)s room and %(numSpaces)s spaces",
|
||||
"%(count)s rooms and 1 space|other": "%(count)s rooms and 1 space",
|
||||
@ -2650,7 +2649,6 @@
|
||||
"Mark as suggested": "Mark as suggested",
|
||||
"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.",
|
||||
"Your server does not support showing space hierarchies.": "Your server does not support showing space hierarchies.",
|
||||
"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>.",
|
||||
"Create room": "Create room",
|
||||
|
@ -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");
|
||||
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");
|
||||
}
|
||||
|
||||
async isEventIndexEmpty(): Promise<boolean> {
|
||||
throw new Error("Unimplemented");
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if our event index is empty.
|
||||
*/
|
||||
|
@ -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");
|
||||
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;
|
||||
|
||||
class EventIndexPeg {
|
||||
constructor() {
|
||||
this.index = null;
|
||||
this._supportIsInstalled = false;
|
||||
this.error = null;
|
||||
}
|
||||
export class EventIndexPeg {
|
||||
public index: EventIndex = null;
|
||||
public error: Error = null;
|
||||
|
||||
private _supportIsInstalled = false;
|
||||
|
||||
/**
|
||||
* Initialize the EventIndexPeg and if event indexing is enabled initialize
|
||||
@ -181,7 +180,7 @@ class EventIndexPeg {
|
||||
}
|
||||
}
|
||||
|
||||
if (!global.mxEventIndexPeg) {
|
||||
global.mxEventIndexPeg = new EventIndexPeg();
|
||||
if (!window.mxEventIndexPeg) {
|
||||
window.mxEventIndexPeg = new EventIndexPeg();
|
||||
}
|
||||
export default global.mxEventIndexPeg;
|
||||
export default window.mxEventIndexPeg;
|
@ -113,7 +113,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
|
||||
}
|
||||
|
||||
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.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);
|
||||
return sortBy(childEvents, getOrder)
|
||||
.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[] {
|
||||
@ -203,7 +203,8 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
|
||||
}
|
||||
|
||||
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[] {
|
||||
@ -409,32 +410,39 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
|
||||
});
|
||||
}, 100, {trailing: true, leading: true});
|
||||
|
||||
private onRoom = (room: Room, membership?: string, oldMembership?: string) => {
|
||||
if ((membership || room.getMyMembership()) === "invite") {
|
||||
this._invitedSpaces.add(room);
|
||||
this.emit(UPDATE_INVITED_SPACES, this.invitedSpaces);
|
||||
} 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 {
|
||||
private onRoom = (room: Room, newMembership?: string, oldMembership?: string) => {
|
||||
const membership = newMembership || room.getMyMembership();
|
||||
|
||||
if (!room.isSpaceRoom()) {
|
||||
// this.onRoomUpdate(room);
|
||||
this.onRoomsUpdate();
|
||||
}
|
||||
|
||||
if (room.getMyMembership() === "join") {
|
||||
if (!room.isSpaceRoom()) {
|
||||
if (membership === "join") {
|
||||
// the user just joined a room, remove it from the suggested list if it was there
|
||||
const numSuggestedRooms = this._suggestedRooms.length;
|
||||
this._suggestedRooms = this._suggestedRooms.filter(r => r.room_id !== room.roomId);
|
||||
if (numSuggestedRooms !== this._suggestedRooms.length) {
|
||||
this.emit(SUGGESTED_ROOMS, this._suggestedRooms);
|
||||
}
|
||||
} else if (room.roomId === RoomViewStore.getRoomId()) {
|
||||
// if the user was looking at the space and then joined: select that space
|
||||
this.setActiveSpace(room);
|
||||
}
|
||||
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
|
||||
this.setActiveSpace(room);
|
||||
}
|
||||
};
|
||||
|
||||
@ -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() {
|
||||
if (!SettingsStore.getValue("feature_spaces")) return;
|
||||
if (this.matrixClient) {
|
||||
@ -507,7 +526,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
|
||||
this.matrixClient.removeListener("Room.accountData", this.onRoomAccountData);
|
||||
this.matrixClient.removeListener("accountData", this.onAccountData);
|
||||
}
|
||||
await this.reset({});
|
||||
await this.reset();
|
||||
}
|
||||
|
||||
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
|
||||
if (!room || payload.context_switch) break;
|
||||
|
||||
// persist last viewed room from a space
|
||||
|
||||
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)) {
|
||||
// TODO maybe reverse these first 2 clauses once space panel active is fixed
|
||||
let parent = this.rootSpaces.find(s => this.spaceFilteredRooms.get(s.roomId)?.has(room.roomId));
|
||||
let parent = this.getCanonicalParent(room.roomId);
|
||||
if (!parent) {
|
||||
parent = this.getCanonicalParent(room.roomId);
|
||||
parent = this.rootSpaces.find(s => this.spaceFilteredRooms.get(s.roomId)?.has(room.roomId));
|
||||
}
|
||||
if (!parent) {
|
||||
const parents = Array.from(this.parentMap.get(room.roomId) || []);
|
||||
@ -582,7 +600,9 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
|
||||
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(
|
||||
spaceId: string,
|
||||
fn: (roomId: string) => void,
|
||||
|
@ -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");
|
||||
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.
|
||||
*/
|
||||
export default class TypingStore {
|
||||
private typingStates: {
|
||||
[roomId: string]: {
|
||||
isTyping: boolean,
|
||||
userTimer: Timer,
|
||||
serverTimer: Timer,
|
||||
},
|
||||
};
|
||||
|
||||
constructor() {
|
||||
this.reset();
|
||||
}
|
||||
|
||||
static sharedInstance(): TypingStore {
|
||||
if (global.mxTypingStore === undefined) {
|
||||
global.mxTypingStore = new TypingStore();
|
||||
if (window.mxTypingStore === undefined) {
|
||||
window.mxTypingStore = new TypingStore();
|
||||
}
|
||||
return global.mxTypingStore;
|
||||
return window.mxTypingStore;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -41,7 +49,7 @@ export default class TypingStore {
|
||||
* MatrixClientPeg client changes.
|
||||
*/
|
||||
reset() {
|
||||
this._typingStates = {
|
||||
this.typingStates = {
|
||||
// "roomId": {
|
||||
// isTyping: bool, // Whether the user is typing or not
|
||||
// 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('lowBandwidth')) return;
|
||||
|
||||
let currentTyping = this._typingStates[roomId];
|
||||
let currentTyping = this.typingStates[roomId];
|
||||
if ((!isTyping && !currentTyping) || (currentTyping && currentTyping.isTyping === isTyping)) {
|
||||
// No change in state, so don't do anything. We'll let the timer run its course.
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentTyping) {
|
||||
currentTyping = this._typingStates[roomId] = {
|
||||
currentTyping = this.typingStates[roomId] = {
|
||||
isTyping: isTyping,
|
||||
serverTimer: new Timer(TYPING_SERVER_TIMEOUT),
|
||||
userTimer: new Timer(TYPING_USER_TIMEOUT),
|
||||
@ -78,7 +86,7 @@ export default class TypingStore {
|
||||
if (isTyping) {
|
||||
if (!currentTyping.serverTimer.isRunning()) {
|
||||
currentTyping.serverTimer.restart().finished().then(() => {
|
||||
const currentTyping = this._typingStates[roomId];
|
||||
const currentTyping = this.typingStates[roomId];
|
||||
if (currentTyping) currentTyping.isTyping = false;
|
||||
|
||||
// The server will (should) time us out on typing, so we don't
|
@ -1,6 +1,5 @@
|
||||
/*
|
||||
Copyright 2018 New Vector Ltd
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2018-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.
|
||||
@ -16,6 +15,8 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import EventEmitter from 'events';
|
||||
import { IWidget } from 'matrix-widget-api';
|
||||
import MatrixEvent from "matrix-js-sdk/src/models/event";
|
||||
import {WidgetType} from "../widgets/WidgetType";
|
||||
|
||||
/**
|
||||
@ -23,14 +24,20 @@ import {WidgetType} from "../widgets/WidgetType";
|
||||
* proxying through state from the js-sdk.
|
||||
*/
|
||||
class WidgetEchoStore extends EventEmitter {
|
||||
private roomWidgetEcho: {
|
||||
[roomId: string]: {
|
||||
[widgetId: string]: IWidget,
|
||||
},
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this._roomWidgetEcho = {
|
||||
this.roomWidgetEcho = {
|
||||
// Map as below. Object is the content of the widget state event,
|
||||
// so for widgets that have been deleted locally, the object is empty.
|
||||
// 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
|
||||
* 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
|
||||
* @returns {MatrixEvent[]} List of widgets in the room, minus any pending removal
|
||||
*/
|
||||
getEchoedRoomWidgets(roomId, currentRoomWidgets) {
|
||||
getEchoedRoomWidgets(roomId: string, currentRoomWidgets: MatrixEvent[]): MatrixEvent[] {
|
||||
const echoedWidgets = [];
|
||||
|
||||
const roomEchoState = Object.assign({}, this._roomWidgetEcho[roomId]);
|
||||
const roomEchoState = Object.assign({}, this.roomWidgetEcho[roomId]);
|
||||
|
||||
for (const w of currentRoomWidgets) {
|
||||
const widgetId = w.getStateKey();
|
||||
@ -65,8 +72,8 @@ class WidgetEchoStore extends EventEmitter {
|
||||
return echoedWidgets;
|
||||
}
|
||||
|
||||
roomHasPendingWidgetsOfType(roomId, currentRoomWidgets, type: WidgetType) {
|
||||
const roomEchoState = Object.assign({}, this._roomWidgetEcho[roomId]);
|
||||
roomHasPendingWidgetsOfType(roomId: string, currentRoomWidgets: MatrixEvent[], type?: WidgetType): boolean {
|
||||
const roomEchoState = Object.assign({}, this.roomWidgetEcho[roomId]);
|
||||
|
||||
// any widget IDs that are already in the room are not pending, so
|
||||
// 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);
|
||||
}
|
||||
|
||||
setRoomWidgetEcho(roomId, widgetId, state) {
|
||||
if (this._roomWidgetEcho[roomId] === undefined) this._roomWidgetEcho[roomId] = {};
|
||||
setRoomWidgetEcho(roomId: string, widgetId: string, state: IWidget) {
|
||||
if (this.roomWidgetEcho[roomId] === undefined) this.roomWidgetEcho[roomId] = {};
|
||||
|
||||
this._roomWidgetEcho[roomId][widgetId] = state;
|
||||
this.roomWidgetEcho[roomId][widgetId] = state;
|
||||
this.emit('update', roomId, widgetId);
|
||||
}
|
||||
|
||||
removeRoomWidgetEcho(roomId, widgetId) {
|
||||
delete this._roomWidgetEcho[roomId][widgetId];
|
||||
if (Object.keys(this._roomWidgetEcho[roomId]).length === 0) delete this._roomWidgetEcho[roomId];
|
||||
removeRoomWidgetEcho(roomId: string, widgetId: string) {
|
||||
delete this.roomWidgetEcho[roomId][widgetId];
|
||||
if (Object.keys(this.roomWidgetEcho[roomId]).length === 0) delete this.roomWidgetEcho[roomId];
|
||||
this.emit('update', roomId, widgetId);
|
||||
}
|
||||
}
|
@ -18,6 +18,8 @@ import { NotificationColor } from "./NotificationColor";
|
||||
import { NotificationState } from "./NotificationState";
|
||||
|
||||
export class StaticNotificationState extends NotificationState {
|
||||
public static readonly RED_EXCLAMATION = StaticNotificationState.forSymbol("!", NotificationColor.Red);
|
||||
|
||||
constructor(symbol: string, count: number, color: NotificationColor) {
|
||||
super();
|
||||
this._symbol = symbol;
|
||||
|
@ -37,7 +37,11 @@ export class VisibilityProvider {
|
||||
await VoipUserMapper.sharedInstance().onNewInvitedRoom(room);
|
||||
}
|
||||
|
||||
public isRoomVisible(room: Room): boolean {
|
||||
public isRoomVisible(room?: Room): boolean {
|
||||
if (!room) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
CallHandler.sharedInstance().getSupportsVirtualRooms() &&
|
||||
VoipUserMapper.sharedInstance().isVirtualRoom(room)
|
||||
|
@ -1,6 +1,5 @@
|
||||
/*
|
||||
Copyright 2019 New Vector Ltd
|
||||
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");
|
||||
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.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import React, { ReactNode } from 'react';
|
||||
import {AutoDiscovery} from "matrix-js-sdk/src/autodiscovery";
|
||||
import {_t, _td, newTranslatableError} from "../languageHandler";
|
||||
import {makeType} from "./TypeUtils";
|
||||
import SdkConfig from '../SdkConfig';
|
||||
|
||||
const LIVELINESS_DISCOVERY_ERRORS = [
|
||||
const LIVELINESS_DISCOVERY_ERRORS: string[] = [
|
||||
AutoDiscovery.ERROR_INVALID_HOMESERVER,
|
||||
AutoDiscovery.ERROR_INVALID_IDENTITY_SERVER,
|
||||
];
|
||||
@ -40,17 +39,23 @@ export class ValidatedServerConfig {
|
||||
warning: string;
|
||||
}
|
||||
|
||||
export interface IAuthComponentState {
|
||||
serverIsAlive: boolean;
|
||||
serverErrorIsFatal: boolean;
|
||||
serverDeadError?: ReactNode;
|
||||
}
|
||||
|
||||
export default class AutoDiscoveryUtils {
|
||||
/**
|
||||
* Checks if a given error or error message is considered an error
|
||||
* relating to the liveliness of the server. Must be an error returned
|
||||
* 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.
|
||||
*/
|
||||
static isLivelinessError(error: string|Error): boolean {
|
||||
static isLivelinessError(error: string | Error): boolean {
|
||||
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.
|
||||
* @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) {
|
||||
return {
|
||||
serverIsAlive: true,
|
||||
@ -70,7 +75,7 @@ export default class AutoDiscoveryUtils {
|
||||
};
|
||||
}
|
||||
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)) {
|
||||
const brand = SdkConfig.get().brand;
|
||||
title = _t("Your %(brand)s is misconfigured", { brand });
|
||||
@ -92,7 +97,7 @@ export default class AutoDiscoveryUtils {
|
||||
}
|
||||
|
||||
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) {
|
||||
isFatalError = false;
|
||||
title = _t("Cannot reach identity server");
|
||||
@ -141,7 +146,10 @@ export default class AutoDiscoveryUtils {
|
||||
* @returns {Promise<ValidatedServerConfig>} Resolves to the validated configuration.
|
||||
*/
|
||||
static async validateServerConfigWithStaticUrls(
|
||||
homeserverUrl: string, identityUrl: string, syntaxOnly = false): ValidatedServerConfig {
|
||||
homeserverUrl: string,
|
||||
identityUrl?: string,
|
||||
syntaxOnly = false,
|
||||
): Promise<ValidatedServerConfig> {
|
||||
if (!homeserverUrl) {
|
||||
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.
|
||||
* @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);
|
||||
return AutoDiscoveryUtils.buildValidatedConfigFromDiscovery(serverName, result);
|
||||
}
|
@ -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) {
|
||||
if (err.errcode === 'M_RESOURCE_LIMIT_EXCEEDED') {
|
||||
const limitError = messageForResourceLimitError(
|
||||
|
@ -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");
|
||||
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 CRYPTO_STORE_NAME = "matrix-js-sdk:crypto";
|
||||
|
||||
function log(msg) {
|
||||
function log(msg: string) {
|
||||
console.log(`StorageManager: ${msg}`);
|
||||
}
|
||||
|
||||
function error(msg) {
|
||||
console.error(`StorageManager: ${msg}`);
|
||||
function error(msg: string, ...args: string[]) {
|
||||
console.error(`StorageManager: ${msg}`, ...args);
|
||||
}
|
||||
|
||||
function track(action) {
|
||||
function track(action: string) {
|
||||
Analytics.trackEvent("StorageManager", action);
|
||||
}
|
||||
|
||||
@ -73,7 +73,7 @@ export async function checkConsistency() {
|
||||
dataInLocalStorage = localStorage.length > 0;
|
||||
log(`Local storage contains data? ${dataInLocalStorage}`);
|
||||
|
||||
cryptoInited = localStorage.getItem("mx_crypto_initialised");
|
||||
cryptoInited = !!localStorage.getItem("mx_crypto_initialised");
|
||||
log(`Crypto initialised? ${cryptoInited}`);
|
||||
} else {
|
||||
healthy = false;
|
@ -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");
|
||||
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()`.
|
||||
*/
|
||||
export default class Timer {
|
||||
constructor(timeout) {
|
||||
this._timeout = timeout;
|
||||
this._onTimeout = this._onTimeout.bind(this);
|
||||
this._setNotStarted();
|
||||
private timerHandle: NodeJS.Timeout;
|
||||
private startTs: number;
|
||||
private promise: Promise<void>;
|
||||
private resolve: () => void;
|
||||
private reject: (Error) => void;
|
||||
|
||||
constructor(private timeout: number) {
|
||||
this.setNotStarted();
|
||||
}
|
||||
|
||||
_setNotStarted() {
|
||||
this._timerHandle = null;
|
||||
this._startTs = null;
|
||||
this._promise = new Promise((resolve, reject) => {
|
||||
this._resolve = resolve;
|
||||
this._reject = reject;
|
||||
private setNotStarted() {
|
||||
this.timerHandle = null;
|
||||
this.startTs = null;
|
||||
this.promise = new Promise<void>((resolve, reject) => {
|
||||
this.resolve = resolve;
|
||||
this.reject = reject;
|
||||
}).finally(() => {
|
||||
this._timerHandle = null;
|
||||
this.timerHandle = null;
|
||||
});
|
||||
}
|
||||
|
||||
_onTimeout() {
|
||||
private onTimeout = () => {
|
||||
const now = Date.now();
|
||||
const elapsed = now - this._startTs;
|
||||
if (elapsed >= this._timeout) {
|
||||
this._resolve();
|
||||
this._setNotStarted();
|
||||
const elapsed = now - this.startTs;
|
||||
if (elapsed >= this.timeout) {
|
||||
this.resolve();
|
||||
this.setNotStarted();
|
||||
} else {
|
||||
const delta = this._timeout - elapsed;
|
||||
this._timerHandle = setTimeout(this._onTimeout, delta);
|
||||
const delta = this.timeout - elapsed;
|
||||
this.timerHandle = setTimeout(this.onTimeout, delta);
|
||||
}
|
||||
}
|
||||
|
||||
changeTimeout(timeout) {
|
||||
if (timeout === this._timeout) {
|
||||
changeTimeout(timeout: number) {
|
||||
if (timeout === this.timeout) {
|
||||
return;
|
||||
}
|
||||
const isSmallerTimeout = timeout < this._timeout;
|
||||
this._timeout = timeout;
|
||||
const isSmallerTimeout = timeout < this.timeout;
|
||||
this.timeout = timeout;
|
||||
if (this.isRunning() && isSmallerTimeout) {
|
||||
clearTimeout(this._timerHandle);
|
||||
this._onTimeout();
|
||||
clearTimeout(this.timerHandle);
|
||||
this.onTimeout();
|
||||
}
|
||||
}
|
||||
|
||||
@ -73,8 +77,8 @@ export default class Timer {
|
||||
*/
|
||||
start() {
|
||||
if (!this.isRunning()) {
|
||||
this._startTs = Date.now();
|
||||
this._timerHandle = setTimeout(this._onTimeout, this._timeout);
|
||||
this.startTs = Date.now();
|
||||
this.timerHandle = setTimeout(this.onTimeout, this.timeout);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
@ -89,7 +93,7 @@ export default class Timer {
|
||||
// can be called in fast succession,
|
||||
// instead just take note and compare
|
||||
// when the already running timeout expires
|
||||
this._startTs = Date.now();
|
||||
this.startTs = Date.now();
|
||||
return this;
|
||||
} else {
|
||||
return this.start();
|
||||
@ -103,9 +107,9 @@ export default class Timer {
|
||||
*/
|
||||
abort() {
|
||||
if (this.isRunning()) {
|
||||
clearTimeout(this._timerHandle);
|
||||
this._reject(new Error("Timer was aborted."));
|
||||
this._setNotStarted();
|
||||
clearTimeout(this.timerHandle);
|
||||
this.reject(new Error("Timer was aborted."));
|
||||
this.setNotStarted();
|
||||
}
|
||||
return this;
|
||||
}
|
||||
@ -116,10 +120,10 @@ export default class Timer {
|
||||
*@return {Promise}
|
||||
*/
|
||||
finished() {
|
||||
return this._promise;
|
||||
return this.promise;
|
||||
}
|
||||
|
||||
isRunning() {
|
||||
return this._timerHandle !== null;
|
||||
return this.timerHandle !== null;
|
||||
}
|
||||
}
|
@ -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");
|
||||
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,
|
||||
* though this does work best if given a large array to downsample to a much smaller array.
|
||||
* @param {number[]} input The input array to downsample.
|
||||
* Quickly resample an array to have less/more data points. If an input which is larger
|
||||
* than the desired size is provided, it will be downsampled. Similarly, if the input
|
||||
* 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.
|
||||
* @returns {number[]} The downsampled array.
|
||||
* @returns {number[]} The resampled array.
|
||||
*/
|
||||
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
|
||||
const everyNth = Math.round(input.length / points);
|
||||
const samples: number[] = [];
|
||||
for (let i = 0; i < input.length; i += everyNth) {
|
||||
samples.push(input[i]);
|
||||
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);
|
||||
for (let i = 0; i < input.length; i += everyNth) {
|
||||
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) {
|
||||
samples.push(input[input.length - 1]);
|
||||
}
|
||||
|
||||
// Sanity trim, just in case
|
||||
if (samples.length > points) {
|
||||
samples = samples.slice(0, points);
|
||||
}
|
||||
|
||||
return samples;
|
||||
}
|
||||
|
||||
@ -178,6 +202,13 @@ export class GroupedArray<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.
|
||||
* @param keyOrder The key order.
|
||||
|
@ -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");
|
||||
you may not use this file except in compliance with the License.
|
||||
@ -19,11 +19,23 @@ limitations under the License.
|
||||
* @param e The enum.
|
||||
* @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);
|
||||
return keys
|
||||
.filter(k => ['string', 'number'].includes(typeof(e[k])))
|
||||
.map(k => e[k]);
|
||||
const values: (string | number)[] = [];
|
||||
for (const key of keys) {
|
||||
const value = e[key];
|
||||
if (Number.isFinite(value) || e[value.toString()] !== Number(key)) {
|
||||
values.push(value);
|
||||
}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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");
|
||||
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 {
|
||||
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;
|
||||
}
|
||||
|
@ -20,31 +20,31 @@ import PermalinkConstructor, {PermalinkParts} from "./PermalinkConstructor";
|
||||
* Generates permalinks that self-reference the running webapp
|
||||
*/
|
||||
export default class ElementPermalinkConstructor extends PermalinkConstructor {
|
||||
_elementUrl: string;
|
||||
private elementUrl: string;
|
||||
|
||||
constructor(elementUrl: string) {
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
return `${this._elementUrl}/#/room/${roomIdOrAlias}${this.encodeServerCandidates(serverCandidates)}`;
|
||||
forRoom(roomIdOrAlias: string, serverCandidates?: string[]): string {
|
||||
return `${this.elementUrl}/#/room/${roomIdOrAlias}${this.encodeServerCandidates(serverCandidates)}`;
|
||||
}
|
||||
|
||||
forUser(userId: string): string {
|
||||
return `${this._elementUrl}/#/user/${userId}`;
|
||||
return `${this.elementUrl}/#/user/${userId}`;
|
||||
}
|
||||
|
||||
forGroup(groupId: string): string {
|
||||
return `${this._elementUrl}/#/group/${groupId}`;
|
||||
return `${this.elementUrl}/#/group/${groupId}`;
|
||||
}
|
||||
|
||||
forEntity(entityId: string): string {
|
||||
@ -58,11 +58,11 @@ export default class ElementPermalinkConstructor extends PermalinkConstructor {
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
encodeServerCandidates(candidates: string[]) {
|
||||
encodeServerCandidates(candidates?: string[]) {
|
||||
if (!candidates || candidates.length === 0) return '';
|
||||
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
|
||||
// Adapted for Element's URL format
|
||||
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");
|
||||
}
|
||||
|
||||
const parts = fullUrl.substring(`${this._elementUrl}/#/`.length);
|
||||
const parts = fullUrl.substring(`${this.elementUrl}/#/`.length);
|
||||
return ElementPermalinkConstructor.parseAppRoute(parts);
|
||||
}
|
||||
|
@ -17,6 +17,9 @@ limitations under the License.
|
||||
import isIp from "is-ip";
|
||||
import * as utils from "matrix-js-sdk/src/utils";
|
||||
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 SpecPermalinkConstructor, {baseUrl as matrixtoBaseUrl} from "./SpecPermalinkConstructor";
|
||||
@ -74,29 +77,35 @@ const MAX_SERVER_CANDIDATES = 3;
|
||||
// the list and magically have the link work.
|
||||
|
||||
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
|
||||
// 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.
|
||||
constructor(room, roomId = null) {
|
||||
this._room = room;
|
||||
this._roomId = room ? room.roomId : roomId;
|
||||
this._highestPlUserId = null;
|
||||
this._populationMap = null;
|
||||
this._bannedHostsRegexps = null;
|
||||
this._allowedHostsRegexps = null;
|
||||
constructor(room: Room, roomId: string = null) {
|
||||
this.room = room;
|
||||
this.roomId = room ? room.roomId : roomId;
|
||||
this.highestPlUserId = null;
|
||||
this.populationMap = null;
|
||||
this.bannedHostsRegexps = null;
|
||||
this.allowedHostsRegexps = 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");
|
||||
}
|
||||
|
||||
this.onMembership = this.onMembership.bind(this);
|
||||
this.onRoomState = this.onRoomState.bind(this);
|
||||
}
|
||||
|
||||
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
|
||||
// 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
|
||||
@ -104,23 +113,23 @@ export class RoomPermalinkCreator {
|
||||
console.warn("Tried to load a permalink creator with no room state");
|
||||
return;
|
||||
}
|
||||
this._updateAllowedServers();
|
||||
this._updateHighestPlUser();
|
||||
this._updatePopulationMap();
|
||||
this._updateServerCandidates();
|
||||
this.updateAllowedServers();
|
||||
this.updateHighestPlUser();
|
||||
this.updatePopulationMap();
|
||||
this.updateServerCandidates();
|
||||
}
|
||||
|
||||
start() {
|
||||
this.load();
|
||||
this._room.on("RoomMember.membership", this.onMembership);
|
||||
this._room.on("RoomState.events", this.onRoomState);
|
||||
this._started = true;
|
||||
this.room.on("RoomMember.membership", this.onMembership);
|
||||
this.room.on("RoomState.events", this.onRoomState);
|
||||
this.started = true;
|
||||
}
|
||||
|
||||
stop() {
|
||||
this._room.removeListener("RoomMember.membership", this.onMembership);
|
||||
this._room.removeListener("RoomState.events", this.onRoomState);
|
||||
this._started = false;
|
||||
this.room.removeListener("RoomMember.membership", this.onMembership);
|
||||
this.room.removeListener("RoomState.events", this.onRoomState);
|
||||
this.started = false;
|
||||
}
|
||||
|
||||
get serverCandidates() {
|
||||
@ -128,44 +137,44 @@ export class RoomPermalinkCreator {
|
||||
}
|
||||
|
||||
isStarted() {
|
||||
return this._started;
|
||||
return this.started;
|
||||
}
|
||||
|
||||
forEvent(eventId) {
|
||||
return getPermalinkConstructor().forEvent(this._roomId, eventId, this._serverCandidates);
|
||||
forEvent(eventId: string): string {
|
||||
return getPermalinkConstructor().forEvent(this.roomId, eventId, this._serverCandidates);
|
||||
}
|
||||
|
||||
forShareableRoom() {
|
||||
if (this._room) {
|
||||
forShareableRoom(): string {
|
||||
if (this.room) {
|
||||
// Prefer to use canonical alias for permalink if possible
|
||||
const alias = this._room.getCanonicalAlias();
|
||||
const alias = this.room.getCanonicalAlias();
|
||||
if (alias) {
|
||||
return getPermalinkConstructor().forRoom(alias, this._serverCandidates);
|
||||
}
|
||||
}
|
||||
return getPermalinkConstructor().forRoom(this._roomId, this._serverCandidates);
|
||||
return getPermalinkConstructor().forRoom(this.roomId, this._serverCandidates);
|
||||
}
|
||||
|
||||
forRoom() {
|
||||
return getPermalinkConstructor().forRoom(this._roomId, this._serverCandidates);
|
||||
forRoom(): string {
|
||||
return getPermalinkConstructor().forRoom(this.roomId, this._serverCandidates);
|
||||
}
|
||||
|
||||
onRoomState(event) {
|
||||
private onRoomState = (event: MatrixEvent) => {
|
||||
switch (event.getType()) {
|
||||
case "m.room.server_acl":
|
||||
this._updateAllowedServers();
|
||||
this._updateHighestPlUser();
|
||||
this._updatePopulationMap();
|
||||
this._updateServerCandidates();
|
||||
case EventType.RoomServerAcl:
|
||||
this.updateAllowedServers();
|
||||
this.updateHighestPlUser();
|
||||
this.updatePopulationMap();
|
||||
this.updateServerCandidates();
|
||||
return;
|
||||
case "m.room.power_levels":
|
||||
this._updateHighestPlUser();
|
||||
this._updateServerCandidates();
|
||||
case EventType.RoomPowerLevels:
|
||||
this.updateHighestPlUser();
|
||||
this.updateServerCandidates();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
onMembership(evt, member, oldMembership) {
|
||||
private onMembership = (evt: MatrixEvent, member: RoomMember, oldMembership: string) => {
|
||||
const userId = member.userId;
|
||||
const membership = member.membership;
|
||||
const serverName = getServerName(userId);
|
||||
@ -173,17 +182,17 @@ export class RoomPermalinkCreator {
|
||||
const hasLeft = oldMembership === "join" && membership !== "join";
|
||||
|
||||
if (hasLeft) {
|
||||
this._populationMap[serverName]--;
|
||||
this.populationMap[serverName]--;
|
||||
} else if (hasJoined) {
|
||||
this._populationMap[serverName]++;
|
||||
this.populationMap[serverName]++;
|
||||
}
|
||||
|
||||
this._updateHighestPlUser();
|
||||
this._updateServerCandidates();
|
||||
this.updateHighestPlUser();
|
||||
this.updateServerCandidates();
|
||||
}
|
||||
|
||||
_updateHighestPlUser() {
|
||||
const plEvent = this._room.currentState.getStateEvents("m.room.power_levels", "");
|
||||
private updateHighestPlUser() {
|
||||
const plEvent = this.room.currentState.getStateEvents("m.room.power_levels", "");
|
||||
if (plEvent) {
|
||||
const content = plEvent.getContent();
|
||||
if (content) {
|
||||
@ -191,14 +200,14 @@ export class RoomPermalinkCreator {
|
||||
if (users) {
|
||||
const entries = Object.entries(users);
|
||||
const allowedEntries = entries.filter(([userId]) => {
|
||||
const member = this._room.getMember(userId);
|
||||
const member = this.room.getMember(userId);
|
||||
if (!member || member.membership !== "join") {
|
||||
return false;
|
||||
}
|
||||
const serverName = getServerName(userId);
|
||||
return !isHostnameIpAddress(serverName) &&
|
||||
!isHostInRegex(serverName, this._bannedHostsRegexps) &&
|
||||
isHostInRegex(serverName, this._allowedHostsRegexps);
|
||||
!isHostInRegex(serverName, this.bannedHostsRegexps) &&
|
||||
isHostInRegex(serverName, this.allowedHostsRegexps);
|
||||
});
|
||||
const maxEntry = allowedEntries.reduce((max, entry) => {
|
||||
return (entry[1] > max[1]) ? entry : max;
|
||||
@ -206,20 +215,20 @@ export class RoomPermalinkCreator {
|
||||
const [userId, powerLevel] = maxEntry;
|
||||
// object wasn't empty, and max entry wasn't a demotion from the default
|
||||
if (userId !== null && powerLevel >= 50) {
|
||||
this._highestPlUserId = userId;
|
||||
this.highestPlUserId = userId;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
this._highestPlUserId = null;
|
||||
this.highestPlUserId = null;
|
||||
}
|
||||
|
||||
_updateAllowedServers() {
|
||||
private updateAllowedServers() {
|
||||
const bannedHostsRegexps = [];
|
||||
let allowedHostsRegexps = [new RegExp(".*")]; // default allow everyone
|
||||
if (this._room.currentState) {
|
||||
const aclEvent = this._room.currentState.getStateEvents("m.room.server_acl", "");
|
||||
if (this.room.currentState) {
|
||||
const aclEvent = this.room.currentState.getStateEvents("m.room.server_acl", "");
|
||||
if (aclEvent && aclEvent.getContent()) {
|
||||
const getRegex = (hostname) => new RegExp("^" + utils.globToRegexp(hostname, false) + "$");
|
||||
|
||||
@ -231,35 +240,35 @@ export class RoomPermalinkCreator {
|
||||
allowed.forEach(h => allowedHostsRegexps.push(getRegex(h)));
|
||||
}
|
||||
}
|
||||
this._bannedHostsRegexps = bannedHostsRegexps;
|
||||
this._allowedHostsRegexps = allowedHostsRegexps;
|
||||
this.bannedHostsRegexps = bannedHostsRegexps;
|
||||
this.allowedHostsRegexps = allowedHostsRegexps;
|
||||
}
|
||||
|
||||
_updatePopulationMap() {
|
||||
private updatePopulationMap() {
|
||||
const populationMap: { [server: string]: number } = {};
|
||||
for (const member of this._room.getJoinedMembers()) {
|
||||
for (const member of this.room.getJoinedMembers()) {
|
||||
const serverName = getServerName(member.userId);
|
||||
if (!populationMap[serverName]) {
|
||||
populationMap[serverName] = 0;
|
||||
}
|
||||
populationMap[serverName]++;
|
||||
}
|
||||
this._populationMap = populationMap;
|
||||
this.populationMap = populationMap;
|
||||
}
|
||||
|
||||
_updateServerCandidates() {
|
||||
private updateServerCandidates() {
|
||||
let candidates = [];
|
||||
if (this._highestPlUserId) {
|
||||
candidates.push(getServerName(this._highestPlUserId));
|
||||
if (this.highestPlUserId) {
|
||||
candidates.push(getServerName(this.highestPlUserId));
|
||||
}
|
||||
|
||||
const serversByPopulation = Object.keys(this._populationMap)
|
||||
.sort((a, b) => this._populationMap[b] - this._populationMap[a])
|
||||
const serversByPopulation = Object.keys(this.populationMap)
|
||||
.sort((a, b) => this.populationMap[b] - this.populationMap[a])
|
||||
.filter(a => {
|
||||
return !candidates.includes(a) &&
|
||||
!isHostnameIpAddress(a) &&
|
||||
!isHostInRegex(a, this._bannedHostsRegexps) &&
|
||||
isHostInRegex(a, this._allowedHostsRegexps);
|
||||
!isHostInRegex(a, this.bannedHostsRegexps) &&
|
||||
isHostInRegex(a, this.allowedHostsRegexps);
|
||||
});
|
||||
|
||||
const remainingServers = serversByPopulation.slice(0, MAX_SERVER_CANDIDATES - candidates.length);
|
||||
@ -273,11 +282,11 @@ export function makeGenericPermalink(entityId: string): string {
|
||||
return getPermalinkConstructor().forEntity(entityId);
|
||||
}
|
||||
|
||||
export function makeUserPermalink(userId) {
|
||||
export function makeUserPermalink(userId: string): string {
|
||||
return getPermalinkConstructor().forUser(userId);
|
||||
}
|
||||
|
||||
export function makeRoomPermalink(roomId) {
|
||||
export function makeRoomPermalink(roomId: string): string {
|
||||
if (!roomId) {
|
||||
throw new Error("can't permalink a falsey roomId");
|
||||
}
|
||||
@ -296,7 +305,7 @@ export function makeRoomPermalink(roomId) {
|
||||
return permalinkCreator.forRoom();
|
||||
}
|
||||
|
||||
export function makeGroupPermalink(groupId) {
|
||||
export function makeGroupPermalink(groupId: string): string {
|
||||
return getPermalinkConstructor().forGroup(groupId);
|
||||
}
|
||||
|
||||
@ -428,24 +437,24 @@ export function parseAppLocalLink(localLink: string): PermalinkParts {
|
||||
return null;
|
||||
}
|
||||
|
||||
function getServerName(userId) {
|
||||
function getServerName(userId: string): string {
|
||||
return userId.split(":").splice(1).join(":");
|
||||
}
|
||||
|
||||
function getHostnameFromMatrixDomain(domain) {
|
||||
function getHostnameFromMatrixDomain(domain: string): string {
|
||||
if (!domain) return null;
|
||||
return new URL(`https://${domain}`).hostname;
|
||||
}
|
||||
|
||||
function isHostInRegex(hostname, regexps) {
|
||||
function isHostInRegex(hostname: string, regexps: RegExp[]) {
|
||||
hostname = getHostnameFromMatrixDomain(hostname);
|
||||
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;
|
||||
}
|
||||
|
||||
function isHostnameIpAddress(hostname) {
|
||||
function isHostnameIpAddress(hostname: string): boolean {
|
||||
hostname = getHostnameFromMatrixDomain(hostname);
|
||||
if (!hostname) return false;
|
||||
|
@ -29,7 +29,7 @@ describe('ScalarAuthClient', function() {
|
||||
it('should request a new token if the old one fails', async function() {
|
||||
const sac = new ScalarAuthClient();
|
||||
|
||||
sac._getAccountName = jest.fn((arg) => {
|
||||
sac.getAccountName = jest.fn((arg) => {
|
||||
switch (arg) {
|
||||
case "brokentoken":
|
||||
return Promise.reject({
|
||||
|
@ -29,7 +29,10 @@ function waitForRoomListStoreUpdate() {
|
||||
|
||||
describe('RoomList', () => {
|
||||
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) {
|
||||
Object.assign(room, opts);
|
||||
}
|
||||
|
714
test/stores/SpaceStore-test.ts
Normal file
714
test/stores/SpaceStore-test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
@ -79,6 +79,17 @@ export function createTestClient() {
|
||||
generateClientSecret: () => "t35tcl1Ent5ECr3T",
|
||||
isGuest: () => 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.room The event.room_id
|
||||
* @param {string} opts.user The event.user_id
|
||||
* @param {string} opts.skey Optional. The state key (auto inserts empty string)
|
||||
* @param {Number} opts.ts Optional. Timestamp for the event
|
||||
* @param {string=} opts.skey Optional. The state key (auto inserts empty string)
|
||||
* @param {number=} opts.ts Optional. Timestamp for the event
|
||||
* @param {Object} opts.content The event.content
|
||||
* @param {boolean} opts.event True to make a MatrixEvent.
|
||||
* @return {Object} a JSON object representing this event.
|
||||
@ -224,7 +235,7 @@ export function mkStubRoom(roomId = null) {
|
||||
hasMembershipState: () => null,
|
||||
getVersion: () => '1',
|
||||
shouldUpgradeToVersion: () => null,
|
||||
getMyMembership: () => "join",
|
||||
getMyMembership: jest.fn().mockReturnValue("join"),
|
||||
maySendMessage: jest.fn().mockReturnValue(true),
|
||||
currentState: {
|
||||
getStateEvents: jest.fn(),
|
||||
@ -233,17 +244,17 @@ export function mkStubRoom(roomId = null) {
|
||||
maySendEvent: jest.fn().mockReturnValue(true),
|
||||
members: [],
|
||||
},
|
||||
tags: {
|
||||
"m.favourite": {
|
||||
order: 0.5,
|
||||
},
|
||||
},
|
||||
tags: {},
|
||||
setBlacklistUnverifiedDevices: jest.fn(),
|
||||
on: jest.fn(),
|
||||
removeListener: jest.fn(),
|
||||
getDMInviter: jest.fn(),
|
||||
getAvatarUrl: () => 'mxc://avatar.url/room.png',
|
||||
getMxcAvatarUrl: () => 'mxc://avatar.url/room.png',
|
||||
isSpaceRoom: jest.fn(() => false),
|
||||
getUnreadNotificationCount: jest.fn(() => 0),
|
||||
getEventReadUpTo: jest.fn(() => null),
|
||||
timeline: [],
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -128,7 +128,7 @@ describe("shieldStatusForMembership self-trust behaviour", function() {
|
||||
|
||||
describe("shieldStatusForMembership other-trust behaviour", function() {
|
||||
beforeAll(() => {
|
||||
DMRoomMap._sharedInstance = {
|
||||
DMRoomMap.sharedInstance = {
|
||||
getUserIdForRoomId: (roomId) => roomId === "DM" ? "@any:h" : null,
|
||||
};
|
||||
});
|
||||
|
@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {Singleflight} from "../src/utils/Singleflight";
|
||||
import {Singleflight} from "../../src/utils/Singleflight";
|
||||
|
||||
describe('Singleflight', () => {
|
||||
afterEach(() => {
|
294
test/utils/arrays-test.ts
Normal file
294
test/utils/arrays-test.ts
Normal 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
67
test/utils/enums-test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
77
test/utils/iterables-test.ts
Normal file
77
test/utils/iterables-test.ts
Normal 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
245
test/utils/maps-test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
163
test/utils/numbers-test.ts
Normal file
163
test/utils/numbers-test.ts
Normal file
@ -0,0 +1,163 @@
|
||||
/*
|
||||
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 {clamp, defaultNumber, percentageOf, percentageWithin, sum} from "../../src/utils/numbers";
|
||||
|
||||
describe('numbers', () => {
|
||||
describe('defaultNumber', () => {
|
||||
it('should use the default when the input is not a number', () => {
|
||||
const def = 42;
|
||||
|
||||
let result = defaultNumber(null, def);
|
||||
expect(result).toBe(def);
|
||||
|
||||
result = defaultNumber(undefined, def);
|
||||
expect(result).toBe(def);
|
||||
|
||||
result = defaultNumber(Number.NaN, def);
|
||||
expect(result).toBe(def);
|
||||
});
|
||||
|
||||
it('should use the number when it is a number', () => {
|
||||
const input = 24;
|
||||
const def = 42;
|
||||
const result = defaultNumber(input, def);
|
||||
expect(result).toBe(input);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clamp', () => {
|
||||
it('should clamp high numbers', () => {
|
||||
const input = 101;
|
||||
const min = 0;
|
||||
const max = 100;
|
||||
const result = clamp(input, min, max);
|
||||
expect(result).toBe(max);
|
||||
});
|
||||
|
||||
it('should clamp low numbers', () => {
|
||||
const input = -1;
|
||||
const min = 0;
|
||||
const max = 100;
|
||||
const result = clamp(input, min, max);
|
||||
expect(result).toBe(min);
|
||||
});
|
||||
|
||||
it('should not clamp numbers in range', () => {
|
||||
const input = 50;
|
||||
const min = 0;
|
||||
const max = 100;
|
||||
const result = clamp(input, min, max);
|
||||
expect(result).toBe(input);
|
||||
});
|
||||
|
||||
it('should clamp floats', () => {
|
||||
const min = -0.10;
|
||||
const max = +0.10;
|
||||
|
||||
let result = clamp(-1.2, min, max);
|
||||
expect(result).toBe(min);
|
||||
|
||||
result = clamp(1.2, min, max);
|
||||
expect(result).toBe(max);
|
||||
|
||||
result = clamp(0.02, min, max);
|
||||
expect(result).toBe(0.02);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sum', () => {
|
||||
it('should sum', () => { // duh
|
||||
const result = sum(1, 2, 1, 4);
|
||||
expect(result).toBe(8);
|
||||
});
|
||||
});
|
||||
|
||||
describe('percentageWithin', () => {
|
||||
it('should work within 0-100', () => {
|
||||
const result = percentageWithin(0.4, 0, 100);
|
||||
expect(result).toBe(40);
|
||||
});
|
||||
|
||||
it('should work within 0-100 when pct > 1', () => {
|
||||
const result = percentageWithin(1.4, 0, 100);
|
||||
expect(result).toBe(140);
|
||||
});
|
||||
|
||||
it('should work within 0-100 when pct < 0', () => {
|
||||
const result = percentageWithin(-1.4, 0, 100);
|
||||
expect(result).toBe(-140);
|
||||
});
|
||||
|
||||
it('should work with ranges other than 0-100', () => {
|
||||
const result = percentageWithin(0.4, 10, 20);
|
||||
expect(result).toBe(14);
|
||||
});
|
||||
|
||||
it('should work with ranges other than 0-100 when pct > 1', () => {
|
||||
const result = percentageWithin(1.4, 10, 20);
|
||||
expect(result).toBe(24);
|
||||
});
|
||||
|
||||
it('should work with ranges other than 0-100 when pct < 0', () => {
|
||||
const result = percentageWithin(-1.4, 10, 20);
|
||||
expect(result).toBe(-4);
|
||||
});
|
||||
|
||||
it('should work with floats', () => {
|
||||
const result = percentageWithin(0.4, 10.2, 20.4);
|
||||
expect(result).toBe(14.28);
|
||||
});
|
||||
});
|
||||
|
||||
// These are the inverse of percentageWithin
|
||||
describe('percentageOf', () => {
|
||||
it('should work within 0-100', () => {
|
||||
const result = percentageOf(40, 0, 100);
|
||||
expect(result).toBe(0.4);
|
||||
});
|
||||
|
||||
it('should work within 0-100 when val > 100', () => {
|
||||
const result = percentageOf(140, 0, 100);
|
||||
expect(result).toBe(1.40);
|
||||
});
|
||||
|
||||
it('should work within 0-100 when val < 0', () => {
|
||||
const result = percentageOf(-140, 0, 100);
|
||||
expect(result).toBe(-1.40);
|
||||
});
|
||||
|
||||
it('should work with ranges other than 0-100', () => {
|
||||
const result = percentageOf(14, 10, 20);
|
||||
expect(result).toBe(0.4);
|
||||
});
|
||||
|
||||
it('should work with ranges other than 0-100 when val > 100', () => {
|
||||
const result = percentageOf(24, 10, 20);
|
||||
expect(result).toBe(1.4);
|
||||
});
|
||||
|
||||
it('should work with ranges other than 0-100 when val < 0', () => {
|
||||
const result = percentageOf(-4, 10, 20);
|
||||
expect(result).toBe(-1.4);
|
||||
});
|
||||
|
||||
it('should work with floats', () => {
|
||||
const result = percentageOf(14.28, 10.2, 20.4);
|
||||
expect(result).toBe(0.4);
|
||||
});
|
||||
});
|
||||
});
|
262
test/utils/objects-test.ts
Normal file
262
test/utils/objects-test.ts
Normal file
@ -0,0 +1,262 @@
|
||||
/*
|
||||
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 {
|
||||
objectClone,
|
||||
objectDiff,
|
||||
objectExcluding,
|
||||
objectFromEntries,
|
||||
objectHasDiff,
|
||||
objectKeyChanges,
|
||||
objectShallowClone,
|
||||
objectWithOnly,
|
||||
} from "../../src/utils/objects";
|
||||
|
||||
describe('objects', () => {
|
||||
describe('objectExcluding', () => {
|
||||
it('should exclude the given properties', () => {
|
||||
const input = {hello: "world", test: true};
|
||||
const output = {hello: "world"};
|
||||
const props = ["test", "doesnotexist"]; // we also make sure it doesn't explode on missing props
|
||||
const result = objectExcluding(input, <any>props); // any is to test the missing prop
|
||||
expect(result).toBeDefined();
|
||||
expect(result).toMatchObject(output);
|
||||
});
|
||||
});
|
||||
|
||||
describe('objectWithOnly', () => {
|
||||
it('should exclusively use the given properties', () => {
|
||||
const input = {hello: "world", test: true};
|
||||
const output = {hello: "world"};
|
||||
const props = ["hello", "doesnotexist"]; // we also make sure it doesn't explode on missing props
|
||||
const result = objectWithOnly(input, <any>props); // any is to test the missing prop
|
||||
expect(result).toBeDefined();
|
||||
expect(result).toMatchObject(output);
|
||||
});
|
||||
});
|
||||
|
||||
describe('objectShallowClone', () => {
|
||||
it('should create a new object', () => {
|
||||
const input = {test: 1};
|
||||
const result = objectShallowClone(input);
|
||||
expect(result).toBeDefined();
|
||||
expect(result).not.toBe(input);
|
||||
expect(result).toMatchObject(input);
|
||||
});
|
||||
|
||||
it('should only clone the top level properties', () => {
|
||||
const input = {a: 1, b: {c: 2}};
|
||||
const result = objectShallowClone(input);
|
||||
expect(result).toBeDefined();
|
||||
expect(result).toMatchObject(input);
|
||||
expect(result.b).toBe(input.b);
|
||||
});
|
||||
|
||||
it('should support custom clone functions', () => {
|
||||
const input = {a: 1, b: 2};
|
||||
const output = {a: 4, b: 8};
|
||||
const result = objectShallowClone(input, (k, v) => {
|
||||
// XXX: inverted expectation for ease of assertion
|
||||
expect(Object.keys(input)).toContain(k);
|
||||
|
||||
return v * 4;
|
||||
});
|
||||
expect(result).toBeDefined();
|
||||
expect(result).toMatchObject(output);
|
||||
});
|
||||
});
|
||||
|
||||
describe('objectHasDiff', () => {
|
||||
it('should return false for the same pointer', () => {
|
||||
const a = {};
|
||||
const result = objectHasDiff(a, a);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true if keys for A > keys for B', () => {
|
||||
const a = {a: 1, b: 2};
|
||||
const b = {a: 1};
|
||||
const result = objectHasDiff(a, b);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true if keys for A < keys for B', () => {
|
||||
const a = {a: 1};
|
||||
const b = {a: 1, b: 2};
|
||||
const result = objectHasDiff(a, b);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false if the objects are the same but different pointers', () => {
|
||||
const a = {a: 1, b: 2};
|
||||
const b = {a: 1, b: 2};
|
||||
const result = objectHasDiff(a, b);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should consider pointers when testing values', () => {
|
||||
const a = {a: {}, b: 2}; // `{}` is shorthand for `new Object()`
|
||||
const b = {a: {}, b: 2};
|
||||
const result = objectHasDiff(a, b);
|
||||
expect(result).toBe(true); // even though the keys are the same, the value pointers vary
|
||||
});
|
||||
});
|
||||
|
||||
describe('objectDiff', () => {
|
||||
it('should return empty sets for the same object', () => {
|
||||
const a = {a: 1, b: 2};
|
||||
const b = {a: 1, b: 2};
|
||||
const result = objectDiff(a, b);
|
||||
expect(result).toBeDefined();
|
||||
expect(result.changed).toBeDefined();
|
||||
expect(result.added).toBeDefined();
|
||||
expect(result.removed).toBeDefined();
|
||||
expect(result.changed).toHaveLength(0);
|
||||
expect(result.added).toHaveLength(0);
|
||||
expect(result.removed).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should return empty sets for the same object pointer', () => {
|
||||
const a = {a: 1, b: 2};
|
||||
const result = objectDiff(a, a);
|
||||
expect(result).toBeDefined();
|
||||
expect(result.changed).toBeDefined();
|
||||
expect(result.added).toBeDefined();
|
||||
expect(result.removed).toBeDefined();
|
||||
expect(result.changed).toHaveLength(0);
|
||||
expect(result.added).toHaveLength(0);
|
||||
expect(result.removed).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should indicate when property changes are made', () => {
|
||||
const a = {a: 1, b: 2};
|
||||
const b = {a: 11, b: 2};
|
||||
const result = objectDiff(a, b);
|
||||
expect(result.changed).toBeDefined();
|
||||
expect(result.added).toBeDefined();
|
||||
expect(result.removed).toBeDefined();
|
||||
expect(result.changed).toHaveLength(1);
|
||||
expect(result.added).toHaveLength(0);
|
||||
expect(result.removed).toHaveLength(0);
|
||||
expect(result.changed).toEqual(['a']);
|
||||
});
|
||||
|
||||
it('should indicate when properties are added', () => {
|
||||
const a = {a: 1, b: 2};
|
||||
const b = {a: 1, b: 2, c: 3};
|
||||
const result = objectDiff(a, b);
|
||||
expect(result.changed).toBeDefined();
|
||||
expect(result.added).toBeDefined();
|
||||
expect(result.removed).toBeDefined();
|
||||
expect(result.changed).toHaveLength(0);
|
||||
expect(result.added).toHaveLength(1);
|
||||
expect(result.removed).toHaveLength(0);
|
||||
expect(result.added).toEqual(['c']);
|
||||
});
|
||||
|
||||
it('should indicate when properties are removed', () => {
|
||||
const a = {a: 1, b: 2};
|
||||
const b = {a: 1};
|
||||
const result = objectDiff(a, b);
|
||||
expect(result.changed).toBeDefined();
|
||||
expect(result.added).toBeDefined();
|
||||
expect(result.removed).toBeDefined();
|
||||
expect(result.changed).toHaveLength(0);
|
||||
expect(result.added).toHaveLength(0);
|
||||
expect(result.removed).toHaveLength(1);
|
||||
expect(result.removed).toEqual(['b']);
|
||||
});
|
||||
|
||||
it('should indicate when multiple aspects change', () => {
|
||||
const a = {a: 1, b: 2, c: 3};
|
||||
const b: (typeof a | {d: number}) = {a: 1, b: 22, d: 4};
|
||||
const result = objectDiff(a, b);
|
||||
expect(result.changed).toBeDefined();
|
||||
expect(result.added).toBeDefined();
|
||||
expect(result.removed).toBeDefined();
|
||||
expect(result.changed).toHaveLength(1);
|
||||
expect(result.added).toHaveLength(1);
|
||||
expect(result.removed).toHaveLength(1);
|
||||
expect(result.changed).toEqual(['b']);
|
||||
expect(result.removed).toEqual(['c']);
|
||||
expect(result.added).toEqual(['d']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('objectKeyChanges', () => {
|
||||
it('should return an empty set if no properties changed', () => {
|
||||
const a = {a: 1, b: 2};
|
||||
const b = {a: 1, b: 2};
|
||||
const result = objectKeyChanges(a, b);
|
||||
expect(result).toBeDefined();
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should return an empty set if no properties changed for the same pointer', () => {
|
||||
const a = {a: 1, b: 2};
|
||||
const result = objectKeyChanges(a, a);
|
||||
expect(result).toBeDefined();
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should return properties which were changed, added, or removed', () => {
|
||||
const a = {a: 1, b: 2, c: 3};
|
||||
const b: (typeof a | {d: number}) = {a: 1, b: 22, d: 4};
|
||||
const result = objectKeyChanges(a, b);
|
||||
expect(result).toBeDefined();
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result).toEqual(['c', 'd', 'b']); // order isn't important, but the test cares
|
||||
});
|
||||
});
|
||||
|
||||
describe('objectClone', () => {
|
||||
it('should deep clone an object', () => {
|
||||
const a = {
|
||||
hello: "world",
|
||||
test: {
|
||||
another: "property",
|
||||
test: 42,
|
||||
third: {
|
||||
prop: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
const result = objectClone(a);
|
||||
expect(result).toBeDefined();
|
||||
expect(result).not.toBe(a);
|
||||
expect(result).toMatchObject(a);
|
||||
expect(result.test).not.toBe(a.test);
|
||||
expect(result.test.third).not.toBe(a.test.third);
|
||||
});
|
||||
});
|
||||
|
||||
describe('objectFromEntries', () => {
|
||||
it('should create an object from an array of entries', () => {
|
||||
const output = {a: 1, b: 2, c: 3};
|
||||
const result = objectFromEntries(Object.entries(output));
|
||||
expect(result).toBeDefined();
|
||||
expect(result).toMatchObject(output);
|
||||
});
|
||||
|
||||
it('should maintain pointers in values', () => {
|
||||
const output = {a: {}, b: 2, c: 3};
|
||||
const result = objectFromEntries(Object.entries(output));
|
||||
expect(result).toBeDefined();
|
||||
expect(result).toMatchObject(output);
|
||||
expect(result['a']).toBe(output.a);
|
||||
});
|
||||
});
|
||||
});
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user