Merge remote-tracking branch 'upstream/develop' into feature/image-view-load-anim/18186

This commit is contained in:
Šimon Brandner 2021-09-21 17:36:26 +02:00
commit 7022ab4f8a
No known key found for this signature in database
GPG key ID: 55C211A1226CB17D
144 changed files with 4658 additions and 2660 deletions

View file

@ -5,6 +5,8 @@ on:
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
env:
PR_NUMBER: ${{github.event.number}}
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- name: Build - name: Build

View file

@ -5,6 +5,8 @@ on:
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
env:
PR_NUMBER: ${{github.event.number}}
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- uses: c-hive/gha-yarn-cache@v2 - uses: c-hive/gha-yarn-cache@v2

View file

@ -1,3 +1,70 @@
Changes in [3.30.0](https://github.com/vector-im/element-desktop/releases/tag/v3.30.0) (2021-09-14)
===================================================================================================
## ✨ Features
* Add bubble highlight styling ([\#6582](https://github.com/matrix-org/matrix-react-sdk/pull/6582)). Fixes vector-im/element-web#18295 and vector-im/element-web#18295. Contributed by [SimonBrandner](https://github.com/SimonBrandner).
* [Release] Add config option to turn on in-room event sending timing metrics ([\#6773](https://github.com/matrix-org/matrix-react-sdk/pull/6773)).
* Create narrow mode for Composer ([\#6682](https://github.com/matrix-org/matrix-react-sdk/pull/6682)). Fixes vector-im/element-web#18533 and vector-im/element-web#18533.
* Prefer matrix.to alias links over room id in spaces & share ([\#6745](https://github.com/matrix-org/matrix-react-sdk/pull/6745)). Fixes vector-im/element-web#18796 and vector-im/element-web#18796.
* Stop automatic playback of voice messages if a non-voice message is encountered ([\#6728](https://github.com/matrix-org/matrix-react-sdk/pull/6728)). Fixes vector-im/element-web#18850 and vector-im/element-web#18850.
* Show call length during a call ([\#6700](https://github.com/matrix-org/matrix-react-sdk/pull/6700)). Fixes vector-im/element-web#18566 and vector-im/element-web#18566. Contributed by [SimonBrandner](https://github.com/SimonBrandner).
* Serialize and retry mass-leave when leaving space ([\#6737](https://github.com/matrix-org/matrix-react-sdk/pull/6737)). Fixes vector-im/element-web#18789 and vector-im/element-web#18789.
* Improve form handling in and around space creation ([\#6739](https://github.com/matrix-org/matrix-react-sdk/pull/6739)). Fixes vector-im/element-web#18775 and vector-im/element-web#18775.
* Split autoplay GIFs and videos into different settings ([\#6726](https://github.com/matrix-org/matrix-react-sdk/pull/6726)). Fixes vector-im/element-web#5771 and vector-im/element-web#5771. Contributed by [SimonBrandner](https://github.com/SimonBrandner).
* Add autoplay for voice messages ([\#6710](https://github.com/matrix-org/matrix-react-sdk/pull/6710)). Fixes vector-im/element-web#18804, vector-im/element-web#18715, vector-im/element-web#18714 vector-im/element-web#17961 and vector-im/element-web#18804.
* Allow to use basic html to format invite messages ([\#6703](https://github.com/matrix-org/matrix-react-sdk/pull/6703)). Fixes vector-im/element-web#15738 and vector-im/element-web#15738. Contributed by [skolmer](https://github.com/skolmer).
* Allow widgets, when eligible, to interact with more rooms as per MSC2762 ([\#6684](https://github.com/matrix-org/matrix-react-sdk/pull/6684)).
* Remove arbitrary limits from send/receive events for widgets ([\#6719](https://github.com/matrix-org/matrix-react-sdk/pull/6719)). Fixes vector-im/element-web#17994 and vector-im/element-web#17994.
* Reload suggested rooms if we see the state change down /sync ([\#6715](https://github.com/matrix-org/matrix-react-sdk/pull/6715)). Fixes vector-im/element-web#18761 and vector-im/element-web#18761.
* When creating private spaces, make the initial rooms restricted if supported ([\#6721](https://github.com/matrix-org/matrix-react-sdk/pull/6721)). Fixes vector-im/element-web#18722 and vector-im/element-web#18722.
* Threading exploration work ([\#6658](https://github.com/matrix-org/matrix-react-sdk/pull/6658)). Fixes vector-im/element-web#18532 and vector-im/element-web#18532.
* Default to `Don't leave any` when leaving a space ([\#6697](https://github.com/matrix-org/matrix-react-sdk/pull/6697)). Fixes vector-im/element-web#18592 and vector-im/element-web#18592. Contributed by [SimonBrandner](https://github.com/SimonBrandner).
* Special case redaction event sending from widgets per MSC2762 ([\#6686](https://github.com/matrix-org/matrix-react-sdk/pull/6686)). Fixes vector-im/element-web#18573 and vector-im/element-web#18573.
* Add active speaker indicators ([\#6639](https://github.com/matrix-org/matrix-react-sdk/pull/6639)). Fixes vector-im/element-web#17627 and vector-im/element-web#17627. Contributed by [SimonBrandner](https://github.com/SimonBrandner).
* Increase general app performance by optimizing layers ([\#6644](https://github.com/matrix-org/matrix-react-sdk/pull/6644)). Fixes vector-im/element-web#18730 and vector-im/element-web#18730. Contributed by [Palid](https://github.com/Palid).
## 🐛 Bug Fixes
* Fix autocomplete not having y-scroll ([\#6802](https://github.com/matrix-org/matrix-react-sdk/pull/6802)).
* Fix emoji picker and stickerpicker not appearing correctly when opened ([\#6801](https://github.com/matrix-org/matrix-react-sdk/pull/6801)).
* Debounce read marker update on scroll ([\#6774](https://github.com/matrix-org/matrix-react-sdk/pull/6774)).
* Fix Space creation wizard go to my first room button behaviour ([\#6748](https://github.com/matrix-org/matrix-react-sdk/pull/6748)). Fixes vector-im/element-web#18764 and vector-im/element-web#18764.
* Fix scroll being stuck at bottom ([\#6751](https://github.com/matrix-org/matrix-react-sdk/pull/6751)). Fixes vector-im/element-web#18903 and vector-im/element-web#18903.
* Fix widgets not remembering identity verification when asked to. ([\#6742](https://github.com/matrix-org/matrix-react-sdk/pull/6742)). Fixes vector-im/element-web#15631 and vector-im/element-web#15631.
* Add missing pluralisation i18n strings for Spaces ([\#6738](https://github.com/matrix-org/matrix-react-sdk/pull/6738)). Fixes vector-im/element-web#18780 and vector-im/element-web#18780.
* Make ForgotPassword UX slightly more user friendly ([\#6636](https://github.com/matrix-org/matrix-react-sdk/pull/6636)). Fixes vector-im/element-web#11531 and vector-im/element-web#11531. Contributed by [Palid](https://github.com/Palid).
* Don't context switch room on SpaceStore ready as it can break permalinks ([\#6730](https://github.com/matrix-org/matrix-react-sdk/pull/6730)). Fixes vector-im/element-web#17974 and vector-im/element-web#17974.
* Fix explore rooms button not working during space creation wizard ([\#6729](https://github.com/matrix-org/matrix-react-sdk/pull/6729)). Fixes vector-im/element-web#18762 and vector-im/element-web#18762.
* Fix bug where one party's media would sometimes not be shown ([\#6731](https://github.com/matrix-org/matrix-react-sdk/pull/6731)).
* Only make the initial space rooms suggested by default ([\#6714](https://github.com/matrix-org/matrix-react-sdk/pull/6714)). Fixes vector-im/element-web#18760 and vector-im/element-web#18760.
* Replace fake username in EventTilePreview with a proper loading state ([\#6702](https://github.com/matrix-org/matrix-react-sdk/pull/6702)). Fixes vector-im/element-web#15897 and vector-im/element-web#15897. Contributed by [skolmer](https://github.com/skolmer).
* Don't send prehistorical events to widgets during decryption at startup ([\#6695](https://github.com/matrix-org/matrix-react-sdk/pull/6695)). Fixes vector-im/element-web#18060 and vector-im/element-web#18060.
* When creating subspaces properly set restricted join rule ([\#6725](https://github.com/matrix-org/matrix-react-sdk/pull/6725)). Fixes vector-im/element-web#18797 and vector-im/element-web#18797.
* Fix the Image View not openning for some pinned messages ([\#6723](https://github.com/matrix-org/matrix-react-sdk/pull/6723)). Fixes vector-im/element-web#18422 and vector-im/element-web#18422. Contributed by [SimonBrandner](https://github.com/SimonBrandner).
* Show autocomplete sections vertically ([\#6722](https://github.com/matrix-org/matrix-react-sdk/pull/6722)). Fixes vector-im/element-web#18860 and vector-im/element-web#18860. Contributed by [SimonBrandner](https://github.com/SimonBrandner).
* Fix EmojiPicker filtering to lower case emojibase data strings ([\#6717](https://github.com/matrix-org/matrix-react-sdk/pull/6717)). Fixes vector-im/element-web#18686 and vector-im/element-web#18686.
* Clear currentRoomId when viewing home page, fixing document title ([\#6716](https://github.com/matrix-org/matrix-react-sdk/pull/6716)). Fixes vector-im/element-web#18668 and vector-im/element-web#18668.
* Fix membership updates to Spaces not applying in real-time ([\#6713](https://github.com/matrix-org/matrix-react-sdk/pull/6713)). Fixes vector-im/element-web#18737 and vector-im/element-web#18737.
* Don't show a double stacked invite modals when inviting to Spaces ([\#6698](https://github.com/matrix-org/matrix-react-sdk/pull/6698)). Fixes vector-im/element-web#18745 and vector-im/element-web#18745. Contributed by [SimonBrandner](https://github.com/SimonBrandner).
* Remove non-functional DuckDuckGo Autocomplete Provider ([\#6712](https://github.com/matrix-org/matrix-react-sdk/pull/6712)). Fixes vector-im/element-web#18778 and vector-im/element-web#18778.
* Filter members on `MemberList` load ([\#6708](https://github.com/matrix-org/matrix-react-sdk/pull/6708)). Contributed by [SimonBrandner](https://github.com/SimonBrandner).
* Fix improper voice messages being produced in Firefox and sometimes other browsers. ([\#6696](https://github.com/matrix-org/matrix-react-sdk/pull/6696)). Fixes vector-im/element-web#18587 and vector-im/element-web#18587.
* Fix client forgetting which capabilities a widget was approved for ([\#6685](https://github.com/matrix-org/matrix-react-sdk/pull/6685)). Fixes vector-im/element-web#18786 and vector-im/element-web#18786.
* Fix left panel widgets not remembering collapsed state ([\#6687](https://github.com/matrix-org/matrix-react-sdk/pull/6687)). Fixes vector-im/element-web#17803 and vector-im/element-web#17803.
* Fix changelog link colour back to blue ([\#6692](https://github.com/matrix-org/matrix-react-sdk/pull/6692)). Fixes vector-im/element-web#18726 and vector-im/element-web#18726.
* Soften codeblock border color ([\#6564](https://github.com/matrix-org/matrix-react-sdk/pull/6564)). Fixes vector-im/element-web#18367 and vector-im/element-web#18367. Contributed by [SimonBrandner](https://github.com/SimonBrandner).
* Pause ringing more aggressively ([\#6691](https://github.com/matrix-org/matrix-react-sdk/pull/6691)). Fixes vector-im/element-web#18588 and vector-im/element-web#18588. Contributed by [SimonBrandner](https://github.com/SimonBrandner).
* Fix command autocomplete ([\#6680](https://github.com/matrix-org/matrix-react-sdk/pull/6680)). Fixes vector-im/element-web#18670 and vector-im/element-web#18670. Contributed by [SimonBrandner](https://github.com/SimonBrandner).
* Don't re-sort the room-list based on profile/status changes ([\#6595](https://github.com/matrix-org/matrix-react-sdk/pull/6595)). Fixes vector-im/element-web#110 and vector-im/element-web#110. Contributed by [SimonBrandner](https://github.com/SimonBrandner).
* Fix codeblock formatting with syntax highlighting on ([\#6681](https://github.com/matrix-org/matrix-react-sdk/pull/6681)). Fixes vector-im/element-web#18739 vector-im/element-web#18365 and vector-im/element-web#18739. Contributed by [SimonBrandner](https://github.com/SimonBrandner).
* Add padding to the Add button in the notification settings ([\#6665](https://github.com/matrix-org/matrix-react-sdk/pull/6665)). Fixes vector-im/element-web#18706 and vector-im/element-web#18706. Contributed by [SimonBrandner](https://github.com/SimonBrandner).
Changes in [3.29.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.29.1) (2021-09-13)
===================================================================================================
## 🔒 SECURITY FIXES
* Fix a security issue with message key sharing. See https://matrix.org/blog/2021/09/13/vulnerability-disclosure-key-sharing
for details.
Changes in [3.29.0](https://github.com/vector-im/element-desktop/releases/tag/v3.29.0) (2021-08-31) Changes in [3.29.0](https://github.com/vector-im/element-desktop/releases/tag/v3.29.0) (2021-08-31)
=================================================================================================== ===================================================================================================

View file

@ -1,6 +1,6 @@
{ {
"name": "matrix-react-sdk", "name": "matrix-react-sdk",
"version": "3.29.0", "version": "3.30.0",
"description": "SDK for matrix.org using React", "description": "SDK for matrix.org using React",
"author": "matrix.org", "author": "matrix.org",
"repository": { "repository": {
@ -93,10 +93,10 @@
"prop-types": "^15.7.2", "prop-types": "^15.7.2",
"qrcode": "^1.4.4", "qrcode": "^1.4.4",
"re-resizable": "^6.9.0", "re-resizable": "^6.9.0",
"react": "^17.0.2", "react": "17.0.2",
"react-beautiful-dnd": "^13.1.0", "react-beautiful-dnd": "^13.1.0",
"react-blurhash": "^0.1.3", "react-blurhash": "^0.1.3",
"react-dom": "^17.0.2", "react-dom": "17.0.2",
"react-focus-lock": "^2.5.0", "react-focus-lock": "^2.5.0",
"react-transition-group": "^4.4.1", "react-transition-group": "^4.4.1",
"resize-observer-polyfill": "^1.5.1", "resize-observer-polyfill": "^1.5.1",
@ -142,9 +142,9 @@
"@types/pako": "^1.0.1", "@types/pako": "^1.0.1",
"@types/parse5": "^6.0.0", "@types/parse5": "^6.0.0",
"@types/qrcode": "^1.3.5", "@types/qrcode": "^1.3.5",
"@types/react": "^17.0.2", "@types/react": "17.0.14",
"@types/react-beautiful-dnd": "^13.0.0", "@types/react-beautiful-dnd": "^13.0.0",
"@types/react-dom": "^17.0.2", "@types/react-dom": "17.0.9",
"@types/react-transition-group": "^4.4.0", "@types/react-transition-group": "^4.4.0",
"@types/sanitize-html": "^2.3.1", "@types/sanitize-html": "^2.3.1",
"@types/zxcvbn": "^4.4.0", "@types/zxcvbn": "^4.4.0",
@ -175,9 +175,12 @@
"stylelint": "^13.9.0", "stylelint": "^13.9.0",
"stylelint-config-standard": "^20.0.0", "stylelint-config-standard": "^20.0.0",
"stylelint-scss": "^3.18.0", "stylelint-scss": "^3.18.0",
"typescript": "^4.1.3", "typescript": "4.3.5",
"walk": "^2.3.14" "walk": "^2.3.14"
}, },
"resolutions": {
"@types/react": "17.0.14"
},
"jest": { "jest": {
"testEnvironment": "./__test-utils__/environment.js", "testEnvironment": "./__test-utils__/environment.js",
"testMatch": [ "testMatch": [

View file

@ -73,12 +73,6 @@ $groupFilterPanelWidth: 56px; // only applies in this file, used for calculation
.mx_GroupFilterPanel .mx_TagTile { .mx_GroupFilterPanel .mx_TagTile {
// opacity: 0.5; // opacity: 0.5;
position: relative; position: relative;
.mx_BetaDot {
position: absolute;
right: -13px;
top: -11px;
}
} }
.mx_GroupFilterPanel .mx_TagTile.mx_TagTile_prototype { .mx_GroupFilterPanel .mx_TagTile.mx_TagTile_prototype {

View file

@ -183,3 +183,40 @@ limitations under the License.
padding: 0; padding: 0;
} }
} }
@media screen and (max-width: 700px) {
.mx_RoomDirectory_roomMemberCount {
padding: 0px;
}
.mx_AccessibleButton_kind_secondary {
padding: 0px !important;
}
.mx_RoomDirectory_join {
margin-left: 0px;
}
.mx_RoomDirectory_alias {
margin-top: 10px;
margin-bottom: 10px;
}
.mx_RoomDirectory_roomDescription {
padding-bottom: 0px;
}
.mx_RoomDirectory_name {
margin-bottom: 5px;
}
.mx_RoomDirectory_roomAvatar {
margin-top: 10px;
}
.mx_RoomDirectory_table {
grid-template-columns: auto;
row-gap: 14px;
margin-top: 5px;
}
}

View file

@ -103,6 +103,16 @@ $activeBorderColor: $secondary-content;
} }
} }
.mx_SpaceItem_new {
position: relative;
.mx_BetaDot {
position: absolute;
left: 33px;
top: -5px;
}
}
.mx_SpaceItem:not(.hasSubSpaces) > .mx_SpaceButton { .mx_SpaceItem:not(.hasSubSpaces) > .mx_SpaceButton {
margin-left: $gutterSize; margin-left: $gutterSize;
min-width: 40px; min-width: 40px;
@ -139,7 +149,6 @@ $activeBorderColor: $secondary-content;
&:not(.mx_SpaceButton_narrow) { &:not(.mx_SpaceButton_narrow) {
.mx_SpaceButton_selectionWrapper { .mx_SpaceButton_selectionWrapper {
width: 100%; width: 100%;
padding-right: 16px;
overflow: hidden; overflow: hidden;
} }
} }
@ -151,7 +160,6 @@ $activeBorderColor: $secondary-content;
display: block; display: block;
text-overflow: ellipsis; text-overflow: ellipsis;
overflow: hidden; overflow: hidden;
padding-right: 8px;
font-size: $font-14px; font-size: $font-14px;
line-height: $font-18px; line-height: $font-18px;
} }
@ -196,23 +204,18 @@ $activeBorderColor: $secondary-content;
} }
&.mx_SpaceButton_new .mx_SpaceButton_icon { &.mx_SpaceButton_new .mx_SpaceButton_icon {
background-color: $accent-color; background-color: $roomlist-button-bg-color;
transition: all .1s ease-in-out; // TODO transition
&::before { &::before {
background-color: #ffffff; background-color: $primary-content;
mask-image: url('$(res)/img/element-icons/plus.svg'); mask-image: url('$(res)/img/element-icons/plus.svg');
transition: all .2s ease-in-out; // TODO transition transition: all .2s ease-in-out; // TODO transition
} }
} }
&.mx_SpaceButton_newCancel .mx_SpaceButton_icon { &.mx_SpaceButton_newCancel .mx_SpaceButton_icon::before {
background-color: $icon-button-color;
&::before {
transform: rotate(45deg); transform: rotate(45deg);
} }
}
.mx_BaseAvatar_image { .mx_BaseAvatar_image {
border-radius: 8px; border-radius: 8px;
@ -225,8 +228,7 @@ $activeBorderColor: $secondary-content;
margin-top: auto; margin-top: auto;
margin-bottom: auto; margin-bottom: auto;
display: none; display: none;
position: absolute; position: relative;
right: 4px;
&::before { &::before {
top: 2px; top: 2px;
@ -245,8 +247,6 @@ $activeBorderColor: $secondary-content;
} }
.mx_SpacePanel_badgeContainer { .mx_SpacePanel_badgeContainer {
position: absolute;
// Create a flexbox to make aligning dot badges easier // Create a flexbox to make aligning dot badges easier
display: flex; display: flex;
align-items: center; align-items: center;
@ -264,6 +264,7 @@ $activeBorderColor: $secondary-content;
&.collapsed { &.collapsed {
.mx_SpaceButton { .mx_SpaceButton {
.mx_SpacePanel_badgeContainer { .mx_SpacePanel_badgeContainer {
position: absolute;
right: 0; right: 0;
top: 0; top: 0;
@ -293,19 +294,12 @@ $activeBorderColor: $secondary-content;
} }
&:not(.collapsed) { &:not(.collapsed) {
.mx_SpacePanel_badgeContainer {
position: absolute;
right: 4px;
}
.mx_SpaceButton:hover, .mx_SpaceButton:hover,
.mx_SpaceButton:focus-within, .mx_SpaceButton:focus-within,
.mx_SpaceButton_hasMenuOpen { .mx_SpaceButton_hasMenuOpen {
&:not(.mx_SpaceButton_invite) { &:not(.mx_SpaceButton_invite) {
// Hide the badge container on hover because it'll be a menu button // Hide the badge container on hover because it'll be a menu button
.mx_SpacePanel_badgeContainer { .mx_SpacePanel_badgeContainer {
width: 0;
height: 0;
display: none; display: none;
} }

View file

@ -215,10 +215,11 @@ $SpaceRoomViewInnerWidth: 428px;
} }
} }
> .mx_BaseAvatar_image, > .mx_RoomAvatar_isSpaceRoom {
> .mx_BaseAvatar > .mx_BaseAvatar_image { &.mx_BaseAvatar_image, .mx_BaseAvatar_image {
border-radius: 12px; border-radius: 12px;
} }
}
h1.mx_SpaceRoomView_preview_name { h1.mx_SpaceRoomView_preview_name {
margin: 20px 0 !important; // override default margin from above margin: 20px 0 !important; // override default margin from above

View file

@ -113,6 +113,7 @@ $dot-size: 12px;
animation: mx_Beta_bluePulse 2s infinite; animation: mx_Beta_bluePulse 2s infinite;
animation-iteration-count: 20; animation-iteration-count: 20;
position: relative; position: relative;
pointer-events: none;
&::after { &::after {
content: ""; content: "";

View file

@ -98,14 +98,14 @@ limitations under the License.
transition: transition:
font-size 0.25s ease-out 0.1s, font-size 0.25s ease-out 0.1s,
color 0.25s ease-out 0.1s, color 0.25s ease-out 0.1s,
top 0.25s ease-out 0.1s, transform 0.25s ease-out 0.1s,
background-color 0.25s ease-out 0.1s; background-color 0.25s ease-out 0.1s;
color: $primary-content; color: $primary-content;
background-color: transparent; background-color: transparent;
font-size: $font-14px; font-size: $font-14px;
transform: translateY(0);
position: absolute; position: absolute;
left: 0px; left: 0px;
top: 0px;
margin: 7px 8px; margin: 7px 8px;
padding: 2px; padding: 2px;
pointer-events: none; // Allow clicks to fall through to the input pointer-events: none; // Allow clicks to fall through to the input
@ -124,10 +124,10 @@ limitations under the License.
transition: transition:
font-size 0.25s ease-out 0s, font-size 0.25s ease-out 0s,
color 0.25s ease-out 0s, color 0.25s ease-out 0s,
top 0.25s ease-out 0s, transform 0.25s ease-out 0s,
background-color 0.25s ease-out 0s; background-color 0.25s ease-out 0s;
font-size: $font-10px; font-size: $font-10px;
top: -13px; transform: translateY(-13px);
padding: 0 2px; padding: 0 2px;
background-color: $field-focused-label-bg-color; background-color: $field-focused-label-bg-color;
pointer-events: initial; pointer-events: initial;

View file

@ -7,7 +7,6 @@
background: $background; background: $background;
border-bottom: none; border-bottom: none;
border-radius: 8px 8px 0 0; border-radius: 8px 8px 0 0;
max-height: 35vh;
overflow: clip; overflow: clip;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -64,6 +63,7 @@
margin: 12px; margin: 12px;
height: 100%; height: 100%;
overflow-y: scroll; overflow-y: scroll;
max-height: 35vh;
} }
.mx_Autocomplete_Completion_container_truncate { .mx_Autocomplete_Completion_container_truncate {

View file

@ -23,11 +23,11 @@ limitations under the License.
} }
.mx_EventTile[data-layout=bubble] { .mx_EventTile[data-layout=bubble] {
position: relative; position: relative;
margin-top: var(--gutterSize); margin-top: var(--gutterSize);
margin-left: 50px; margin-left: 49px;
margin-right: 100px; margin-right: 100px;
font-size: $font-14px;
&.mx_EventTile_continuation { &.mx_EventTile_continuation {
margin-top: 2px; margin-top: 2px;
@ -77,10 +77,11 @@ limitations under the License.
max-width: 70%; max-width: 70%;
} }
.mx_SenderProfile { > .mx_SenderProfile {
position: relative; position: relative;
top: -2px; top: -2px;
left: 2px; left: 2px;
font-size: $font-15px;
} }
&[data-self=false] { &[data-self=false] {
@ -113,8 +114,6 @@ limitations under the License.
.mx_ReplyTile .mx_SenderProfile { .mx_ReplyTile .mx_SenderProfile {
display: block; display: block;
top: unset;
left: unset;
} }
.mx_ReactionsRow { .mx_ReactionsRow {
@ -287,6 +286,8 @@ limitations under the License.
.mx_EventTile_line, .mx_EventTile_line,
.mx_EventTile_info { .mx_EventTile_info {
min-width: 100%; min-width: 100%;
// Preserve alignment with left edge of text in bubbles
margin: 0;
} }
.mx_EventTile_e2eIcon { .mx_EventTile_e2eIcon {
@ -294,9 +295,10 @@ limitations under the License.
} }
.mx_EventTile_line > a { .mx_EventTile_line > a {
// Align timestamps with those of normal bubble tiles
right: auto; right: auto;
top: -15px; top: -11px;
left: -68px; left: -95px;
} }
} }
@ -326,11 +328,10 @@ limitations under the License.
} }
.mx_EventTile_line { .mx_EventTile_line {
margin: 0 5px; margin: 0;
> a { > a {
left: auto; // Align timestamps with those of normal bubble tiles
right: 0; left: -76px;
transform: translateX(calc(100% + 5px));
} }
} }
@ -340,7 +341,8 @@ limitations under the License.
} }
.mx_EventListSummary[data-expanded=false][data-layout=bubble] { .mx_EventListSummary[data-expanded=false][data-layout=bubble] {
padding: 0 34px; // Align with left edge of bubble tiles
padding: 0 49px;
} }
/* events that do not require bubble layout */ /* events that do not require bubble layout */

View file

@ -172,14 +172,12 @@ limitations under the License.
} }
} }
// In the general case, we leave height of headers alone even if sticky, so // In the general case, we reserve space for each sublist header to prevent
// that the sublists below them do not jump. However, that leaves a gap // scroll jumps when they become sticky. However, that leaves a gap when
// when scrolled to the top above the first sublist (whose header can only // scrolled to the top above the first sublist (whose header can only ever
// ever stick to top), so we force height to 0 for only that first header. // stick to top), so we make sure to exclude the first visible sublist.
// See also https://github.com/vector-im/element-web/issues/14429. &:not(.mx_RoomSublist_hidden) ~ .mx_RoomSublist .mx_RoomSublist_headerContainer {
&:first-child .mx_RoomSublist_headerContainer { height: 24px;
height: 0;
padding-bottom: 4px;
} }
.mx_RoomSublist_resizeBox { .mx_RoomSublist_resizeBox {

View file

@ -21,6 +21,17 @@ limitations under the License.
.mx_SettingsTab_section { .mx_SettingsTab_section {
margin-bottom: 30px; margin-bottom: 30px;
> details {
> summary {
cursor: pointer;
color: $primary-content;
}
& + .mx_SettingsFlag {
margin-top: 20px;
}
}
} }
.mx_PreferencesUserSettingsTab_CommunityMigrator { .mx_PreferencesUserSettingsTab_CommunityMigrator {

0
res/img/betas/.gitkeep Normal file
View file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 380 KiB

View file

@ -184,6 +184,9 @@ $visual-bell-bg-color: #800;
$room-warning-bg-color: $header-panel-bg-color; $room-warning-bg-color: $header-panel-bg-color;
$authpage-body-bg-color: $background;
$authpage-primary-color: $primary-content;
$dark-panel-bg-color: $header-panel-bg-color; $dark-panel-bg-color: $header-panel-bg-color;
$panel-gradient: rgba(34, 38, 46, 0), rgba(34, 38, 46, 1); $panel-gradient: rgba(34, 38, 46, 0), rgba(34, 38, 46, 1);

View file

@ -82,6 +82,8 @@ $tab-label-fg-color: var(--timeline-text-color);
// was #4e5054 // was #4e5054
$authpage-lang-color: var(--timeline-text-color); $authpage-lang-color: var(--timeline-text-color);
$roomheader-color: var(--timeline-text-color); $roomheader-color: var(--timeline-text-color);
// was #232f32
$authpage-primary-color: var(--timeline-text-color);
// --roomlist-text-secondary-color // --roomlist-text-secondary-color
$roomtile-preview-color: var(--roomlist-text-secondary-color); $roomtile-preview-color: var(--roomlist-text-secondary-color);
$roomlist-header-color: var(--roomlist-text-secondary-color); $roomlist-header-color: var(--roomlist-text-secondary-color);

View file

@ -574,11 +574,12 @@ async function doSetLoggedIn(
await abortLogin(); await abortLogin();
} }
PosthogAnalytics.instance.updateAnonymityFromSettings(credentials.userId);
Analytics.setLoggedIn(credentials.guest, credentials.homeserverUrl); Analytics.setLoggedIn(credentials.guest, credentials.homeserverUrl);
MatrixClientPeg.replaceUsingCreds(credentials); MatrixClientPeg.replaceUsingCreds(credentials);
PosthogAnalytics.instance.updateAnonymityFromSettings(credentials.userId);
const client = MatrixClientPeg.get(); const client = MatrixClientPeg.get();
if (credentials.freshLogin && SettingsStore.getValue("feature_dehydration")) { if (credentials.freshLogin && SettingsStore.getValue("feature_dehydration")) {

View file

@ -17,8 +17,8 @@ limitations under the License.
import SettingsStore from "./settings/SettingsStore"; import SettingsStore from "./settings/SettingsStore";
import { SettingLevel } from "./settings/SettingLevel"; import { SettingLevel } from "./settings/SettingLevel";
import { setMatrixCallAudioInput, setMatrixCallVideoInput } from "matrix-js-sdk/src/matrix";
import EventEmitter from 'events'; import EventEmitter from 'events';
import { MatrixClientPeg } from "./MatrixClientPeg";
// XXX: MediaDeviceKind is a union type, so we make our own enum // XXX: MediaDeviceKind is a union type, so we make our own enum
export enum MediaDeviceKindEnum { export enum MediaDeviceKindEnum {
@ -74,8 +74,8 @@ export default class MediaDeviceHandler extends EventEmitter {
const audioDeviceId = SettingsStore.getValue("webrtc_audioinput"); const audioDeviceId = SettingsStore.getValue("webrtc_audioinput");
const videoDeviceId = SettingsStore.getValue("webrtc_videoinput"); const videoDeviceId = SettingsStore.getValue("webrtc_videoinput");
setMatrixCallAudioInput(audioDeviceId); MatrixClientPeg.get().getMediaHandler().setAudioInput(audioDeviceId);
setMatrixCallVideoInput(videoDeviceId); MatrixClientPeg.get().getMediaHandler().setVideoInput(videoDeviceId);
} }
public setAudioOutput(deviceId: string): void { public setAudioOutput(deviceId: string): void {
@ -90,7 +90,7 @@ export default class MediaDeviceHandler extends EventEmitter {
*/ */
public setAudioInput(deviceId: string): void { public setAudioInput(deviceId: string): void {
SettingsStore.setValue("webrtc_audioinput", null, SettingLevel.DEVICE, deviceId); SettingsStore.setValue("webrtc_audioinput", null, SettingLevel.DEVICE, deviceId);
setMatrixCallAudioInput(deviceId); MatrixClientPeg.get().getMediaHandler().setAudioInput(deviceId);
} }
/** /**
@ -100,7 +100,7 @@ export default class MediaDeviceHandler extends EventEmitter {
*/ */
public setVideoInput(deviceId: string): void { public setVideoInput(deviceId: string): void {
SettingsStore.setValue("webrtc_videoinput", null, SettingLevel.DEVICE, deviceId); SettingsStore.setValue("webrtc_videoinput", null, SettingLevel.DEVICE, deviceId);
setMatrixCallVideoInput(deviceId); MatrixClientPeg.get().getMediaHandler().setVideoInput(deviceId);
} }
public setDevice(deviceId: string, kind: MediaDeviceKindEnum): void { public setDevice(deviceId: string, kind: MediaDeviceKindEnum): void {

View file

@ -18,6 +18,8 @@ import posthog, { PostHog } from 'posthog-js';
import PlatformPeg from './PlatformPeg'; import PlatformPeg from './PlatformPeg';
import SdkConfig from './SdkConfig'; import SdkConfig from './SdkConfig';
import SettingsStore from './settings/SettingsStore'; import SettingsStore from './settings/SettingsStore';
import { MatrixClientPeg } from "./MatrixClientPeg";
import { MatrixClient } from "matrix-js-sdk/src/client";
/* Posthog analytics tracking. /* Posthog analytics tracking.
* *
@ -27,10 +29,11 @@ import SettingsStore from './settings/SettingsStore';
* - If [Do Not Track](https://developer.mozilla.org/en-US/docs/Web/API/Navigator/doNotTrack) is * - If [Do Not Track](https://developer.mozilla.org/en-US/docs/Web/API/Navigator/doNotTrack) is
* enabled, events are not sent (this detection is built into posthog and turned on via the * enabled, events are not sent (this detection is built into posthog and turned on via the
* `respect_dnt` flag being passed to `posthog.init`). * `respect_dnt` flag being passed to `posthog.init`).
* - If the `feature_pseudonymous_analytics_opt_in` labs flag is `true`, track pseudonomously, i.e. * - If the `feature_pseudonymous_analytics_opt_in` labs flag is `true`, track pseudonomously by maintaining
* hash all matrix identifiers in tracking events (user IDs, room IDs etc) using SHA-256. * a randomised analytics ID in account_data for that user (shared between devices) and sending it to posthog to
* - Otherwise, if the existing `analyticsOptIn` flag is `true`, track anonymously, i.e. identify the user.
* redact all matrix identifiers in tracking events. * - Otherwise, if the existing `analyticsOptIn` flag is `true`, track anonymously, i.e. do not identify the user
using any identifier that would be consistent across devices.
* - If both flags are false or not set, events are not sent. * - If both flags are false or not set, events are not sent.
*/ */
@ -71,12 +74,6 @@ interface IPageView extends IAnonymousEvent {
}; };
} }
const hashHex = async (input: string): Promise<string> => {
const buf = new TextEncoder().encode(input);
const digestBuf = await window.crypto.subtle.digest("sha-256", buf);
return [...new Uint8Array(digestBuf)].map((b: number) => b.toString(16).padStart(2, "0")).join("");
};
const whitelistedScreens = new Set([ const whitelistedScreens = new Set([
"register", "login", "forgot_password", "soft_logout", "new", "settings", "welcome", "home", "start", "directory", "register", "login", "forgot_password", "soft_logout", "new", "settings", "welcome", "home", "start", "directory",
"start_sso", "start_cas", "groups", "complete_security", "post_registration", "room", "user", "group", "start_sso", "start_cas", "groups", "complete_security", "post_registration", "room", "user", "group",
@ -89,7 +86,6 @@ export async function getRedactedCurrentLocation(
anonymity: Anonymity, anonymity: Anonymity,
): Promise<string> { ): Promise<string> {
// Redact PII from the current location. // Redact PII from the current location.
// If anonymous is true, redact entirely, if false, substitute it with a hash.
// For known screens, assumes a URL structure of /<screen name>/might/be/pii // For known screens, assumes a URL structure of /<screen name>/might/be/pii
if (origin.startsWith('file://')) { if (origin.startsWith('file://')) {
pathname = "/<redacted_file_scheme_url>/"; pathname = "/<redacted_file_scheme_url>/";
@ -99,17 +95,13 @@ export async function getRedactedCurrentLocation(
if (hash == "") { if (hash == "") {
hashStr = ""; hashStr = "";
} else { } else {
let [beforeFirstSlash, screen, ...parts] = hash.split("/"); let [beforeFirstSlash, screen] = hash.split("/");
if (!whitelistedScreens.has(screen)) { if (!whitelistedScreens.has(screen)) {
screen = "<redacted_screen_name>"; screen = "<redacted_screen_name>";
} }
for (let i = 0; i < parts.length; i++) { hashStr = `${beforeFirstSlash}/${screen}/<redacted>`;
parts[i] = anonymity === Anonymity.Anonymous ? `<redacted>` : await hashHex(parts[i]);
}
hashStr = `${beforeFirstSlash}/${screen}/${parts.join("/")}`;
} }
return origin + pathname + hashStr; return origin + pathname + hashStr;
} }
@ -123,15 +115,15 @@ export class PosthogAnalytics {
/* Wrapper for Posthog analytics. /* Wrapper for Posthog analytics.
* 3 modes of anonymity are supported, governed by this.anonymity * 3 modes of anonymity are supported, governed by this.anonymity
* - Anonymity.Disabled means *no data* is passed to posthog * - Anonymity.Disabled means *no data* is passed to posthog
* - Anonymity.Anonymous means all identifers will be redacted before being passed to posthog * - Anonymity.Anonymous means no identifier is passed to posthog
* - Anonymity.Pseudonymous means all identifiers will be hashed via SHA-256 before being passed * - Anonymity.Pseudonymous means an analytics ID stored in account_data and shared between devices
* to Posthog * is passed to posthog.
* *
* To update anonymity, call updateAnonymityFromSettings() or you can set it directly via setAnonymity(). * To update anonymity, call updateAnonymityFromSettings() or you can set it directly via setAnonymity().
* *
* To pass an event to Posthog: * To pass an event to Posthog:
* *
* 1. Declare a type for the event, extending IAnonymousEvent, IPseudonymousEvent or IRoomEvent. * 1. Declare a type for the event, extending IAnonymousEvent or IPseudonymousEvent.
* 2. Call the appropriate track*() method. Pseudonymous events will be dropped when anonymity is * 2. Call the appropriate track*() method. Pseudonymous events will be dropped when anonymity is
* Anonymous or Disabled; Anonymous events will be dropped when anonymity is Disabled. * Anonymous or Disabled; Anonymous events will be dropped when anonymity is Disabled.
*/ */
@ -141,6 +133,7 @@ export class PosthogAnalytics {
private enabled = false; private enabled = false;
private static _instance = null; private static _instance = null;
private platformSuperProperties = {}; private platformSuperProperties = {};
private static ANALYTICS_ID_EVENT_TYPE = "im.vector.web.analytics_id";
public static get instance(): PosthogAnalytics { public static get instance(): PosthogAnalytics {
if (!this._instance) { if (!this._instance) {
@ -274,9 +267,32 @@ export class PosthogAnalytics {
this.anonymity = anonymity; this.anonymity = anonymity;
} }
public async identifyUser(userId: string): Promise<void> { private static getRandomAnalyticsId(): string {
return [...crypto.getRandomValues(new Uint8Array(16))].map((c) => c.toString(16)).join('');
}
public async identifyUser(client: MatrixClient, analyticsIdGenerator: () => string): Promise<void> {
if (this.anonymity == Anonymity.Pseudonymous) { if (this.anonymity == Anonymity.Pseudonymous) {
this.posthog.identify(await hashHex(userId)); // Check the user's account_data for an analytics ID to use. Storing the ID in account_data allows
// different devices to send the same ID.
try {
const accountData = await client.getAccountDataFromServer(PosthogAnalytics.ANALYTICS_ID_EVENT_TYPE);
let analyticsID = accountData?.id;
if (!analyticsID) {
// Couldn't retrieve an analytics ID from user settings, so create one and set it on the server.
// Note there's a race condition here - if two devices do these steps at the same time, last write
// wins, and the first writer will send tracking with an ID that doesn't match the one on the server
// until the next time account data is refreshed and this function is called (most likely on next
// page load). This will happen pretty infrequently, so we can tolerate the possibility.
analyticsID = analyticsIdGenerator();
await client.setAccountData("im.vector.web.analytics_id", { id: analyticsID });
}
this.posthog.identify(analyticsID);
} catch (e) {
// The above could fail due to network requests, but not essential to starting the application,
// so swallow it.
console.log("Unable to identify user for tracking" + e.toString());
}
} }
} }
@ -307,18 +323,6 @@ export class PosthogAnalytics {
await this.capture(eventName, properties); await this.capture(eventName, properties);
} }
public async trackRoomEvent<E extends IRoomEvent>(
eventName: E["eventName"],
roomId: string,
properties: Omit<E["properties"], "roomId">,
): Promise<void> {
const updatedProperties = {
...properties,
hashedRoomId: roomId ? await hashHex(roomId) : null,
};
await this.trackPseudonymousEvent(eventName, updatedProperties);
}
public async trackPageView(durationMs: number): Promise<void> { public async trackPageView(durationMs: number): Promise<void> {
const hash = window.location.hash; const hash = window.location.hash;
@ -349,7 +353,7 @@ export class PosthogAnalytics {
// Identify the user (via hashed user ID) to posthog if anonymity is pseudonmyous // Identify the user (via hashed user ID) to posthog if anonymity is pseudonmyous
this.setAnonymity(PosthogAnalytics.getAnonymityFromSettings()); this.setAnonymity(PosthogAnalytics.getAnonymityFromSettings());
if (userId && this.getAnonymity() == Anonymity.Pseudonymous) { if (userId && this.getAnonymity() == Anonymity.Pseudonymous) {
await this.identifyUser(userId); await this.identifyUser(MatrixClientPeg.get(), PosthogAnalytics.getRandomAnalyticsId);
} }
} }
} }

View file

@ -48,11 +48,6 @@ export default class Resend {
// XXX: temporary logging to try to diagnose // XXX: temporary logging to try to diagnose
// https://github.com/vector-im/element-web/issues/3148 // https://github.com/vector-im/element-web/issues/3148
console.log('Resend got send failure: ' + err.name + '(' + err + ')'); console.log('Resend got send failure: ' + err.name + '(' + err + ')');
dis.dispatch({
action: 'message_send_failed',
event: event,
});
}); });
} }

View file

@ -26,10 +26,9 @@ import { SettingLevel } from "../../../../settings/SettingLevel";
import Field from '../../../../components/views/elements/Field'; import Field from '../../../../components/views/elements/Field';
import BaseDialog from "../../../../components/views/dialogs/BaseDialog"; import BaseDialog from "../../../../components/views/dialogs/BaseDialog";
import DialogButtons from "../../../../components/views/elements/DialogButtons"; import DialogButtons from "../../../../components/views/elements/DialogButtons";
import { IDialogProps } from "../../../../components/views/dialogs/IDialogProps";
interface IProps { interface IProps extends IDialogProps {}
onFinished: (confirmed: boolean) => void;
}
interface IState { interface IState {
eventIndexSize: number; eventIndexSize: number;

View file

@ -322,10 +322,16 @@ export class ContextMenu extends React.PureComponent<IProps, IState> {
const menuClasses = classNames({ const menuClasses = classNames({
'mx_ContextualMenu': true, 'mx_ContextualMenu': true,
'mx_ContextualMenu_left': !hasChevron && position.left, /**
'mx_ContextualMenu_right': !hasChevron && position.right, * In some cases we may get the number of 0, which still means that we're supposed to properly
'mx_ContextualMenu_top': !hasChevron && position.top, * add the specific position class, but as it was falsy things didn't work as intended.
'mx_ContextualMenu_bottom': !hasChevron && position.bottom, * In addition, defensively check for counter cases where we may get more than one value,
* even if we shouldn't.
*/
'mx_ContextualMenu_left': !hasChevron && position.left !== undefined && !position.right,
'mx_ContextualMenu_right': !hasChevron && position.right !== undefined && !position.left,
'mx_ContextualMenu_top': !hasChevron && position.top !== undefined && !position.bottom,
'mx_ContextualMenu_bottom': !hasChevron && position.bottom !== undefined && !position.top,
'mx_ContextualMenu_withChevron_left': chevronFace === ChevronFace.Left, 'mx_ContextualMenu_withChevron_left': chevronFace === ChevronFace.Left,
'mx_ContextualMenu_withChevron_right': chevronFace === ChevronFace.Right, 'mx_ContextualMenu_withChevron_right': chevronFace === ChevronFace.Right,
'mx_ContextualMenu_withChevron_top': chevronFace === ChevronFace.Top, 'mx_ContextualMenu_withChevron_top': chevronFace === ChevronFace.Top,
@ -404,17 +410,27 @@ export class ContextMenu extends React.PureComponent<IProps, IState> {
} }
} }
export type ToRightOf = {
left: number;
top: number;
chevronOffset: number;
};
// Placement method for <ContextMenu /> to position context menu to right of elementRect with chevronOffset // Placement method for <ContextMenu /> to position context menu to right of elementRect with chevronOffset
export const toRightOf = (elementRect: Pick<DOMRect, "right" | "top" | "height">, chevronOffset = 12) => { export const toRightOf = (elementRect: Pick<DOMRect, "right" | "top" | "height">, chevronOffset = 12): ToRightOf => {
const left = elementRect.right + window.pageXOffset + 3; const left = elementRect.right + window.pageXOffset + 3;
let top = elementRect.top + (elementRect.height / 2) + window.pageYOffset; let top = elementRect.top + (elementRect.height / 2) + window.pageYOffset;
top -= chevronOffset + 8; // where 8 is half the height of the chevron top -= chevronOffset + 8; // where 8 is half the height of the chevron
return { left, top, chevronOffset }; return { left, top, chevronOffset };
}; };
export type AboveLeftOf = IPosition & {
chevronFace: ChevronFace;
};
// Placement method for <ContextMenu /> to position context menu right-aligned and flowing to the left of elementRect, // Placement method for <ContextMenu /> to position context menu right-aligned and flowing to the left of elementRect,
// and either above or below: wherever there is more space (maybe this should be aboveOrBelowLeftOf?) // and either above or below: wherever there is more space (maybe this should be aboveOrBelowLeftOf?)
export const aboveLeftOf = (elementRect: DOMRect, chevronFace = ChevronFace.None, vPadding = 0) => { export const aboveLeftOf = (elementRect: DOMRect, chevronFace = ChevronFace.None, vPadding = 0): AboveLeftOf => {
const menuOptions: IPosition & { chevronFace: ChevronFace } = { chevronFace }; const menuOptions: IPosition & { chevronFace: ChevronFace } = { chevronFace };
const buttonRight = elementRect.right + window.pageXOffset; const buttonRight = elementRect.right + window.pageXOffset;

View file

@ -17,7 +17,6 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import request from 'browser-request'; import request from 'browser-request';
import { _t } from '../../languageHandler'; import { _t } from '../../languageHandler';
import sanitizeHtml from 'sanitize-html'; import sanitizeHtml from 'sanitize-html';
@ -26,38 +25,43 @@ import { MatrixClientPeg } from '../../MatrixClientPeg';
import classnames from 'classnames'; import classnames from 'classnames';
import MatrixClientContext from "../../contexts/MatrixClientContext"; import MatrixClientContext from "../../contexts/MatrixClientContext";
import AutoHideScrollbar from "./AutoHideScrollbar"; import AutoHideScrollbar from "./AutoHideScrollbar";
import { ActionPayload } from "../../dispatcher/payloads";
export default class EmbeddedPage extends React.PureComponent { interface IProps {
static propTypes = {
// URL to request embedded page content from // URL to request embedded page content from
url: PropTypes.string, url?: string;
// Class name prefix to apply for a given instance // Class name prefix to apply for a given instance
className: PropTypes.string, className?: string;
// Whether to wrap the page in a scrollbar // Whether to wrap the page in a scrollbar
scrollbar: PropTypes.bool, scrollbar?: boolean;
// Map of keys to replace with values, e.g {$placeholder: "value"} // Map of keys to replace with values, e.g {$placeholder: "value"}
replaceMap: PropTypes.object, replaceMap?: Map<string, string>;
}; }
static contextType = MatrixClientContext; interface IState {
page: string;
}
constructor(props, context) { export default class EmbeddedPage extends React.PureComponent<IProps, IState> {
public static contextType = MatrixClientContext;
private unmounted = false;
private dispatcherRef: string = null;
constructor(props: IProps, context: typeof MatrixClientContext) {
super(props, context); super(props, context);
this._dispatcherRef = null;
this.state = { this.state = {
page: '', page: '',
}; };
} }
translate(s) { protected translate(s: string): string {
// default implementation - skins may wish to extend this // default implementation - skins may wish to extend this
return sanitizeHtml(_t(s)); return sanitizeHtml(_t(s));
} }
componentDidMount() { public componentDidMount(): void {
this._unmounted = false; this.unmounted = false;
if (!this.props.url) { if (!this.props.url) {
return; return;
@ -70,7 +74,7 @@ export default class EmbeddedPage extends React.PureComponent {
request( request(
{ method: "GET", url: this.props.url }, { method: "GET", url: this.props.url },
(err, response, body) => { (err, response, body) => {
if (this._unmounted) { if (this.unmounted) {
return; return;
} }
@ -92,22 +96,22 @@ export default class EmbeddedPage extends React.PureComponent {
}, },
); );
this._dispatcherRef = dis.register(this.onAction); this.dispatcherRef = dis.register(this.onAction);
} }
componentWillUnmount() { public componentWillUnmount(): void {
this._unmounted = true; this.unmounted = true;
if (this._dispatcherRef !== null) dis.unregister(this._dispatcherRef); if (this.dispatcherRef !== null) dis.unregister(this.dispatcherRef);
} }
onAction = (payload) => { private onAction = (payload: ActionPayload): void => {
// HACK: Workaround for the context's MatrixClient not being set up at render time. // HACK: Workaround for the context's MatrixClient not being set up at render time.
if (payload.action === 'client_started') { if (payload.action === 'client_started') {
this.forceUpdate(); this.forceUpdate();
} }
}; };
render() { public render(): JSX.Element {
// HACK: Workaround for the context's MatrixClient not updating. // HACK: Workaround for the context's MatrixClient not updating.
const client = this.context || MatrixClientPeg.get(); const client = this.context || MatrixClientPeg.get();
const isGuest = client ? client.isGuest() : true; const isGuest = client ? client.isGuest() : true;

View file

@ -15,16 +15,15 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import { replaceableComponent } from "../../utils/replaceableComponent"; import { replaceableComponent } from "../../utils/replaceableComponent";
@replaceableComponent("structures.GenericErrorPage") interface IProps {
export default class GenericErrorPage extends React.PureComponent { title: React.ReactNode;
static propTypes = { message: React.ReactNode;
title: PropTypes.object.isRequired, // jsx for title }
message: PropTypes.object.isRequired, // jsx to display
};
@replaceableComponent("structures.GenericErrorPage")
export default class GenericErrorPage extends React.PureComponent<IProps> {
render() { render() {
return <div className='mx_GenericErrorPage'> return <div className='mx_GenericErrorPage'>
<div className='mx_GenericErrorPage_box'> <div className='mx_GenericErrorPage_box'>

View file

@ -146,19 +146,13 @@ class GroupFilterPanel extends React.Component<IGroupFilterPanelProps, IGroupFil
mx_GroupFilterPanel_items_selected: itemsSelected, mx_GroupFilterPanel_items_selected: itemsSelected,
}); });
let betaDot;
if (SettingsStore.getBetaInfo("feature_spaces") && !localStorage.getItem("mx_seenSpacesBeta")) {
betaDot = <div className="mx_BetaDot" />;
}
let createButton = ( let createButton = (
<ActionButton <ActionButton
tooltip tooltip
label={_t("Communities")} label={_t("Communities")}
action="toggle_my_groups" action="toggle_my_groups"
className="mx_TagTile mx_TagTile_plus"> className="mx_TagTile mx_TagTile_plus"
{ betaDot } />
</ActionButton>
); );
if (SettingsStore.getValue("feature_communities_v2_prototypes")) { if (SettingsStore.getValue("feature_communities_v2_prototypes")) {

View file

@ -14,34 +14,39 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React from "react"; import React, { createRef } from "react";
import PropTypes from "prop-types";
import AutoHideScrollbar from "./AutoHideScrollbar"; import AutoHideScrollbar from "./AutoHideScrollbar";
import { replaceableComponent } from "../../utils/replaceableComponent"; import { replaceableComponent } from "../../utils/replaceableComponent";
@replaceableComponent("structures.IndicatorScrollbar") interface IProps {
export default class IndicatorScrollbar extends React.Component {
static propTypes = {
// If true, the scrollbar will append mx_IndicatorScrollbar_leftOverflowIndicator // If true, the scrollbar will append mx_IndicatorScrollbar_leftOverflowIndicator
// and mx_IndicatorScrollbar_rightOverflowIndicator elements to the list for positioning // and mx_IndicatorScrollbar_rightOverflowIndicator elements to the list for positioning
// by the parent element. // by the parent element.
trackHorizontalOverflow: PropTypes.bool, trackHorizontalOverflow?: boolean;
// If true, when the user tries to use their mouse wheel in the component it will // If true, when the user tries to use their mouse wheel in the component it will
// scroll horizontally rather than vertically. This should only be used on components // scroll horizontally rather than vertically. This should only be used on components
// with no vertical scroll opportunity. // with no vertical scroll opportunity.
verticalScrollsHorizontally: PropTypes.bool, verticalScrollsHorizontally?: boolean;
};
constructor(props) { children: React.ReactNode;
className: string;
}
interface IState {
leftIndicatorOffset: number | string;
rightIndicatorOffset: number | string;
}
@replaceableComponent("structures.IndicatorScrollbar")
export default class IndicatorScrollbar extends React.Component<IProps, IState> {
private autoHideScrollbar = createRef<AutoHideScrollbar>();
private scrollElement: HTMLDivElement;
private likelyTrackpadUser: boolean = null;
private checkAgainForTrackpad = 0; // ts in milliseconds to recheck this._likelyTrackpadUser
constructor(props: IProps) {
super(props); super(props);
this._collectScroller = this._collectScroller.bind(this);
this._collectScrollerComponent = this._collectScrollerComponent.bind(this);
this.checkOverflow = this.checkOverflow.bind(this);
this._scrollElement = null;
this._autoHideScrollbar = null;
this._likelyTrackpadUser = null;
this._checkAgainForTrackpad = 0; // ts in milliseconds to recheck this._likelyTrackpadUser
this.state = { this.state = {
leftIndicatorOffset: 0, leftIndicatorOffset: 0,
@ -49,30 +54,19 @@ export default class IndicatorScrollbar extends React.Component {
}; };
} }
moveToOrigin() { private collectScroller = (scroller: HTMLDivElement): void => {
if (!this._scrollElement) return; if (scroller && !this.scrollElement) {
this.scrollElement = scroller;
this._scrollElement.scrollLeft = 0;
this._scrollElement.scrollTop = 0;
}
_collectScroller(scroller) {
if (scroller && !this._scrollElement) {
this._scrollElement = scroller;
// Using the passive option to not block the main thread // Using the passive option to not block the main thread
// https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#improving_scrolling_performance_with_passive_listeners // https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#improving_scrolling_performance_with_passive_listeners
this._scrollElement.addEventListener("scroll", this.checkOverflow, { passive: true }); this.scrollElement.addEventListener("scroll", this.checkOverflow, { passive: true });
this.checkOverflow(); this.checkOverflow();
} }
} };
_collectScrollerComponent(autoHideScrollbar) { public componentDidUpdate(prevProps: IProps): void {
this._autoHideScrollbar = autoHideScrollbar; const prevLen = React.Children.count(prevProps.children);
} const curLen = React.Children.count(this.props.children);
componentDidUpdate(prevProps) {
const prevLen = prevProps && prevProps.children && prevProps.children.length || 0;
const curLen = this.props.children && this.props.children.length || 0;
// check overflow only if amount of children changes. // check overflow only if amount of children changes.
// if we don't guard here, we end up with an infinite // if we don't guard here, we end up with an infinite
// render > componentDidUpdate > checkOverflow > setState > render loop // render > componentDidUpdate > checkOverflow > setState > render loop
@ -81,62 +75,58 @@ export default class IndicatorScrollbar extends React.Component {
} }
} }
componentDidMount() { public componentDidMount(): void {
this.checkOverflow(); this.checkOverflow();
} }
checkOverflow() { private checkOverflow = (): void => {
const hasTopOverflow = this._scrollElement.scrollTop > 0; const hasTopOverflow = this.scrollElement.scrollTop > 0;
const hasBottomOverflow = this._scrollElement.scrollHeight > const hasBottomOverflow = this.scrollElement.scrollHeight >
(this._scrollElement.scrollTop + this._scrollElement.clientHeight); (this.scrollElement.scrollTop + this.scrollElement.clientHeight);
const hasLeftOverflow = this._scrollElement.scrollLeft > 0; const hasLeftOverflow = this.scrollElement.scrollLeft > 0;
const hasRightOverflow = this._scrollElement.scrollWidth > const hasRightOverflow = this.scrollElement.scrollWidth >
(this._scrollElement.scrollLeft + this._scrollElement.clientWidth); (this.scrollElement.scrollLeft + this.scrollElement.clientWidth);
if (hasTopOverflow) { if (hasTopOverflow) {
this._scrollElement.classList.add("mx_IndicatorScrollbar_topOverflow"); this.scrollElement.classList.add("mx_IndicatorScrollbar_topOverflow");
} else { } else {
this._scrollElement.classList.remove("mx_IndicatorScrollbar_topOverflow"); this.scrollElement.classList.remove("mx_IndicatorScrollbar_topOverflow");
} }
if (hasBottomOverflow) { if (hasBottomOverflow) {
this._scrollElement.classList.add("mx_IndicatorScrollbar_bottomOverflow"); this.scrollElement.classList.add("mx_IndicatorScrollbar_bottomOverflow");
} else { } else {
this._scrollElement.classList.remove("mx_IndicatorScrollbar_bottomOverflow"); this.scrollElement.classList.remove("mx_IndicatorScrollbar_bottomOverflow");
} }
if (hasLeftOverflow) { if (hasLeftOverflow) {
this._scrollElement.classList.add("mx_IndicatorScrollbar_leftOverflow"); this.scrollElement.classList.add("mx_IndicatorScrollbar_leftOverflow");
} else { } else {
this._scrollElement.classList.remove("mx_IndicatorScrollbar_leftOverflow"); this.scrollElement.classList.remove("mx_IndicatorScrollbar_leftOverflow");
} }
if (hasRightOverflow) { if (hasRightOverflow) {
this._scrollElement.classList.add("mx_IndicatorScrollbar_rightOverflow"); this.scrollElement.classList.add("mx_IndicatorScrollbar_rightOverflow");
} else { } else {
this._scrollElement.classList.remove("mx_IndicatorScrollbar_rightOverflow"); this.scrollElement.classList.remove("mx_IndicatorScrollbar_rightOverflow");
} }
if (this.props.trackHorizontalOverflow) { if (this.props.trackHorizontalOverflow) {
this.setState({ this.setState({
// Offset from absolute position of the container // Offset from absolute position of the container
leftIndicatorOffset: hasLeftOverflow ? `${this._scrollElement.scrollLeft}px` : '0', leftIndicatorOffset: hasLeftOverflow ? `${this.scrollElement.scrollLeft}px` : '0',
// Negative because we're coming from the right // Negative because we're coming from the right
rightIndicatorOffset: hasRightOverflow ? `-${this._scrollElement.scrollLeft}px` : '0', rightIndicatorOffset: hasRightOverflow ? `-${this.scrollElement.scrollLeft}px` : '0',
}); });
} }
} };
getScrollTop() { public componentWillUnmount(): void {
return this._autoHideScrollbar.getScrollTop(); if (this.scrollElement) {
} this.scrollElement.removeEventListener("scroll", this.checkOverflow);
componentWillUnmount() {
if (this._scrollElement) {
this._scrollElement.removeEventListener("scroll", this.checkOverflow);
} }
} }
onMouseWheel = (e) => { private onMouseWheel = (e: React.WheelEvent): void => {
if (this.props.verticalScrollsHorizontally && this._scrollElement) { if (this.props.verticalScrollsHorizontally && this.scrollElement) {
// xyThreshold is the amount of horizontal motion required for the component to // xyThreshold is the amount of horizontal motion required for the component to
// ignore the vertical delta in a scroll. Used to stop trackpads from acting in // ignore the vertical delta in a scroll. Used to stop trackpads from acting in
// strange ways. Should be positive. // strange ways. Should be positive.
@ -150,19 +140,19 @@ export default class IndicatorScrollbar extends React.Component {
// for at least the next 1 minute. // for at least the next 1 minute.
const now = new Date().getTime(); const now = new Date().getTime();
if (Math.abs(e.deltaX) > 0) { if (Math.abs(e.deltaX) > 0) {
this._likelyTrackpadUser = true; this.likelyTrackpadUser = true;
this._checkAgainForTrackpad = now + (1 * 60 * 1000); this.checkAgainForTrackpad = now + (1 * 60 * 1000);
} else { } else {
// if we haven't seen any horizontal scrolling for a while, assume // if we haven't seen any horizontal scrolling for a while, assume
// the user might have plugged in a mousewheel // the user might have plugged in a mousewheel
if (this._likelyTrackpadUser && now >= this._checkAgainForTrackpad) { if (this.likelyTrackpadUser && now >= this.checkAgainForTrackpad) {
this._likelyTrackpadUser = false; this.likelyTrackpadUser = false;
} }
} }
// don't mess with the horizontal scroll for trackpad users // don't mess with the horizontal scroll for trackpad users
// See https://github.com/vector-im/element-web/issues/10005 // See https://github.com/vector-im/element-web/issues/10005
if (this._likelyTrackpadUser) { if (this.likelyTrackpadUser) {
return; return;
} }
@ -178,13 +168,13 @@ export default class IndicatorScrollbar extends React.Component {
// noinspection JSSuspiciousNameCombination // noinspection JSSuspiciousNameCombination
const val = Math.abs(e.deltaY) < 25 ? (e.deltaY + additionalScroll) : e.deltaY; const val = Math.abs(e.deltaY) < 25 ? (e.deltaY + additionalScroll) : e.deltaY;
this._scrollElement.scrollLeft += val * yRetention; this.scrollElement.scrollLeft += val * yRetention;
} }
} }
}; };
render() { public render(): JSX.Element {
// eslint-disable-next-line no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
const { children, trackHorizontalOverflow, verticalScrollsHorizontally, ...otherProps } = this.props; const { children, trackHorizontalOverflow, verticalScrollsHorizontally, ...otherProps } = this.props;
const leftIndicatorStyle = { left: this.state.leftIndicatorOffset }; const leftIndicatorStyle = { left: this.state.leftIndicatorOffset };
@ -195,8 +185,8 @@ export default class IndicatorScrollbar extends React.Component {
? <div className="mx_IndicatorScrollbar_rightOverflowIndicator" style={rightIndicatorStyle} /> : null; ? <div className="mx_IndicatorScrollbar_rightOverflowIndicator" style={rightIndicatorStyle} /> : null;
return (<AutoHideScrollbar return (<AutoHideScrollbar
ref={this._collectScrollerComponent} ref={this.autoHideScrollbar}
wrappedRef={this._collectScroller} wrappedRef={this.collectScroller}
onWheel={this.onMouseWheel} onWheel={this.onMouseWheel}
{...otherProps} {...otherProps}
> >

View file

@ -76,7 +76,6 @@ const LeftPanelWidget: React.FC = () => {
<AppTile <AppTile
app={app} app={app}
fullWidth fullWidth
show
showMenubar={false} showMenubar={false}
userWidget userWidget
userId={cli.getUserId()} userId={cli.getUserId()}

View file

@ -0,0 +1,115 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useContext } from "react";
import MatrixClientContext from "../../contexts/MatrixClientContext";
import { _t } from "../../languageHandler";
import AccessibleButton from "../views/elements/AccessibleButton";
import ErrorBoundary from "../views/elements/ErrorBoundary";
import { IGroupSummary } from "../views/dialogs/CreateSpaceFromCommunityDialog";
import { useAsyncMemo } from "../../hooks/useAsyncMemo";
import Spinner from "../views/elements/Spinner";
import GroupAvatar from "../views/avatars/GroupAvatar";
import { linkifyElement } from "../../HtmlUtils";
import defaultDispatcher from "../../dispatcher/dispatcher";
import { Action } from "../../dispatcher/actions";
import { UserTab } from "../views/dialogs/UserSettingsDialog";
interface IProps {
groupId: string;
}
const onSwapClick = () => {
defaultDispatcher.dispatch({
action: Action.ViewUserSettings,
initialTabId: UserTab.Preferences,
});
};
// XXX: temporary community migration component, reuses SpaceRoomView & SpacePreview classes for simplicity
const LegacyCommunityPreview = ({ groupId }: IProps) => {
const cli = useContext(MatrixClientContext);
const groupSummary = useAsyncMemo<IGroupSummary>(() => cli.getGroupSummary(groupId), [cli, groupId]);
if (!groupSummary) {
return <main className="mx_SpaceRoomView">
<div className="mx_MainSplit">
<div className="mx_SpaceRoomView_preview">
<Spinner />
</div>
</div>
</main>;
}
let visibilitySection: JSX.Element;
if (groupSummary.profile.is_public) {
visibilitySection = <span className="mx_SpaceRoomView_info_public">
{ _t("Public community") }
</span>;
} else {
visibilitySection = <span className="mx_SpaceRoomView_info_private">
{ _t("Private community") }
</span>;
}
return <main className="mx_SpaceRoomView">
<ErrorBoundary>
<div className="mx_MainSplit">
<div className="mx_SpaceRoomView_preview">
<GroupAvatar
groupId={groupId}
groupName={groupSummary.profile.name}
groupAvatarUrl={groupSummary.profile.avatar_url}
height={80}
width={80}
resizeMethod='crop'
/>
<h1 className="mx_SpaceRoomView_preview_name">
{ groupSummary.profile.name }
</h1>
<div className="mx_SpaceRoomView_info">
{ visibilitySection }
</div>
<div className="mx_SpaceRoomView_preview_topic" ref={e => e && linkifyElement(e)}>
{ groupSummary.profile.short_description }
</div>
<div className="mx_SpaceRoomView_preview_spaceBetaPrompt">
{ groupSummary.user?.membership === "join"
? _t("To view %(communityName)s, swap to communities in your <a>preferences</a>", {
communityName: groupSummary.profile.name,
}, {
a: sub => (
<AccessibleButton onClick={onSwapClick} kind="link">{ sub }</AccessibleButton>
),
})
: _t("To join %(communityName)s, swap to communities in your <a>preferences</a>", {
communityName: groupSummary.profile.name,
}, {
a: sub => (
<AccessibleButton onClick={onSwapClick} kind="link">{ sub }</AccessibleButton>
),
})
}
</div>
</div>
</div>
</ErrorBoundary>
</main>;
};
export default LegacyCommunityPreview;

View file

@ -69,6 +69,7 @@ import classNames from 'classnames';
import GroupFilterPanel from './GroupFilterPanel'; import GroupFilterPanel from './GroupFilterPanel';
import CustomRoomTagPanel from './CustomRoomTagPanel'; import CustomRoomTagPanel from './CustomRoomTagPanel';
import { mediaFromMxc } from "../../customisations/Media"; import { mediaFromMxc } from "../../customisations/Media";
import LegacyCommunityPreview from "./LegacyCommunityPreview";
// We need to fetch each pinned message individually (if we don't already have it) // We need to fetch each pinned message individually (if we don't already have it)
// so each pinned message may trigger a request. Limit the number per room for sanity. // so each pinned message may trigger a request. Limit the number per room for sanity.
@ -629,11 +630,15 @@ class LoggedInView extends React.Component<IProps, IState> {
pageElement = <UserView userId={this.props.currentUserId} resizeNotifier={this.props.resizeNotifier} />; pageElement = <UserView userId={this.props.currentUserId} resizeNotifier={this.props.resizeNotifier} />;
break; break;
case PageTypes.GroupView: case PageTypes.GroupView:
if (SpaceStore.spacesEnabled) {
pageElement = <LegacyCommunityPreview groupId={this.props.currentGroupId} />;
} else {
pageElement = <GroupView pageElement = <GroupView
groupId={this.props.currentGroupId} groupId={this.props.currentGroupId}
isNew={this.props.currentGroupIsNew} isNew={this.props.currentGroupIsNew}
resizeNotifier={this.props.resizeNotifier} resizeNotifier={this.props.resizeNotifier}
/>; />;
}
break; break;
} }

View file

@ -16,25 +16,35 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import { Resizable } from 're-resizable'; import { NumberSize, Resizable } from 're-resizable';
import { replaceableComponent } from "../../utils/replaceableComponent"; import { replaceableComponent } from "../../utils/replaceableComponent";
import ResizeNotifier from "../../utils/ResizeNotifier";
import { Direction } from "re-resizable/lib/resizer";
interface IProps {
resizeNotifier: ResizeNotifier;
collapsedRhs?: boolean;
panel?: JSX.Element;
}
@replaceableComponent("structures.MainSplit") @replaceableComponent("structures.MainSplit")
export default class MainSplit extends React.Component { export default class MainSplit extends React.Component<IProps> {
_onResizeStart = () => { private onResizeStart = (): void => {
this.props.resizeNotifier.startResizing(); this.props.resizeNotifier.startResizing();
}; };
_onResize = () => { private onResize = (): void => {
this.props.resizeNotifier.notifyRightHandleResized(); this.props.resizeNotifier.notifyRightHandleResized();
}; };
_onResizeStop = (event, direction, refToElement, delta) => { private onResizeStop = (
event: MouseEvent | TouchEvent, direction: Direction, elementRef: HTMLElement, delta: NumberSize,
): void => {
this.props.resizeNotifier.stopResizing(); this.props.resizeNotifier.stopResizing();
window.localStorage.setItem("mx_rhs_size", this._loadSidePanelSize().width + delta.width); window.localStorage.setItem("mx_rhs_size", (this.loadSidePanelSize().width + delta.width).toString());
}; };
_loadSidePanelSize() { private loadSidePanelSize(): {height: string | number, width: number} {
let rhsSize = parseInt(window.localStorage.getItem("mx_rhs_size"), 10); let rhsSize = parseInt(window.localStorage.getItem("mx_rhs_size"), 10);
if (isNaN(rhsSize)) { if (isNaN(rhsSize)) {
@ -47,7 +57,7 @@ export default class MainSplit extends React.Component {
}; };
} }
render() { public render(): JSX.Element {
const bodyView = React.Children.only(this.props.children); const bodyView = React.Children.only(this.props.children);
const panelView = this.props.panel; const panelView = this.props.panel;
@ -56,7 +66,7 @@ export default class MainSplit extends React.Component {
let children; let children;
if (hasResizer) { if (hasResizer) {
children = <Resizable children = <Resizable
defaultSize={this._loadSidePanelSize()} defaultSize={this.loadSidePanelSize()}
minWidth={264} minWidth={264}
maxWidth="50%" maxWidth="50%"
enable={{ enable={{
@ -69,9 +79,9 @@ export default class MainSplit extends React.Component {
bottomLeft: false, bottomLeft: false,
topLeft: false, topLeft: false,
}} }}
onResizeStart={this._onResizeStart} onResizeStart={this.onResizeStart}
onResize={this._onResize} onResize={this.onResize}
onResizeStop={this._onResizeStop} onResizeStop={this.onResizeStop}
className="mx_RightPanel_ResizeWrapper" className="mx_RightPanel_ResizeWrapper"
handleClasses={{ left: "mx_RightPanel_ResizeHandle" }} handleClasses={{ left: "mx_RightPanel_ResizeHandle" }}
> >

View file

@ -143,7 +143,7 @@ export enum Views {
SOFT_LOGOUT, SOFT_LOGOUT,
} }
const AUTH_SCREENS = ["register", "login", "forgot_password", "start_sso", "start_cas"]; const AUTH_SCREENS = ["register", "login", "forgot_password", "start_sso", "start_cas", "welcome"];
// Actions that are redirected through the onboarding process prior to being // Actions that are redirected through the onboarding process prior to being
// re-dispatched. NOTE: some actions are non-trivial and would require // re-dispatched. NOTE: some actions are non-trivial and would require
@ -1800,11 +1800,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
subAction: params.action, subAction: params.action,
}); });
} else if (screen.indexOf('group/') === 0) { } else if (screen.indexOf('group/') === 0) {
if (SpaceStore.spacesEnabled) {
dis.dispatch({ action: "view_home_page" });
return;
}
const groupId = screen.substring(6); const groupId = screen.substring(6);
// TODO: Check valid group ID // TODO: Check valid group ID
@ -1897,15 +1892,10 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
onSendEvent(roomId: string, event: MatrixEvent) { onSendEvent(roomId: string, event: MatrixEvent) {
const cli = MatrixClientPeg.get(); const cli = MatrixClientPeg.get();
if (!cli) { if (!cli) return;
dis.dispatch({ action: 'message_send_failed' });
return;
}
cli.sendEvent(roomId, event.getType(), event.getContent()).then(() => { cli.sendEvent(roomId, event.getType(), event.getContent()).then(() => {
dis.dispatch({ action: 'message_sent' }); dis.dispatch({ action: 'message_sent' });
}, (err) => {
dis.dispatch({ action: 'message_send_failed' });
}); });
} }

View file

@ -448,7 +448,9 @@ export default class MessagePanel extends React.Component<IProps, IState> {
// Always show highlighted event // Always show highlighted event
if (this.props.highlightedEventId === mxEv.getId()) return true; if (this.props.highlightedEventId === mxEv.getId()) return true;
if (mxEv.replyInThread // Checking if the message has a "parentEventId" as we do not
// want to hide the root event of the thread
if (mxEv.replyInThread && mxEv.parentEventId
&& this.props.hideThreadedMessages && this.props.hideThreadedMessages
&& SettingsStore.getValue("feature_thread")) { && SettingsStore.getValue("feature_thread")) {
return false; return false;

View file

@ -25,7 +25,6 @@ import AccessibleButton from '../views/elements/AccessibleButton';
import MatrixClientContext from "../../contexts/MatrixClientContext"; import MatrixClientContext from "../../contexts/MatrixClientContext";
import AutoHideScrollbar from "./AutoHideScrollbar"; import AutoHideScrollbar from "./AutoHideScrollbar";
import { replaceableComponent } from "../../utils/replaceableComponent"; import { replaceableComponent } from "../../utils/replaceableComponent";
import BetaCard from "../views/beta/BetaCard";
@replaceableComponent("structures.MyGroups") @replaceableComponent("structures.MyGroups")
export default class MyGroups extends React.Component { export default class MyGroups extends React.Component {
@ -138,7 +137,6 @@ export default class MyGroups extends React.Component {
</div> </div>
</div>*/ } </div>*/ }
</div> </div>
<BetaCard featureId="feature_spaces" title={_t("Communities are changing to Spaces")} />
<div className="mx_MyGroups_content"> <div className="mx_MyGroups_content">
{ contentHeader } { contentHeader }
{ content } { content }

View file

@ -15,95 +15,110 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import { _t, _td } from '../../languageHandler'; import { _t, _td } from '../../languageHandler';
import { MatrixClientPeg } from '../../MatrixClientPeg';
import Resend from '../../Resend'; import Resend from '../../Resend';
import dis from '../../dispatcher/dispatcher'; import dis from '../../dispatcher/dispatcher';
import { messageForResourceLimitError } from '../../utils/ErrorUtils'; import { messageForResourceLimitError } from '../../utils/ErrorUtils';
import { Action } from "../../dispatcher/actions"; import { Action } from "../../dispatcher/actions";
import { replaceableComponent } from "../../utils/replaceableComponent"; import { replaceableComponent } from "../../utils/replaceableComponent";
import { EventStatus } from "matrix-js-sdk/src/models/event"; import { EventStatus, MatrixEvent } from "matrix-js-sdk/src/models/event";
import NotificationBadge from "../views/rooms/NotificationBadge"; import NotificationBadge from "../views/rooms/NotificationBadge";
import { StaticNotificationState } from "../../stores/notifications/StaticNotificationState"; import { StaticNotificationState } from "../../stores/notifications/StaticNotificationState";
import AccessibleButton from "../views/elements/AccessibleButton"; import AccessibleButton from "../views/elements/AccessibleButton";
import InlineSpinner from "../views/elements/InlineSpinner"; import InlineSpinner from "../views/elements/InlineSpinner";
import { SyncState } from "matrix-js-sdk/src/sync.api";
import { ISyncStateData } from "matrix-js-sdk/src/sync";
import { Room } from "matrix-js-sdk/src/models/room";
import MatrixClientContext from "../../contexts/MatrixClientContext";
const STATUS_BAR_HIDDEN = 0; const STATUS_BAR_HIDDEN = 0;
const STATUS_BAR_EXPANDED = 1; const STATUS_BAR_EXPANDED = 1;
const STATUS_BAR_EXPANDED_LARGE = 2; const STATUS_BAR_EXPANDED_LARGE = 2;
export function getUnsentMessages(room) { export function getUnsentMessages(room: Room): MatrixEvent[] {
if (!room) { return []; } if (!room) { return []; }
return room.getPendingEvents().filter(function(ev) { return room.getPendingEvents().filter(function(ev) {
return ev.status === EventStatus.NOT_SENT; return ev.status === EventStatus.NOT_SENT;
}); });
} }
@replaceableComponent("structures.RoomStatusBar") interface IProps {
export default class RoomStatusBar extends React.PureComponent {
static propTypes = {
// the room this statusbar is representing. // the room this statusbar is representing.
room: PropTypes.object.isRequired, room: Room;
// true if the room is being peeked at. This affects components that shouldn't // true if the room is being peeked at. This affects components that shouldn't
// logically be shown when peeking, such as a prompt to invite people to a room. // logically be shown when peeking, such as a prompt to invite people to a room.
isPeeking: PropTypes.bool, isPeeking?: boolean;
// callback for when the user clicks on the 'resend all' button in the // callback for when the user clicks on the 'resend all' button in the
// 'unsent messages' bar // 'unsent messages' bar
onResendAllClick: PropTypes.func, onResendAllClick?: () => void;
// callback for when the user clicks on the 'cancel all' button in the // callback for when the user clicks on the 'cancel all' button in the
// 'unsent messages' bar // 'unsent messages' bar
onCancelAllClick: PropTypes.func, onCancelAllClick?: () => void;
// callback for when the user clicks on the 'invite others' button in the // callback for when the user clicks on the 'invite others' button in the
// 'you are alone' bar // 'you are alone' bar
onInviteClick: PropTypes.func, onInviteClick?: () => void;
// callback for when we do something that changes the size of the // callback for when we do something that changes the size of the
// status bar. This is used to trigger a re-layout in the parent // status bar. This is used to trigger a re-layout in the parent
// component. // component.
onResize: PropTypes.func, onResize?: () => void;
// callback for when the status bar can be hidden from view, as it is // callback for when the status bar can be hidden from view, as it is
// not displaying anything // not displaying anything
onHidden: PropTypes.func, onHidden?: () => void;
// callback for when the status bar is displaying something and should // callback for when the status bar is displaying something and should
// be visible // be visible
onVisible: PropTypes.func, onVisible?: () => void;
}; }
state = { interface IState {
syncState: MatrixClientPeg.get().getSyncState(), syncState: SyncState;
syncStateData: MatrixClientPeg.get().getSyncStateData(), syncStateData: ISyncStateData;
unsentMessages: MatrixEvent[];
isResending: boolean;
}
@replaceableComponent("structures.RoomStatusBar")
export default class RoomStatusBar extends React.PureComponent<IProps, IState> {
public static contextType = MatrixClientContext;
constructor(props: IProps, context: typeof MatrixClientContext) {
super(props, context);
this.state = {
syncState: this.context.getSyncState(),
syncStateData: this.context.getSyncStateData(),
unsentMessages: getUnsentMessages(this.props.room), unsentMessages: getUnsentMessages(this.props.room),
isResending: false, isResending: false,
}; };
componentDidMount() {
MatrixClientPeg.get().on("sync", this.onSyncStateChange);
MatrixClientPeg.get().on("Room.localEchoUpdated", this._onRoomLocalEchoUpdated);
this._checkSize();
} }
componentDidUpdate() { public componentDidMount(): void {
this._checkSize(); const client = this.context;
client.on("sync", this.onSyncStateChange);
client.on("Room.localEchoUpdated", this.onRoomLocalEchoUpdated);
this.checkSize();
} }
componentWillUnmount() { public componentDidUpdate(): void {
this.checkSize();
}
public componentWillUnmount(): void {
// we may have entirely lost our client as we're logging out before clicking login on the guest bar... // we may have entirely lost our client as we're logging out before clicking login on the guest bar...
const client = MatrixClientPeg.get(); const client = this.context;
if (client) { if (client) {
client.removeListener("sync", this.onSyncStateChange); client.removeListener("sync", this.onSyncStateChange);
client.removeListener("Room.localEchoUpdated", this._onRoomLocalEchoUpdated); client.removeListener("Room.localEchoUpdated", this.onRoomLocalEchoUpdated);
} }
} }
onSyncStateChange = (state, prevState, data) => { private onSyncStateChange = (state: SyncState, prevState: SyncState, data: ISyncStateData): void => {
if (state === "SYNCING" && prevState === "SYNCING") { if (state === "SYNCING" && prevState === "SYNCING") {
return; return;
} }
@ -113,7 +128,7 @@ export default class RoomStatusBar extends React.PureComponent {
}); });
}; };
_onResendAllClick = () => { private onResendAllClick = (): void => {
Resend.resendUnsentEvents(this.props.room).then(() => { Resend.resendUnsentEvents(this.props.room).then(() => {
this.setState({ isResending: false }); this.setState({ isResending: false });
}); });
@ -121,12 +136,12 @@ export default class RoomStatusBar extends React.PureComponent {
dis.fire(Action.FocusSendMessageComposer); dis.fire(Action.FocusSendMessageComposer);
}; };
_onCancelAllClick = () => { private onCancelAllClick = (): void => {
Resend.cancelUnsentEvents(this.props.room); Resend.cancelUnsentEvents(this.props.room);
dis.fire(Action.FocusSendMessageComposer); dis.fire(Action.FocusSendMessageComposer);
}; };
_onRoomLocalEchoUpdated = (event, room, oldEventId, oldStatus) => { private onRoomLocalEchoUpdated = (ev: MatrixEvent, room: Room) => {
if (room.roomId !== this.props.room.roomId) return; if (room.roomId !== this.props.room.roomId) return;
const messages = getUnsentMessages(this.props.room); const messages = getUnsentMessages(this.props.room);
this.setState({ this.setState({
@ -136,8 +151,8 @@ export default class RoomStatusBar extends React.PureComponent {
}; };
// Check whether current size is greater than 0, if yes call props.onVisible // Check whether current size is greater than 0, if yes call props.onVisible
_checkSize() { private checkSize(): void {
if (this._getSize()) { if (this.getSize()) {
if (this.props.onVisible) this.props.onVisible(); if (this.props.onVisible) this.props.onVisible();
} else { } else {
if (this.props.onHidden) this.props.onHidden(); if (this.props.onHidden) this.props.onHidden();
@ -147,8 +162,8 @@ export default class RoomStatusBar extends React.PureComponent {
// We don't need the actual height - just whether it is likely to have // We don't need the actual height - just whether it is likely to have
// changed - so we use '0' to indicate normal size, and other values to // changed - so we use '0' to indicate normal size, and other values to
// indicate other sizes. // indicate other sizes.
_getSize() { private getSize(): number {
if (this._shouldShowConnectionError()) { if (this.shouldShowConnectionError()) {
return STATUS_BAR_EXPANDED; return STATUS_BAR_EXPANDED;
} else if (this.state.unsentMessages.length > 0 || this.state.isResending) { } else if (this.state.unsentMessages.length > 0 || this.state.isResending) {
return STATUS_BAR_EXPANDED_LARGE; return STATUS_BAR_EXPANDED_LARGE;
@ -156,7 +171,7 @@ export default class RoomStatusBar extends React.PureComponent {
return STATUS_BAR_HIDDEN; return STATUS_BAR_HIDDEN;
} }
_shouldShowConnectionError() { private shouldShowConnectionError(): boolean {
// no conn bar trumps the "some not sent" msg since you can't resend without // no conn bar trumps the "some not sent" msg since you can't resend without
// a connection! // a connection!
// There's one situation in which we don't show this 'no connection' bar, and that's // There's one situation in which we don't show this 'no connection' bar, and that's
@ -164,12 +179,12 @@ export default class RoomStatusBar extends React.PureComponent {
const errorIsMauError = Boolean( const errorIsMauError = Boolean(
this.state.syncStateData && this.state.syncStateData &&
this.state.syncStateData.error && this.state.syncStateData.error &&
this.state.syncStateData.error.errcode === 'M_RESOURCE_LIMIT_EXCEEDED', this.state.syncStateData.error.name === 'M_RESOURCE_LIMIT_EXCEEDED',
); );
return this.state.syncState === "ERROR" && !errorIsMauError; return this.state.syncState === "ERROR" && !errorIsMauError;
} }
_getUnsentMessageContent() { private getUnsentMessageContent(): JSX.Element {
const unsentMessages = this.state.unsentMessages; const unsentMessages = this.state.unsentMessages;
let title; let title;
@ -221,10 +236,10 @@ export default class RoomStatusBar extends React.PureComponent {
} }
let buttonRow = <> let buttonRow = <>
<AccessibleButton onClick={this._onCancelAllClick} className="mx_RoomStatusBar_unsentCancelAllBtn"> <AccessibleButton onClick={this.onCancelAllClick} className="mx_RoomStatusBar_unsentCancelAllBtn">
{ _t("Delete all") } { _t("Delete all") }
</AccessibleButton> </AccessibleButton>
<AccessibleButton onClick={this._onResendAllClick} className="mx_RoomStatusBar_unsentResendAllBtn"> <AccessibleButton onClick={this.onResendAllClick} className="mx_RoomStatusBar_unsentResendAllBtn">
{ _t("Retry all") } { _t("Retry all") }
</AccessibleButton> </AccessibleButton>
</>; </>;
@ -260,8 +275,8 @@ export default class RoomStatusBar extends React.PureComponent {
</>; </>;
} }
render() { public render(): JSX.Element {
if (this._shouldShowConnectionError()) { if (this.shouldShowConnectionError()) {
return ( return (
<div className="mx_RoomStatusBar"> <div className="mx_RoomStatusBar">
<div role="alert"> <div role="alert">
@ -287,7 +302,7 @@ export default class RoomStatusBar extends React.PureComponent {
} }
if (this.state.unsentMessages.length > 0 || this.state.isResending) { if (this.state.unsentMessages.length > 0 || this.state.isResending) {
return this._getUnsentMessageContent(); return this.getUnsentMessageContent();
} }
return null; return null;

View file

@ -16,7 +16,6 @@ limitations under the License.
*/ */
import React, { createRef } from 'react'; import React, { createRef } from 'react';
import PropTypes from 'prop-types';
import { Key } from '../../Keyboard'; import { Key } from '../../Keyboard';
import dis from '../../dispatcher/dispatcher'; import dis from '../../dispatcher/dispatcher';
import { throttle } from 'lodash'; import { throttle } from 'lodash';
@ -24,106 +23,116 @@ import AccessibleButton from '../../components/views/elements/AccessibleButton';
import classNames from 'classnames'; import classNames from 'classnames';
import { replaceableComponent } from "../../utils/replaceableComponent"; import { replaceableComponent } from "../../utils/replaceableComponent";
@replaceableComponent("structures.SearchBox") interface IProps {
export default class SearchBox extends React.Component { onSearch?: (query: string) => void;
static propTypes = { onCleared?: (source?: string) => void;
onSearch: PropTypes.func, onKeyDown?: (ev: React.KeyboardEvent) => void;
onCleared: PropTypes.func, onFocus?: (ev: React.FocusEvent) => void;
onKeyDown: PropTypes.func, onBlur?: (ev: React.FocusEvent) => void;
className: PropTypes.string, className?: string;
placeholder: PropTypes.string.isRequired, placeholder: string;
autoFocus: PropTypes.bool, blurredPlaceholder?: string;
initialValue: PropTypes.string, autoFocus?: boolean;
initialValue?: string;
collapsed?: boolean;
// If true, the search box will focus and clear itself // If true, the search box will focus and clear itself
// on room search focus action (it would be nicer to take // on room search focus action (it would be nicer to take
// this functionality out, but not obvious how that would work) // this functionality out, but not obvious how that would work)
enableRoomSearchFocus: PropTypes.bool, enableRoomSearchFocus?: boolean;
}; }
static defaultProps = { interface IState {
searchTerm: string;
blurred: boolean;
}
@replaceableComponent("structures.SearchBox")
export default class SearchBox extends React.Component<IProps, IState> {
private dispatcherRef: string;
private search = createRef<HTMLInputElement>();
static defaultProps: Partial<IProps> = {
enableRoomSearchFocus: false, enableRoomSearchFocus: false,
}; };
constructor(props) { constructor(props: IProps) {
super(props); super(props);
this._search = createRef();
this.state = { this.state = {
searchTerm: this.props.initialValue || "", searchTerm: props.initialValue || "",
blurred: true, blurred: true,
}; };
} }
componentDidMount() { public componentDidMount(): void {
this.dispatcherRef = dis.register(this.onAction); this.dispatcherRef = dis.register(this.onAction);
} }
componentWillUnmount() { public componentWillUnmount(): void {
dis.unregister(this.dispatcherRef); dis.unregister(this.dispatcherRef);
} }
onAction = payload => { private onAction = (payload): void => {
if (!this.props.enableRoomSearchFocus) return; if (!this.props.enableRoomSearchFocus) return;
switch (payload.action) { switch (payload.action) {
case 'view_room': case 'view_room':
if (this._search.current && payload.clear_search) { if (this.search.current && payload.clear_search) {
this._clearSearch(); this.clearSearch();
} }
break; break;
case 'focus_room_filter': case 'focus_room_filter':
if (this._search.current) { if (this.search.current) {
this._search.current.focus(); this.search.current.focus();
} }
break; break;
} }
}; };
onChange = () => { private onChange = (): void => {
if (!this._search.current) return; if (!this.search.current) return;
this.setState({ searchTerm: this._search.current.value }); this.setState({ searchTerm: this.search.current.value });
this.onSearch(); this.onSearch();
}; };
onSearch = throttle(() => { private onSearch = throttle((): void => {
this.props.onSearch(this._search.current.value); this.props.onSearch(this.search.current.value);
}, 200, { trailing: true, leading: true }); }, 200, { trailing: true, leading: true });
_onKeyDown = ev => { private onKeyDown = (ev: React.KeyboardEvent): void => {
switch (ev.key) { switch (ev.key) {
case Key.ESCAPE: case Key.ESCAPE:
this._clearSearch("keyboard"); this.clearSearch("keyboard");
break; break;
} }
if (this.props.onKeyDown) this.props.onKeyDown(ev); if (this.props.onKeyDown) this.props.onKeyDown(ev);
}; };
_onFocus = ev => { private onFocus = (ev: React.FocusEvent): void => {
this.setState({ blurred: false }); this.setState({ blurred: false });
ev.target.select(); (ev.target as HTMLInputElement).select();
if (this.props.onFocus) { if (this.props.onFocus) {
this.props.onFocus(ev); this.props.onFocus(ev);
} }
}; };
_onBlur = ev => { private onBlur = (ev: React.FocusEvent): void => {
this.setState({ blurred: true }); this.setState({ blurred: true });
if (this.props.onBlur) { if (this.props.onBlur) {
this.props.onBlur(ev); this.props.onBlur(ev);
} }
}; };
_clearSearch(source) { private clearSearch(source?: string): void {
this._search.current.value = ""; this.search.current.value = "";
this.onChange(); this.onChange();
if (this.props.onCleared) { if (this.props.onCleared) {
this.props.onCleared(source); this.props.onCleared(source);
} }
} }
render() { public render(): JSX.Element {
// check for collapsed here and // check for collapsed here and
// not at parent so we keep // not at parent so we keep
// searchTerm in our state // searchTerm in our state
@ -136,7 +145,7 @@ export default class SearchBox extends React.Component {
key="button" key="button"
tabIndex={-1} tabIndex={-1}
className="mx_SearchBox_closeButton" className="mx_SearchBox_closeButton"
onClick={() => {this._clearSearch("button"); }} onClick={() => {this.clearSearch("button"); }}
/>) : undefined; />) : undefined;
// show a shorter placeholder when blurred, if requested // show a shorter placeholder when blurred, if requested
@ -151,13 +160,13 @@ export default class SearchBox extends React.Component {
<input <input
key="searchfield" key="searchfield"
type="text" type="text"
ref={this._search} ref={this.search}
className={"mx_textinput_icon mx_textinput_search " + className} className={"mx_textinput_icon mx_textinput_search " + className}
value={this.state.searchTerm} value={this.state.searchTerm}
onFocus={this._onFocus} onFocus={this.onFocus}
onChange={this.onChange} onChange={this.onChange}
onKeyDown={this._onKeyDown} onKeyDown={this.onKeyDown}
onBlur={this._onBlur} onBlur={this.onBlur}
placeholder={placeholder} placeholder={placeholder}
autoComplete="off" autoComplete="off"
autoFocus={this.props.autoFocus} autoFocus={this.props.autoFocus}

View file

@ -57,12 +57,20 @@ import { Key } from "../../Keyboard";
import { IState, RovingTabIndexProvider, useRovingTabIndex } from "../../accessibility/RovingTabIndex"; import { IState, RovingTabIndexProvider, useRovingTabIndex } from "../../accessibility/RovingTabIndex";
import { getDisplayAliasForRoom } from "./RoomDirectory"; import { getDisplayAliasForRoom } from "./RoomDirectory";
import MatrixClientContext from "../../contexts/MatrixClientContext"; import MatrixClientContext from "../../contexts/MatrixClientContext";
import { useEventEmitterState } from "../../hooks/useEventEmitter";
import { IOOBData } from "../../stores/ThreepidInviteStore";
interface IProps { interface IProps {
space: Room; space: Room;
initialText?: string; initialText?: string;
additionalButtons?: ReactNode; additionalButtons?: ReactNode;
showRoom(cli: MatrixClient, hierarchy: RoomHierarchy, roomId: string, autoJoin?: boolean): void; showRoom(
cli: MatrixClient,
hierarchy: RoomHierarchy,
roomId: string,
autoJoin?: boolean,
roomType?: RoomType,
): void;
} }
interface ITileProps { interface ITileProps {
@ -71,7 +79,7 @@ interface ITileProps {
selected?: boolean; selected?: boolean;
numChildRooms?: number; numChildRooms?: number;
hasPermissions?: boolean; hasPermissions?: boolean;
onViewRoomClick(autoJoin: boolean): void; onViewRoomClick(autoJoin: boolean, roomType: RoomType): void;
onToggleClick?(): void; onToggleClick?(): void;
} }
@ -87,7 +95,8 @@ const Tile: React.FC<ITileProps> = ({
}) => { }) => {
const cli = useContext(MatrixClientContext); const cli = useContext(MatrixClientContext);
const joinedRoom = cli.getRoom(room.room_id)?.getMyMembership() === "join" ? cli.getRoom(room.room_id) : null; const joinedRoom = cli.getRoom(room.room_id)?.getMyMembership() === "join" ? cli.getRoom(room.room_id) : null;
const name = joinedRoom?.name || room.name || room.canonical_alias || room.aliases?.[0] const joinedRoomName = useEventEmitterState(joinedRoom, "Room.name", room => room?.name);
const name = joinedRoomName || room.name || room.canonical_alias || room.aliases?.[0]
|| (room.room_type === RoomType.Space ? _t("Unnamed Space") : _t("Unnamed Room")); || (room.room_type === RoomType.Space ? _t("Unnamed Space") : _t("Unnamed Room"));
const [showChildren, toggleShowChildren] = useStateToggle(true); const [showChildren, toggleShowChildren] = useStateToggle(true);
@ -96,12 +105,12 @@ const Tile: React.FC<ITileProps> = ({
const onPreviewClick = (ev: ButtonEvent) => { const onPreviewClick = (ev: ButtonEvent) => {
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
onViewRoomClick(false); onViewRoomClick(false, room.room_type as RoomType);
}; };
const onJoinClick = (ev: ButtonEvent) => { const onJoinClick = (ev: ButtonEvent) => {
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
onViewRoomClick(true); onViewRoomClick(true, room.room_type as RoomType);
}; };
let button; let button;
@ -278,7 +287,13 @@ const Tile: React.FC<ITileProps> = ({
</li>; </li>;
}; };
export const showRoom = (cli: MatrixClient, hierarchy: RoomHierarchy, roomId: string, autoJoin = false) => { export const showRoom = (
cli: MatrixClient,
hierarchy: RoomHierarchy,
roomId: string,
autoJoin = false,
roomType?: RoomType,
) => {
const room = hierarchy.roomMap.get(roomId); const room = hierarchy.roomMap.get(roomId);
// Don't let the user view a room they won't be able to either peek or join: // Don't let the user view a room they won't be able to either peek or join:
@ -303,7 +318,8 @@ export const showRoom = (cli: MatrixClient, hierarchy: RoomHierarchy, roomId: st
avatarUrl: room.avatar_url, avatarUrl: room.avatar_url,
// XXX: This logic is duplicated from the JS SDK which would normally decide what the name is. // XXX: This logic is duplicated from the JS SDK which would normally decide what the name is.
name: room.name || roomAlias || _t("Unnamed room"), name: room.name || roomAlias || _t("Unnamed room"),
}, roomType,
} as IOOBData,
}); });
}; };
@ -313,7 +329,7 @@ interface IHierarchyLevelProps {
hierarchy: RoomHierarchy; hierarchy: RoomHierarchy;
parents: Set<string>; parents: Set<string>;
selectedMap?: Map<string, Set<string>>; selectedMap?: Map<string, Set<string>>;
onViewRoomClick(roomId: string, autoJoin: boolean): void; onViewRoomClick(roomId: string, autoJoin: boolean, roomType?: RoomType): void;
onToggleClick?(parentId: string, childId: string): void; onToggleClick?(parentId: string, childId: string): void;
} }
@ -351,8 +367,8 @@ export const HierarchyLevel = ({
room={room} room={room}
suggested={hierarchy.isSuggested(root.room_id, room.room_id)} suggested={hierarchy.isSuggested(root.room_id, room.room_id)}
selected={selectedMap?.get(root.room_id)?.has(room.room_id)} selected={selectedMap?.get(root.room_id)?.has(room.room_id)}
onViewRoomClick={(autoJoin) => { onViewRoomClick={(autoJoin, roomType) => {
onViewRoomClick(room.room_id, autoJoin); onViewRoomClick(room.room_id, autoJoin, roomType);
}} }}
hasPermissions={hasPermissions} hasPermissions={hasPermissions}
onToggleClick={onToggleClick ? () => onToggleClick(root.room_id, room.room_id) : undefined} onToggleClick={onToggleClick ? () => onToggleClick(root.room_id, room.room_id) : undefined}
@ -371,8 +387,8 @@ export const HierarchyLevel = ({
}).length} }).length}
suggested={hierarchy.isSuggested(root.room_id, space.room_id)} suggested={hierarchy.isSuggested(root.room_id, space.room_id)}
selected={selectedMap?.get(root.room_id)?.has(space.room_id)} selected={selectedMap?.get(root.room_id)?.has(space.room_id)}
onViewRoomClick={(autoJoin) => { onViewRoomClick={(autoJoin, roomType) => {
onViewRoomClick(space.room_id, autoJoin); onViewRoomClick(space.room_id, autoJoin, roomType);
}} }}
hasPermissions={hasPermissions} hasPermissions={hasPermissions}
onToggleClick={onToggleClick ? () => onToggleClick(root.room_id, space.room_id) : undefined} onToggleClick={onToggleClick ? () => onToggleClick(root.room_id, space.room_id) : undefined}
@ -574,7 +590,7 @@ const SpaceHierarchy = ({
const { loading, rooms, hierarchy, loadMore } = useSpaceSummary(space); const { loading, rooms, hierarchy, loadMore } = useSpaceSummary(space);
const filteredRoomSet = useMemo<Set<IHierarchyRoom>>(() => { const filteredRoomSet = useMemo<Set<IHierarchyRoom>>(() => {
if (!rooms.length) return new Set(); if (!rooms?.length) return new Set();
const lcQuery = query.toLowerCase().trim(); const lcQuery = query.toLowerCase().trim();
if (!lcQuery) return new Set(rooms); if (!lcQuery) return new Set(rooms);
@ -650,8 +666,8 @@ const SpaceHierarchy = ({
parents={new Set()} parents={new Set()}
selectedMap={selected} selectedMap={selected}
onToggleClick={hasPermissions ? onToggleClick : undefined} onToggleClick={hasPermissions ? onToggleClick : undefined}
onViewRoomClick={(roomId, autoJoin) => { onViewRoomClick={(roomId, autoJoin, roomType) => {
showRoom(cli, hierarchy, roomId, autoJoin); showRoom(cli, hierarchy, roomId, autoJoin, roomType);
}} }}
/> />
</>; </>;

View file

@ -78,6 +78,7 @@ import { CreateEventField, IGroupSummary } from "../views/dialogs/CreateSpaceFro
import { useAsyncMemo } from "../../hooks/useAsyncMemo"; import { useAsyncMemo } from "../../hooks/useAsyncMemo";
import Spinner from "../views/elements/Spinner"; import Spinner from "../views/elements/Spinner";
import GroupAvatar from "../views/avatars/GroupAvatar"; import GroupAvatar from "../views/avatars/GroupAvatar";
import { useDispatcher } from "../../hooks/useDispatcher";
interface IProps { interface IProps {
space: Room; space: Room;
@ -155,10 +156,10 @@ const SpaceInfo = ({ space }) => {
</div>; </div>;
}; };
const onBetaClick = () => { const onPreferencesClick = () => {
defaultDispatcher.dispatch({ defaultDispatcher.dispatch({
action: Action.ViewUserSettings, action: Action.ViewUserSettings,
initialTabId: UserTab.Labs, initialTabId: UserTab.Preferences,
}); });
}; };
@ -191,6 +192,11 @@ interface ISpacePreviewProps {
const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }: ISpacePreviewProps) => { const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }: ISpacePreviewProps) => {
const cli = useContext(MatrixClientContext); const cli = useContext(MatrixClientContext);
const myMembership = useMyRoomMembership(space); const myMembership = useMyRoomMembership(space);
useDispatcher(defaultDispatcher, payload => {
if (payload.action === Action.JoinRoomError && payload.roomId === space.roomId) {
setBusy(false); // stop the spinner, join failed
}
});
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
@ -280,15 +286,11 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }: ISp
if (!spacesEnabled) { if (!spacesEnabled) {
footer = <div className="mx_SpaceRoomView_preview_spaceBetaPrompt"> footer = <div className="mx_SpaceRoomView_preview_spaceBetaPrompt">
{ myMembership === "join" { myMembership === "join"
? _t("To view %(spaceName)s, turn on the <a>Spaces beta</a>", { ? _t("To view this Space, hide communities in your <a>preferences</a>", {}, {
spaceName: space.name, a: sub => <AccessibleButton onClick={onPreferencesClick} kind="link">{ sub }</AccessibleButton>,
}, {
a: sub => <AccessibleButton onClick={onBetaClick} kind="link">{ sub }</AccessibleButton>,
}) })
: _t("To join %(spaceName)s, turn on the <a>Spaces beta</a>", { : _t("To join this Space, hide communities in your <a>preferences</a>", {}, {
spaceName: space.name, a: sub => <AccessibleButton onClick={onPreferencesClick} kind="link">{ sub }</AccessibleButton>,
}, {
a: sub => <AccessibleButton onClick={onBetaClick} kind="link">{ sub }</AccessibleButton>,
}) })
} }
</div>; </div>;
@ -725,7 +727,7 @@ const SpaceSetupPrivateInvite = ({ space, onFinished }) => {
</div> </div>
<div className="mx_SpaceRoomView_inviteTeammates_betaDisclaimer"> <div className="mx_SpaceRoomView_inviteTeammates_betaDisclaimer">
<BetaPill onClick={onBetaClick} /> <BetaPill />
{ _t("<b>This is an experimental feature.</b> For now, " + { _t("<b>This is an experimental feature.</b> For now, " +
"new users receiving an invite will have to open the invite on <link/> to actually join.", {}, { "new users receiving an invite will have to open the invite on <link/> to actually join.", {}, {
b: sub => <b>{ sub }</b>, b: sub => <b>{ sub }</b>,

View file

@ -16,7 +16,7 @@ limitations under the License.
import React from 'react'; import React from 'react';
import { MatrixEvent, Room } from 'matrix-js-sdk/src'; import { MatrixEvent, Room } from 'matrix-js-sdk/src';
import { Thread } from 'matrix-js-sdk/src/models/thread'; import { Thread, ThreadEvent } from 'matrix-js-sdk/src/models/thread';
import BaseCard from "../views/right_panel/BaseCard"; import BaseCard from "../views/right_panel/BaseCard";
import { RightPanelPhases } from "../../stores/RightPanelStorePhases"; import { RightPanelPhases } from "../../stores/RightPanelStorePhases";
@ -46,13 +46,13 @@ export default class ThreadPanel extends React.Component<IProps, IState> {
} }
public componentDidMount(): void { public componentDidMount(): void {
this.room.on("Thread.update", this.onThreadEventReceived); this.room.on(ThreadEvent.Update, this.onThreadEventReceived);
this.room.on("Thread.ready", this.onThreadEventReceived); this.room.on(ThreadEvent.Ready, this.onThreadEventReceived);
} }
public componentWillUnmount(): void { public componentWillUnmount(): void {
this.room.removeListener("Thread.update", this.onThreadEventReceived); this.room.removeListener(ThreadEvent.Update, this.onThreadEventReceived);
this.room.removeListener("Thread.ready", this.onThreadEventReceived); this.room.removeListener(ThreadEvent.Ready, this.onThreadEventReceived);
} }
private onThreadEventReceived = () => this.updateThreads(); private onThreadEventReceived = () => this.updateThreads();

View file

@ -16,7 +16,7 @@ limitations under the License.
import React from 'react'; import React from 'react';
import { MatrixEvent, Room } from 'matrix-js-sdk/src'; import { MatrixEvent, Room } from 'matrix-js-sdk/src';
import { Thread } from 'matrix-js-sdk/src/models/thread'; import { Thread, ThreadEvent } from 'matrix-js-sdk/src/models/thread';
import BaseCard from "../views/right_panel/BaseCard"; import BaseCard from "../views/right_panel/BaseCard";
import { RightPanelPhases } from "../../stores/RightPanelStorePhases"; import { RightPanelPhases } from "../../stores/RightPanelStorePhases";
@ -99,15 +99,15 @@ export default class ThreadView extends React.Component<IProps, IState> {
thread = new Thread([mxEv], this.props.room, client); thread = new Thread([mxEv], this.props.room, client);
mxEv.setThread(thread); mxEv.setThread(thread);
} }
thread.on("Thread.update", this.updateThread); thread.on(ThreadEvent.Update, this.updateThread);
thread.once("Thread.ready", this.updateThread); thread.once(ThreadEvent.Ready, this.updateThread);
this.updateThread(thread); this.updateThread(thread);
}; };
private teardownThread = () => { private teardownThread = () => {
if (this.state.thread) { if (this.state.thread) {
this.state.thread.removeListener("Thread.update", this.updateThread); this.state.thread.removeListener(ThreadEvent.Update, this.updateThread);
this.state.thread.removeListener("Thread.ready", this.updateThread); this.state.thread.removeListener(ThreadEvent.Ready, this.updateThread);
} }
}; };

View file

@ -16,52 +16,60 @@ limitations under the License.
*/ */
import React from "react"; import React from "react";
import PropTypes from "prop-types";
import { MatrixClientPeg } from "../../MatrixClientPeg"; import { MatrixClientPeg } from "../../MatrixClientPeg";
import * as sdk from "../../index";
import Modal from '../../Modal'; import Modal from '../../Modal';
import { _t } from '../../languageHandler'; import { _t } from '../../languageHandler';
import HomePage from "./HomePage"; import HomePage from "./HomePage";
import { replaceableComponent } from "../../utils/replaceableComponent"; import { replaceableComponent } from "../../utils/replaceableComponent";
import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { RoomMember } from "matrix-js-sdk/src/models/room-member"; import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import ErrorDialog from "../views/dialogs/ErrorDialog";
import MainSplit from "./MainSplit";
import RightPanel from "./RightPanel";
import Spinner from "../views/elements/Spinner";
import ResizeNotifier from "../../utils/ResizeNotifier";
interface IProps {
userId?: string;
resizeNotifier: ResizeNotifier;
}
interface IState {
loading: boolean;
member?: RoomMember;
}
@replaceableComponent("structures.UserView") @replaceableComponent("structures.UserView")
export default class UserView extends React.Component { export default class UserView extends React.Component<IProps, IState> {
static get propTypes() { constructor(props: IProps) {
return { super(props);
userId: PropTypes.string, this.state = {
loading: true,
}; };
} }
constructor(props) { public componentDidMount(): void {
super(props);
this.state = {};
}
componentDidMount() {
if (this.props.userId) { if (this.props.userId) {
this._loadProfileInfo(); this.loadProfileInfo();
} }
} }
componentDidUpdate(prevProps) { public componentDidUpdate(prevProps: IProps): void {
// XXX: We shouldn't need to null check the userId here, but we declare // XXX: We shouldn't need to null check the userId here, but we declare
// it as optional and MatrixChat sometimes fires in a way which results // it as optional and MatrixChat sometimes fires in a way which results
// in an NPE when we try to update the profile info. // in an NPE when we try to update the profile info.
if (prevProps.userId !== this.props.userId && this.props.userId) { if (prevProps.userId !== this.props.userId && this.props.userId) {
this._loadProfileInfo(); this.loadProfileInfo();
} }
} }
async _loadProfileInfo() { private async loadProfileInfo(): Promise<void> {
const cli = MatrixClientPeg.get(); const cli = MatrixClientPeg.get();
this.setState({ loading: true }); this.setState({ loading: true });
let profileInfo; let profileInfo;
try { try {
profileInfo = await cli.getProfileInfo(this.props.userId); profileInfo = await cli.getProfileInfo(this.props.userId);
} catch (err) { } catch (err) {
const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog');
Modal.createTrackedDialog(_t('Could not load user profile'), '', ErrorDialog, { Modal.createTrackedDialog(_t('Could not load user profile'), '', ErrorDialog, {
title: _t('Could not load user profile'), title: _t('Could not load user profile'),
description: ((err && err.message) ? err.message : _t("Operation failed")), description: ((err && err.message) ? err.message : _t("Operation failed")),
@ -75,14 +83,11 @@ export default class UserView extends React.Component {
this.setState({ member, loading: false }); this.setState({ member, loading: false });
} }
render() { public render(): JSX.Element {
if (this.state.loading) { if (this.state.loading) {
const Spinner = sdk.getComponent("elements.Spinner");
return <Spinner />; return <Spinner />;
} else if (this.state.member) { } else if (this.state.member?.user) {
const RightPanel = sdk.getComponent('structures.RightPanel'); const panel = <RightPanel user={this.state.member.user} resizeNotifier={this.props.resizeNotifier} />;
const MainSplit = sdk.getComponent('structures.MainSplit');
const panel = <RightPanel user={this.state.member} />;
return (<MainSplit panel={panel} resizeNotifier={this.props.resizeNotifier}> return (<MainSplit panel={panel} resizeNotifier={this.props.resizeNotifier}>
<HomePage /> <HomePage />
</MainSplit>); </MainSplit>);

View file

@ -17,24 +17,28 @@ limitations under the License.
*/ */
import React from "react"; import React from "react";
import PropTypes from "prop-types";
import SyntaxHighlight from "../views/elements/SyntaxHighlight"; import SyntaxHighlight from "../views/elements/SyntaxHighlight";
import { _t } from "../../languageHandler"; import { _t } from "../../languageHandler";
import * as sdk from "../../index";
import MatrixClientContext from "../../contexts/MatrixClientContext"; import MatrixClientContext from "../../contexts/MatrixClientContext";
import { SendCustomEvent } from "../views/dialogs/DevtoolsDialog"; import { SendCustomEvent } from "../views/dialogs/DevtoolsDialog";
import { canEditContent } from "../../utils/EventUtils"; import { canEditContent } from "../../utils/EventUtils";
import { MatrixClientPeg } from '../../MatrixClientPeg'; import { MatrixClientPeg } from '../../MatrixClientPeg';
import { replaceableComponent } from "../../utils/replaceableComponent"; import { replaceableComponent } from "../../utils/replaceableComponent";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { IDialogProps } from "../views/dialogs/IDialogProps";
import BaseDialog from "../views/dialogs/BaseDialog";
interface IProps extends IDialogProps {
mxEvent: MatrixEvent; // the MatrixEvent associated with the context menu
}
interface IState {
isEditing: boolean;
}
@replaceableComponent("structures.ViewSource") @replaceableComponent("structures.ViewSource")
export default class ViewSource extends React.Component { export default class ViewSource extends React.Component<IProps, IState> {
static propTypes = { constructor(props: IProps) {
onFinished: PropTypes.func.isRequired,
mxEvent: PropTypes.object.isRequired, // the MatrixEvent associated with the context menu
};
constructor(props) {
super(props); super(props);
this.state = { this.state = {
@ -42,19 +46,20 @@ export default class ViewSource extends React.Component {
}; };
} }
onBack() { private onBack(): void {
// TODO: refresh the "Event ID:" modal header // TODO: refresh the "Event ID:" modal header
this.setState({ isEditing: false }); this.setState({ isEditing: false });
} }
onEdit() { private onEdit(): void {
this.setState({ isEditing: true }); this.setState({ isEditing: true });
} }
// returns the dialog body for viewing the event source // returns the dialog body for viewing the event source
viewSourceContent() { private viewSourceContent(): JSX.Element {
const mxEvent = this.props.mxEvent.replacingEvent() || this.props.mxEvent; // show the replacing event, not the original, if it is an edit const mxEvent = this.props.mxEvent.replacingEvent() || this.props.mxEvent; // show the replacing event, not the original, if it is an edit
const isEncrypted = mxEvent.isEncrypted(); const isEncrypted = mxEvent.isEncrypted();
// @ts-ignore
const decryptedEventSource = mxEvent.clearEvent; // FIXME: clearEvent is private const decryptedEventSource = mxEvent.clearEvent; // FIXME: clearEvent is private
const originalEventSource = mxEvent.event; const originalEventSource = mxEvent.event;
@ -86,7 +91,7 @@ export default class ViewSource extends React.Component {
} }
// returns the id of the initial message, not the id of the previous edit // returns the id of the initial message, not the id of the previous edit
getBaseEventId() { private getBaseEventId(): string {
const mxEvent = this.props.mxEvent.replacingEvent() || this.props.mxEvent; // show the replacing event, not the original, if it is an edit const mxEvent = this.props.mxEvent.replacingEvent() || this.props.mxEvent; // show the replacing event, not the original, if it is an edit
const isEncrypted = mxEvent.isEncrypted(); const isEncrypted = mxEvent.isEncrypted();
const baseMxEvent = this.props.mxEvent; const baseMxEvent = this.props.mxEvent;
@ -100,7 +105,7 @@ export default class ViewSource extends React.Component {
} }
// returns the SendCustomEvent component prefilled with the correct details // returns the SendCustomEvent component prefilled with the correct details
editSourceContent() { private editSourceContent(): JSX.Element {
const mxEvent = this.props.mxEvent.replacingEvent() || this.props.mxEvent; // show the replacing event, not the original, if it is an edit const mxEvent = this.props.mxEvent.replacingEvent() || this.props.mxEvent; // show the replacing event, not the original, if it is an edit
const isStateEvent = mxEvent.isState(); const isStateEvent = mxEvent.isState();
@ -159,14 +164,13 @@ export default class ViewSource extends React.Component {
} }
} }
canSendStateEvent(mxEvent) { private canSendStateEvent(mxEvent: MatrixEvent): boolean {
const cli = MatrixClientPeg.get(); const cli = MatrixClientPeg.get();
const room = cli.getRoom(mxEvent.getRoomId()); const room = cli.getRoom(mxEvent.getRoomId());
return room.currentState.mayClientSendStateEvent(mxEvent.getType(), cli); return room.currentState.mayClientSendStateEvent(mxEvent.getType(), cli);
} }
render() { public render(): JSX.Element {
const BaseDialog = sdk.getComponent("views.dialogs.BaseDialog");
const mxEvent = this.props.mxEvent.replacingEvent() || this.props.mxEvent; // show the replacing event, not the original, if it is an edit const mxEvent = this.props.mxEvent.replacingEvent() || this.props.mxEvent; // show the replacing event, not the original, if it is an edit
const isEditing = this.state.isEditing; const isEditing = this.state.isEditing;

View file

@ -15,43 +15,48 @@ limitations under the License.
*/ */
import React, { createRef } from 'react'; import React, { createRef } from 'react';
import PropTypes from 'prop-types';
import { MatrixClientPeg } from '../../../MatrixClientPeg'; import { MatrixClientPeg } from '../../../MatrixClientPeg';
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import MemberAvatar from '../avatars/MemberAvatar'; import MemberAvatar from '../avatars/MemberAvatar';
import classNames from 'classnames'; import classNames from 'classnames';
import StatusMessageContextMenu from "../context_menus/StatusMessageContextMenu"; import StatusMessageContextMenu from "../context_menus/StatusMessageContextMenu";
import SettingsStore from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
import { ContextMenu, ContextMenuButton } from "../../structures/ContextMenu"; import { ChevronFace, ContextMenu, ContextMenuButton } from "../../structures/ContextMenu";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { ResizeMethod } from "matrix-js-sdk/src/@types/partials";
interface IProps {
member: RoomMember;
width?: number;
height?: number;
resizeMethod?: ResizeMethod;
}
interface IState {
hasStatus: boolean;
menuDisplayed: boolean;
}
@replaceableComponent("views.avatars.MemberStatusMessageAvatar") @replaceableComponent("views.avatars.MemberStatusMessageAvatar")
export default class MemberStatusMessageAvatar extends React.Component { export default class MemberStatusMessageAvatar extends React.Component<IProps, IState> {
static propTypes = { public static defaultProps: Partial<IProps> = {
member: PropTypes.object.isRequired,
width: PropTypes.number,
height: PropTypes.number,
resizeMethod: PropTypes.string,
};
static defaultProps = {
width: 40, width: 40,
height: 40, height: 40,
resizeMethod: 'crop', resizeMethod: 'crop',
}; };
private button = createRef<HTMLDivElement>();
constructor(props) { constructor(props: IProps) {
super(props); super(props);
this.state = { this.state = {
hasStatus: this.hasStatus, hasStatus: this.hasStatus,
menuDisplayed: false, menuDisplayed: false,
}; };
this._button = createRef();
} }
componentDidMount() { public componentDidMount(): void {
if (this.props.member.userId !== MatrixClientPeg.get().getUserId()) { if (this.props.member.userId !== MatrixClientPeg.get().getUserId()) {
throw new Error("Cannot use MemberStatusMessageAvatar on anyone but the logged in user"); throw new Error("Cannot use MemberStatusMessageAvatar on anyone but the logged in user");
} }
@ -62,44 +67,44 @@ export default class MemberStatusMessageAvatar extends React.Component {
if (!user) { if (!user) {
return; return;
} }
user.on("User._unstable_statusMessage", this._onStatusMessageCommitted); user.on("User._unstable_statusMessage", this.onStatusMessageCommitted);
} }
componentWillUnmount() { public componentWillUnmount(): void {
const { user } = this.props.member; const { user } = this.props.member;
if (!user) { if (!user) {
return; return;
} }
user.removeListener( user.removeListener(
"User._unstable_statusMessage", "User._unstable_statusMessage",
this._onStatusMessageCommitted, this.onStatusMessageCommitted,
); );
} }
get hasStatus() { private get hasStatus(): boolean {
const { user } = this.props.member; const { user } = this.props.member;
if (!user) { if (!user) {
return false; return false;
} }
return !!user._unstable_statusMessage; return !!user.unstable_statusMessage;
} }
_onStatusMessageCommitted = () => { private onStatusMessageCommitted = (): void => {
// The `User` object has observed a status message change. // The `User` object has observed a status message change.
this.setState({ this.setState({
hasStatus: this.hasStatus, hasStatus: this.hasStatus,
}); });
}; };
openMenu = () => { private openMenu = (): void => {
this.setState({ menuDisplayed: true }); this.setState({ menuDisplayed: true });
}; };
closeMenu = () => { private closeMenu = (): void => {
this.setState({ menuDisplayed: false }); this.setState({ menuDisplayed: false });
}; };
render() { public render(): JSX.Element {
const avatar = <MemberAvatar const avatar = <MemberAvatar
member={this.props.member} member={this.props.member}
width={this.props.width} width={this.props.width}
@ -118,7 +123,7 @@ export default class MemberStatusMessageAvatar extends React.Component {
let contextMenu; let contextMenu;
if (this.state.menuDisplayed) { if (this.state.menuDisplayed) {
const elementRect = this._button.current.getBoundingClientRect(); const elementRect = this.button.current.getBoundingClientRect();
const chevronWidth = 16; // See .mx_ContextualMenu_chevron_bottom const chevronWidth = 16; // See .mx_ContextualMenu_chevron_bottom
const chevronMargin = 1; // Add some spacing away from target const chevronMargin = 1; // Add some spacing away from target
@ -126,13 +131,13 @@ export default class MemberStatusMessageAvatar extends React.Component {
contextMenu = ( contextMenu = (
<ContextMenu <ContextMenu
chevronOffset={(elementRect.width - chevronWidth) / 2} chevronOffset={(elementRect.width - chevronWidth) / 2}
chevronFace="bottom" chevronFace={ChevronFace.Bottom}
left={elementRect.left + window.pageXOffset} left={elementRect.left + window.pageXOffset}
top={elementRect.top + window.pageYOffset - chevronMargin} top={elementRect.top + window.pageYOffset - chevronMargin}
menuWidth={226} menuWidth={226}
onFinished={this.closeMenu} onFinished={this.closeMenu}
> >
<StatusMessageContextMenu user={this.props.member.user} onFinished={this.closeMenu} /> <StatusMessageContextMenu user={this.props.member.user} />
</ContextMenu> </ContextMenu>
); );
} }
@ -140,7 +145,7 @@ export default class MemberStatusMessageAvatar extends React.Component {
return <React.Fragment> return <React.Fragment>
<ContextMenuButton <ContextMenuButton
className={classes} className={classes}
inputRef={this._button} inputRef={this.button}
onClick={this.openMenu} onClick={this.openMenu}
isExpanded={this.state.menuDisplayed} isExpanded={this.state.menuDisplayed}
label={_t("User Status")} label={_t("User Status")}

View file

@ -15,45 +15,41 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
/* interface IProps {
* This component can be used to display generic HTML content in a contextual element: React.ReactNode;
* menu.
*/
@replaceableComponent("views.context_menus.GenericElementContextMenu")
export default class GenericElementContextMenu extends React.Component {
static propTypes = {
element: PropTypes.element.isRequired,
// Function to be called when the parent window is resized // Function to be called when the parent window is resized
// This can be used to reposition or close the menu on resize and // This can be used to reposition or close the menu on resize and
// ensure that it is not displayed in a stale position. // ensure that it is not displayed in a stale position.
onResize: PropTypes.func, onResize?: () => void;
};
constructor(props) {
super(props);
this.resize = this.resize.bind(this);
} }
componentDidMount() { /**
this.resize = this.resize.bind(this); * This component can be used to display generic HTML content in a contextual
* menu.
*/
@replaceableComponent("views.context_menus.GenericElementContextMenu")
export default class GenericElementContextMenu extends React.Component<IProps> {
constructor(props: IProps) {
super(props);
}
public componentDidMount(): void {
window.addEventListener("resize", this.resize); window.addEventListener("resize", this.resize);
} }
componentWillUnmount() { public componentWillUnmount(): void {
window.removeEventListener("resize", this.resize); window.removeEventListener("resize", this.resize);
} }
resize() { private resize = (): void => {
if (this.props.onResize) { if (this.props.onResize) {
this.props.onResize(); this.props.onResize();
} }
} };
render() { public render(): JSX.Element {
return <div>{ this.props.element }</div>; return <div>{ this.props.element }</div>;
} }
} }

View file

@ -15,16 +15,15 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
@replaceableComponent("views.context_menus.GenericTextContextMenu") interface IProps {
export default class GenericTextContextMenu extends React.Component { message: string;
static propTypes = { }
message: PropTypes.string.isRequired,
};
render() { @replaceableComponent("views.context_menus.GenericTextContextMenu")
export default class GenericTextContextMenu extends React.Component<IProps> {
public render(): JSX.Element {
return <div>{ this.props.message }</div>; return <div>{ this.props.message }</div>;
} }
} }

View file

@ -14,53 +14,59 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React from 'react'; import React, { ChangeEvent } from 'react';
import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import { MatrixClientPeg } from '../../../MatrixClientPeg'; import { MatrixClientPeg } from '../../../MatrixClientPeg';
import * as sdk from '../../../index'; import AccessibleButton, { ButtonEvent } from '../elements/AccessibleButton';
import AccessibleButton from '../elements/AccessibleButton';
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import { User } from "matrix-js-sdk/src/models/user";
import Spinner from "../elements/Spinner";
interface IProps {
// js-sdk User object. Not required because it might not exist.
user?: User;
}
interface IState {
message: string;
waiting: boolean;
}
@replaceableComponent("views.context_menus.StatusMessageContextMenu") @replaceableComponent("views.context_menus.StatusMessageContextMenu")
export default class StatusMessageContextMenu extends React.Component { export default class StatusMessageContextMenu extends React.Component<IProps, IState> {
static propTypes = { constructor(props: IProps) {
// js-sdk User object. Not required because it might not exist.
user: PropTypes.object,
};
constructor(props) {
super(props); super(props);
this.state = { this.state = {
message: this.comittedStatusMessage, message: this.comittedStatusMessage,
waiting: false,
}; };
} }
componentDidMount() { public componentDidMount(): void {
const { user } = this.props; const { user } = this.props;
if (!user) { if (!user) {
return; return;
} }
user.on("User._unstable_statusMessage", this._onStatusMessageCommitted); user.on("User._unstable_statusMessage", this.onStatusMessageCommitted);
} }
componentWillUnmount() { public componentWillUnmount(): void {
const { user } = this.props; const { user } = this.props;
if (!user) { if (!user) {
return; return;
} }
user.removeListener( user.removeListener(
"User._unstable_statusMessage", "User._unstable_statusMessage",
this._onStatusMessageCommitted, this.onStatusMessageCommitted,
); );
} }
get comittedStatusMessage() { get comittedStatusMessage(): string {
return this.props.user ? this.props.user._unstable_statusMessage : ""; return this.props.user ? this.props.user.unstable_statusMessage : "";
} }
_onStatusMessageCommitted = () => { private onStatusMessageCommitted = (): void => {
// The `User` object has observed a status message change. // The `User` object has observed a status message change.
this.setState({ this.setState({
message: this.comittedStatusMessage, message: this.comittedStatusMessage,
@ -68,14 +74,14 @@ export default class StatusMessageContextMenu extends React.Component {
}); });
}; };
_onClearClick = (e) => { private onClearClick = (): void=> {
MatrixClientPeg.get()._unstable_setStatusMessage(""); MatrixClientPeg.get()._unstable_setStatusMessage("");
this.setState({ this.setState({
waiting: true, waiting: true,
}); });
}; };
_onSubmit = (e) => { private onSubmit = (e: ButtonEvent): void => {
e.preventDefault(); e.preventDefault();
MatrixClientPeg.get()._unstable_setStatusMessage(this.state.message); MatrixClientPeg.get()._unstable_setStatusMessage(this.state.message);
this.setState({ this.setState({
@ -83,27 +89,25 @@ export default class StatusMessageContextMenu extends React.Component {
}); });
}; };
_onStatusChange = (e) => { private onStatusChange = (e: ChangeEvent): void => {
// The input field's value was changed. // The input field's value was changed.
this.setState({ this.setState({
message: e.target.value, message: (e.target as HTMLInputElement).value,
}); });
}; };
render() { public render(): JSX.Element {
const Spinner = sdk.getComponent('views.elements.Spinner');
let actionButton; let actionButton;
if (this.comittedStatusMessage) { if (this.comittedStatusMessage) {
if (this.state.message === this.comittedStatusMessage) { if (this.state.message === this.comittedStatusMessage) {
actionButton = <AccessibleButton className="mx_StatusMessageContextMenu_clear" actionButton = <AccessibleButton className="mx_StatusMessageContextMenu_clear"
onClick={this._onClearClick} onClick={this.onClearClick}
> >
<span>{ _t("Clear status") }</span> <span>{ _t("Clear status") }</span>
</AccessibleButton>; </AccessibleButton>;
} else { } else {
actionButton = <AccessibleButton className="mx_StatusMessageContextMenu_submit" actionButton = <AccessibleButton className="mx_StatusMessageContextMenu_submit"
onClick={this._onSubmit} onClick={this.onSubmit}
> >
<span>{ _t("Update status") }</span> <span>{ _t("Update status") }</span>
</AccessibleButton>; </AccessibleButton>;
@ -112,7 +116,7 @@ export default class StatusMessageContextMenu extends React.Component {
actionButton = <AccessibleButton actionButton = <AccessibleButton
className="mx_StatusMessageContextMenu_submit" className="mx_StatusMessageContextMenu_submit"
disabled={!this.state.message} disabled={!this.state.message}
onClick={this._onSubmit} onClick={this.onSubmit}
> >
<span>{ _t("Set status") }</span> <span>{ _t("Set status") }</span>
</AccessibleButton>; </AccessibleButton>;
@ -120,13 +124,13 @@ export default class StatusMessageContextMenu extends React.Component {
let spinner = null; let spinner = null;
if (this.state.waiting) { if (this.state.waiting) {
spinner = <Spinner w="24" h="24" />; spinner = <Spinner w={24} h={24} />;
} }
const form = <form const form = <form
className="mx_StatusMessageContextMenu_form" className="mx_StatusMessageContextMenu_form"
autoComplete="off" autoComplete="off"
onSubmit={this._onSubmit} onSubmit={this.onSubmit}
> >
<input <input
type="text" type="text"
@ -134,9 +138,9 @@ export default class StatusMessageContextMenu extends React.Component {
key="message" key="message"
placeholder={_t("Set a new status...")} placeholder={_t("Set a new status...")}
autoFocus={true} autoFocus={true}
maxLength="60" maxLength={60}
value={this.state.message} value={this.state.message}
onChange={this._onStatusChange} onChange={this.onStatusChange}
/> />
<div className="mx_StatusMessageContextMenu_actionContainer"> <div className="mx_StatusMessageContextMenu_actionContainer">
{ actionButton } { actionButton }

View file

@ -258,7 +258,6 @@ export const AddExistingToSpace: React.FC<IAddExistingToSpaceProps> = ({
className="mx_textinput_icon mx_textinput_search" className="mx_textinput_icon mx_textinput_search"
placeholder={filterPlaceholder} placeholder={filterPlaceholder}
onSearch={setQuery} onSearch={setQuery}
autoComplete={true}
autoFocus={true} autoFocus={true}
/> />
<AutoHideScrollbar className="mx_AddExistingToSpace_content"> <AutoHideScrollbar className="mx_AddExistingToSpace_content">

View file

@ -23,10 +23,9 @@ import Modal from '../../../Modal';
import BaseDialog from "./BaseDialog"; import BaseDialog from "./BaseDialog";
import DialogButtons from "../elements/DialogButtons"; import DialogButtons from "../elements/DialogButtons";
import QuestionDialog from "./QuestionDialog"; import QuestionDialog from "./QuestionDialog";
import { IDialogProps } from "./IDialogProps";
interface IProps { interface IProps extends IDialogProps {}
onFinished: (success: boolean) => void;
}
const CryptoStoreTooNewDialog: React.FC<IProps> = (props: IProps) => { const CryptoStoreTooNewDialog: React.FC<IProps> = (props: IProps) => {
const brand = SdkConfig.get().brand; const brand = SdkConfig.get().brand;

View file

@ -243,7 +243,6 @@ const ForwardDialog: React.FC<IProps> = ({ matrixClient: cli, event, permalinkCr
className="mx_textinput_icon mx_textinput_search" className="mx_textinput_icon mx_textinput_search"
placeholder={_t("Search for rooms or people")} placeholder={_t("Search for rooms or people")}
onSearch={setQuery} onSearch={setQuery}
autoComplete={true}
autoFocus={true} autoFocus={true}
/> />
<AutoHideScrollbar className="mx_ForwardList_content"> <AutoHideScrollbar className="mx_ForwardList_content">

View file

@ -57,7 +57,6 @@ const SpaceChildPicker = ({ filterPlaceholder, rooms, selected, onChange }) => {
className="mx_textinput_icon mx_textinput_search" className="mx_textinput_icon mx_textinput_search"
placeholder={filterPlaceholder} placeholder={filterPlaceholder}
onSearch={setQuery} onSearch={setQuery}
autoComplete={true}
autoFocus={true} autoFocus={true}
/> />
<AutoHideScrollbar className="mx_LeaveSpaceDialog_content"> <AutoHideScrollbar className="mx_LeaveSpaceDialog_content">
@ -98,13 +97,13 @@ const LeaveRoomsPicker = ({ space, spaceChildren, roomsToLeave, setRoomsToLeave
definitions={[ definitions={[
{ {
value: RoomsToLeave.None, value: RoomsToLeave.None,
label: _t("Don't leave any"), label: _t("Don't leave any rooms"),
}, { }, {
value: RoomsToLeave.All, value: RoomsToLeave.All,
label: _t("Leave all rooms and spaces"), label: _t("Leave all rooms"),
}, { }, {
value: RoomsToLeave.Specific, value: RoomsToLeave.Specific,
label: _t("Leave specific rooms and spaces"), label: _t("Leave some rooms"),
}, },
]} ]}
/> />
@ -167,11 +166,13 @@ const LeaveSpaceDialog: React.FC<IProps> = ({ space, onFinished }) => {
> >
<div className="mx_Dialog_content" id="mx_LeaveSpaceDialog"> <div className="mx_Dialog_content" id="mx_LeaveSpaceDialog">
<p> <p>
{ _t("Are you sure you want to leave <spaceName/>?", {}, { { _t("You are about to leave <spaceName/>.", {}, {
spaceName: () => <b>{ space.name }</b>, spaceName: () => <b>{ space.name }</b>,
}) } }) }
&nbsp; &nbsp;
{ rejoinWarning } { rejoinWarning }
{ rejoinWarning && (<>&nbsp;</>) }
{ spaceChildren.length > 0 && _t("Would you like to leave the rooms in this space?") }
</p> </p>
{ spaceChildren.length > 0 && <LeaveRoomsPicker { spaceChildren.length > 0 && <LeaveRoomsPicker

View file

@ -126,7 +126,6 @@ const ManageRestrictedJoinRuleDialog: React.FC<IProps> = ({ room, selected = [],
className="mx_textinput_icon mx_textinput_search" className="mx_textinput_icon mx_textinput_search"
placeholder={_t("Search spaces")} placeholder={_t("Search spaces")}
onSearch={setQuery} onSearch={setQuery}
autoComplete={true}
autoFocus={true} autoFocus={true}
/> />
<AutoHideScrollbar className="mx_ManageRestrictedJoinRuleDialog_content"> <AutoHideScrollbar className="mx_ManageRestrictedJoinRuleDialog_content">

View file

@ -19,7 +19,7 @@ import { User } from "matrix-js-sdk/src/models/user";
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { MatrixClientPeg } from "../../../MatrixClientPeg";
import E2EIcon from "../rooms/E2EIcon"; import E2EIcon, { E2EState } from "../rooms/E2EIcon";
import AccessibleButton from "../elements/AccessibleButton"; import AccessibleButton from "../elements/AccessibleButton";
import BaseDialog from "./BaseDialog"; import BaseDialog from "./BaseDialog";
import { IDialogProps } from "./IDialogProps"; import { IDialogProps } from "./IDialogProps";
@ -47,7 +47,7 @@ const UntrustedDeviceDialog: React.FC<IProps> = ({ device, user, onFinished }) =
onFinished={onFinished} onFinished={onFinished}
className="mx_UntrustedDeviceDialog" className="mx_UntrustedDeviceDialog"
title={<> title={<>
<E2EIcon status="warning" size={24} hideTooltip={true} /> <E2EIcon status={E2EState.Warning} size={24} hideTooltip={true} />
{ _t("Not Trusted") } { _t("Not Trusted") }
</>} </>}
> >

View file

@ -19,7 +19,6 @@ limitations under the License.
import url from 'url'; import url from 'url';
import React, { createRef } from 'react'; import React, { createRef } from 'react';
import PropTypes from 'prop-types';
import { MatrixClientPeg } from '../../../MatrixClientPeg'; import { MatrixClientPeg } from '../../../MatrixClientPeg';
import AccessibleButton from './AccessibleButton'; import AccessibleButton from './AccessibleButton';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
@ -39,33 +38,95 @@ import { MatrixCapabilities } from "matrix-widget-api";
import RoomWidgetContextMenu from "../context_menus/WidgetContextMenu"; import RoomWidgetContextMenu from "../context_menus/WidgetContextMenu";
import WidgetAvatar from "../avatars/WidgetAvatar"; import WidgetAvatar from "../avatars/WidgetAvatar";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import { Room } from "matrix-js-sdk/src/models/room";
import { IApp } from "../../../stores/WidgetStore";
interface IProps {
app: IApp;
// If room is not specified then it is an account level widget
// which bypasses permission prompts as it was added explicitly by that user
room: Room;
// Specifying 'fullWidth' as true will render the app tile to fill the width of the app drawer continer.
// This should be set to true when there is only one widget in the app drawer, otherwise it should be false.
fullWidth?: boolean;
// Optional. If set, renders a smaller view of the widget
miniMode?: boolean;
// UserId of the current user
userId: string;
// UserId of the entity that added / modified the widget
creatorUserId: string;
waitForIframeLoad: boolean;
showMenubar?: boolean;
// Optional onEditClickHandler (overrides default behaviour)
onEditClick?: () => void;
// Optional onDeleteClickHandler (overrides default behaviour)
onDeleteClick?: () => void;
// Optionally hide the tile title
showTitle?: boolean;
// Optionally handle minimise button pointer events (default false)
handleMinimisePointerEvents?: boolean;
// Optionally hide the popout widget icon
showPopout?: boolean;
// Is this an instance of a user widget
userWidget: boolean;
// sets the pointer-events property on the iframe
pointerEvents?: string;
widgetPageTitle?: string;
}
interface IState {
initialising: boolean; // True while we are mangling the widget URL
// True while the iframe content is loading
loading: boolean;
// Assume that widget has permission to load if we are the user who
// added it to the room, or if explicitly granted by the user
hasPermissionToLoad: boolean;
error: Error;
menuDisplayed: boolean;
widgetPageTitle: string;
}
@replaceableComponent("views.elements.AppTile") @replaceableComponent("views.elements.AppTile")
export default class AppTile extends React.Component { export default class AppTile extends React.Component<IProps, IState> {
constructor(props) { public static defaultProps: Partial<IProps> = {
waitForIframeLoad: true,
showMenubar: true,
showTitle: true,
showPopout: true,
handleMinimisePointerEvents: false,
userWidget: false,
miniMode: false,
};
private contextMenuButton = createRef<any>();
private iframe: HTMLIFrameElement; // ref to the iframe (callback style)
private allowedWidgetsWatchRef: string;
private persistKey: string;
private sgWidget: StopGapWidget;
private dispatcherRef: string;
constructor(props: IProps) {
super(props); super(props);
// The key used for PersistedElement // The key used for PersistedElement
this._persistKey = getPersistKey(this.props.app.id); this.persistKey = getPersistKey(this.props.app.id);
try { try {
this._sgWidget = new StopGapWidget(this.props); this.sgWidget = new StopGapWidget(this.props);
this._sgWidget.on("preparing", this._onWidgetPrepared); this.sgWidget.on("preparing", this.onWidgetPrepared);
this._sgWidget.on("ready", this._onWidgetReady); this.sgWidget.on("ready", this.onWidgetReady);
} catch (e) { } catch (e) {
console.log("Failed to construct widget", e); console.log("Failed to construct widget", e);
this._sgWidget = null; this.sgWidget = null;
} }
this.iframe = null; // ref to the iframe (callback style)
this.state = this._getNewState(props); this.state = this.getNewState(props);
this._contextMenuButton = createRef();
this._allowedWidgetsWatchRef = SettingsStore.watchSetting("allowedWidgets", null, this.onAllowedWidgetsChange); this.allowedWidgetsWatchRef = SettingsStore.watchSetting("allowedWidgets", null, this.onAllowedWidgetsChange);
} }
// This is a function to make the impact of calling SettingsStore slightly less // This is a function to make the impact of calling SettingsStore slightly less
hasPermissionToLoad = (props) => { private hasPermissionToLoad = (props: IProps): boolean => {
if (this._usingLocalWidget()) return true; if (this.usingLocalWidget()) return true;
if (!props.room) return true; // user widgets always have permissions if (!props.room) return true; // user widgets always have permissions
const currentlyAllowedWidgets = SettingsStore.getValue("allowedWidgets", props.room.roomId); const currentlyAllowedWidgets = SettingsStore.getValue("allowedWidgets", props.room.roomId);
@ -81,34 +142,34 @@ export default class AppTile extends React.Component {
* @param {Object} newProps The new properties of the component * @param {Object} newProps The new properties of the component
* @return {Object} Updated component state to be set with setState * @return {Object} Updated component state to be set with setState
*/ */
_getNewState(newProps) { private getNewState(newProps: IProps): IState {
return { return {
initialising: true, // True while we are mangling the widget URL initialising: true, // True while we are mangling the widget URL
// True while the iframe content is loading // True while the iframe content is loading
loading: this.props.waitForIframeLoad && !PersistedElement.isMounted(this._persistKey), loading: this.props.waitForIframeLoad && !PersistedElement.isMounted(this.persistKey),
// Assume that widget has permission to load if we are the user who // Assume that widget has permission to load if we are the user who
// added it to the room, or if explicitly granted by the user // added it to the room, or if explicitly granted by the user
hasPermissionToLoad: this.hasPermissionToLoad(newProps), hasPermissionToLoad: this.hasPermissionToLoad(newProps),
error: null, error: null,
widgetPageTitle: newProps.widgetPageTitle,
menuDisplayed: false, menuDisplayed: false,
widgetPageTitle: this.props.widgetPageTitle,
}; };
} }
onAllowedWidgetsChange = () => { private onAllowedWidgetsChange = (): void => {
const hasPermissionToLoad = this.hasPermissionToLoad(this.props); const hasPermissionToLoad = this.hasPermissionToLoad(this.props);
if (this.state.hasPermissionToLoad && !hasPermissionToLoad) { if (this.state.hasPermissionToLoad && !hasPermissionToLoad) {
// Force the widget to be non-persistent (able to be deleted/forgotten) // Force the widget to be non-persistent (able to be deleted/forgotten)
ActiveWidgetStore.destroyPersistentWidget(this.props.app.id); ActiveWidgetStore.destroyPersistentWidget(this.props.app.id);
PersistedElement.destroyElement(this._persistKey); PersistedElement.destroyElement(this.persistKey);
if (this._sgWidget) this._sgWidget.stop(); if (this.sgWidget) this.sgWidget.stop();
} }
this.setState({ hasPermissionToLoad }); this.setState({ hasPermissionToLoad });
}; };
isMixedContent() { private isMixedContent(): boolean {
const parentContentProtocol = window.location.protocol; const parentContentProtocol = window.location.protocol;
const u = url.parse(this.props.app.url); const u = url.parse(this.props.app.url);
const childContentProtocol = u.protocol; const childContentProtocol = u.protocol;
@ -120,69 +181,70 @@ export default class AppTile extends React.Component {
return false; return false;
} }
componentDidMount() { public componentDidMount(): void {
// Only fetch IM token on mount if we're showing and have permission to load // Only fetch IM token on mount if we're showing and have permission to load
if (this._sgWidget && this.state.hasPermissionToLoad) { if (this.sgWidget && this.state.hasPermissionToLoad) {
this._startWidget(); this.startWidget();
} }
// Widget action listeners // Widget action listeners
this.dispatcherRef = dis.register(this._onAction); this.dispatcherRef = dis.register(this.onAction);
} }
componentWillUnmount() { public componentWillUnmount(): void {
// Widget action listeners // Widget action listeners
if (this.dispatcherRef) dis.unregister(this.dispatcherRef); if (this.dispatcherRef) dis.unregister(this.dispatcherRef);
// if it's not remaining on screen, get rid of the PersistedElement container // if it's not remaining on screen, get rid of the PersistedElement container
if (!ActiveWidgetStore.getWidgetPersistence(this.props.app.id)) { if (!ActiveWidgetStore.getWidgetPersistence(this.props.app.id)) {
ActiveWidgetStore.destroyPersistentWidget(this.props.app.id); ActiveWidgetStore.destroyPersistentWidget(this.props.app.id);
PersistedElement.destroyElement(this._persistKey); PersistedElement.destroyElement(this.persistKey);
} }
if (this._sgWidget) { if (this.sgWidget) {
this._sgWidget.stop(); this.sgWidget.stop();
} }
SettingsStore.unwatchSetting(this._allowedWidgetsWatchRef); SettingsStore.unwatchSetting(this.allowedWidgetsWatchRef);
} }
_resetWidget(newProps) { private resetWidget(newProps: IProps): void {
if (this._sgWidget) { if (this.sgWidget) {
this._sgWidget.stop(); this.sgWidget.stop();
} }
try { try {
this._sgWidget = new StopGapWidget(newProps); this.sgWidget = new StopGapWidget(newProps);
this._sgWidget.on("preparing", this._onWidgetPrepared); this.sgWidget.on("preparing", this.onWidgetPrepared);
this._sgWidget.on("ready", this._onWidgetReady); this.sgWidget.on("ready", this.onWidgetReady);
this._startWidget(); this.startWidget();
} catch (e) { } catch (e) {
console.log("Failed to construct widget", e); console.log("Failed to construct widget", e);
this._sgWidget = null; this.sgWidget = null;
} }
} }
_startWidget() { private startWidget(): void {
this._sgWidget.prepare().then(() => { this.sgWidget.prepare().then(() => {
this.setState({ initialising: false }); this.setState({ initialising: false });
}); });
} }
_iframeRefChange = (ref) => { private iframeRefChange = (ref: HTMLIFrameElement): void => {
this.iframe = ref; this.iframe = ref;
if (ref) { if (ref) {
if (this._sgWidget) this._sgWidget.start(ref); if (this.sgWidget) this.sgWidget.start(ref);
} else { } else {
this._resetWidget(this.props); this.resetWidget(this.props);
} }
}; };
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event // TODO: [REACT-WARNING] Replace with appropriate lifecycle event
UNSAFE_componentWillReceiveProps(nextProps) { // eslint-disable-line camelcase // eslint-disable-next-line @typescript-eslint/naming-convention
public UNSAFE_componentWillReceiveProps(nextProps: IProps): void { // eslint-disable-line camelcase
if (nextProps.app.url !== this.props.app.url) { if (nextProps.app.url !== this.props.app.url) {
this._getNewState(nextProps); this.getNewState(nextProps);
if (this.state.hasPermissionToLoad) { if (this.state.hasPermissionToLoad) {
this._resetWidget(nextProps); this.resetWidget(nextProps);
} }
} }
@ -198,7 +260,7 @@ export default class AppTile extends React.Component {
* @private * @private
* @returns {Promise<*>} Resolves when the widget is terminated, or timeout passed. * @returns {Promise<*>} Resolves when the widget is terminated, or timeout passed.
*/ */
async _endWidgetActions() { // widget migration dev note: async to maintain signature private async endWidgetActions(): Promise<void> { // widget migration dev note: async to maintain signature
// HACK: This is a really dirty way to ensure that Jitsi cleans up // HACK: This is a really dirty way to ensure that Jitsi cleans up
// its hold on the webcam. Without this, the widget holds a media // its hold on the webcam. Without this, the widget holds a media
// stream open, even after death. See https://github.com/vector-im/element-web/issues/7351 // stream open, even after death. See https://github.com/vector-im/element-web/issues/7351
@ -217,27 +279,27 @@ export default class AppTile extends React.Component {
} }
// Delete the widget from the persisted store for good measure. // Delete the widget from the persisted store for good measure.
PersistedElement.destroyElement(this._persistKey); PersistedElement.destroyElement(this.persistKey);
ActiveWidgetStore.destroyPersistentWidget(this.props.app.id); ActiveWidgetStore.destroyPersistentWidget(this.props.app.id);
if (this._sgWidget) this._sgWidget.stop({ forceDestroy: true }); if (this.sgWidget) this.sgWidget.stop({ forceDestroy: true });
} }
_onWidgetPrepared = () => { private onWidgetPrepared = (): void => {
this.setState({ loading: false }); this.setState({ loading: false });
}; };
_onWidgetReady = () => { private onWidgetReady = (): void => {
if (WidgetType.JITSI.matches(this.props.app.type)) { if (WidgetType.JITSI.matches(this.props.app.type)) {
this._sgWidget.widgetApi.transport.send(ElementWidgetActions.ClientReady, {}); this.sgWidget.widgetApi.transport.send(ElementWidgetActions.ClientReady, {});
} }
}; };
_onAction = payload => { private onAction = (payload): void => {
if (payload.widgetId === this.props.app.id) { if (payload.widgetId === this.props.app.id) {
switch (payload.action) { switch (payload.action) {
case 'm.sticker': case 'm.sticker':
if (this._sgWidget.widgetApi.hasCapability(MatrixCapabilities.StickerSending)) { if (this.sgWidget.widgetApi.hasCapability(MatrixCapabilities.StickerSending)) {
dis.dispatch({ action: 'post_sticker_message', data: payload.data }); dis.dispatch({ action: 'post_sticker_message', data: payload.data });
dis.dispatch({ action: 'stickerpicker_close' }); dis.dispatch({ action: 'stickerpicker_close' });
} else { } else {
@ -248,7 +310,7 @@ export default class AppTile extends React.Component {
} }
}; };
_grantWidgetPermission = () => { private grantWidgetPermission = (): void => {
const roomId = this.props.room.roomId; const roomId = this.props.room.roomId;
console.info("Granting permission for widget to load: " + this.props.app.eventId); console.info("Granting permission for widget to load: " + this.props.app.eventId);
const current = SettingsStore.getValue("allowedWidgets", roomId); const current = SettingsStore.getValue("allowedWidgets", roomId);
@ -258,14 +320,14 @@ export default class AppTile extends React.Component {
this.setState({ hasPermissionToLoad: true }); this.setState({ hasPermissionToLoad: true });
// Fetch a token for the integration manager, now that we're allowed to // Fetch a token for the integration manager, now that we're allowed to
this._startWidget(); this.startWidget();
}).catch(err => { }).catch(err => {
console.error(err); console.error(err);
// We don't really need to do anything about this - the user will just hit the button again. // We don't really need to do anything about this - the user will just hit the button again.
}); });
}; };
formatAppTileName() { private formatAppTileName(): string {
let appTileName = "No name"; let appTileName = "No name";
if (this.props.app.name && this.props.app.name.trim()) { if (this.props.app.name && this.props.app.name.trim()) {
appTileName = this.props.app.name.trim(); appTileName = this.props.app.name.trim();
@ -278,11 +340,11 @@ export default class AppTile extends React.Component {
* actual widget URL * actual widget URL
* @returns {bool} true If using a local version of the widget * @returns {bool} true If using a local version of the widget
*/ */
_usingLocalWidget() { private usingLocalWidget(): boolean {
return WidgetType.JITSI.matches(this.props.app.type); return WidgetType.JITSI.matches(this.props.app.type);
} }
_getTileTitle() { private getTileTitle(): JSX.Element {
const name = this.formatAppTileName(); const name = this.formatAppTileName();
const titleSpacer = <span>&nbsp;-&nbsp;</span>; const titleSpacer = <span>&nbsp;-&nbsp;</span>;
let title = ''; let title = '';
@ -300,32 +362,32 @@ export default class AppTile extends React.Component {
} }
// TODO replace with full screen interactions // TODO replace with full screen interactions
_onPopoutWidgetClick = () => { private onPopoutWidgetClick = (): void => {
// Ensure Jitsi conferences are closed on pop-out, to not confuse the user to join them // Ensure Jitsi conferences are closed on pop-out, to not confuse the user to join them
// twice from the same computer, which Jitsi can have problems with (audio echo/gain-loop). // twice from the same computer, which Jitsi can have problems with (audio echo/gain-loop).
if (WidgetType.JITSI.matches(this.props.app.type)) { if (WidgetType.JITSI.matches(this.props.app.type)) {
this._endWidgetActions().then(() => { this.endWidgetActions().then(() => {
if (this.iframe) { if (this.iframe) {
// Reload iframe // Reload iframe
this.iframe.src = this._sgWidget.embedUrl; this.iframe.src = this.sgWidget.embedUrl;
} }
}); });
} }
// Using Object.assign workaround as the following opens in a new window instead of a new tab. // Using Object.assign workaround as the following opens in a new window instead of a new tab.
// window.open(this._getPopoutUrl(), '_blank', 'noopener=yes'); // window.open(this._getPopoutUrl(), '_blank', 'noopener=yes');
Object.assign(document.createElement('a'), Object.assign(document.createElement('a'),
{ target: '_blank', href: this._sgWidget.popoutUrl, rel: 'noreferrer noopener' }).click(); { target: '_blank', href: this.sgWidget.popoutUrl, rel: 'noreferrer noopener' }).click();
}; };
_onContextMenuClick = () => { private onContextMenuClick = (): void => {
this.setState({ menuDisplayed: true }); this.setState({ menuDisplayed: true });
}; };
_closeContextMenu = () => { private closeContextMenu = (): void => {
this.setState({ menuDisplayed: false }); this.setState({ menuDisplayed: false });
}; };
render() { public render(): JSX.Element {
let appTileBody; let appTileBody;
// Note that there is advice saying allow-scripts shouldn't be used with allow-same-origin // Note that there is advice saying allow-scripts shouldn't be used with allow-same-origin
@ -351,7 +413,7 @@ export default class AppTile extends React.Component {
<Spinner message={_t("Loading...")} /> <Spinner message={_t("Loading...")} />
</div> </div>
); );
if (this._sgWidget === null) { if (this.sgWidget === null) {
appTileBody = ( appTileBody = (
<div className={appTileBodyClass} style={appTileBodyStyles}> <div className={appTileBodyClass} style={appTileBodyStyles}>
<AppWarning errorMsg={_t("Error loading Widget")} /> <AppWarning errorMsg={_t("Error loading Widget")} />
@ -365,9 +427,9 @@ export default class AppTile extends React.Component {
<AppPermission <AppPermission
roomId={this.props.room.roomId} roomId={this.props.room.roomId}
creatorUserId={this.props.creatorUserId} creatorUserId={this.props.creatorUserId}
url={this._sgWidget.embedUrl} url={this.sgWidget.embedUrl}
isRoomEncrypted={isEncrypted} isRoomEncrypted={isEncrypted}
onPermissionGranted={this._grantWidgetPermission} onPermissionGranted={this.grantWidgetPermission}
/> />
</div> </div>
); );
@ -390,8 +452,8 @@ export default class AppTile extends React.Component {
{ this.state.loading && loadingElement } { this.state.loading && loadingElement }
<iframe <iframe
allow={iframeFeatures} allow={iframeFeatures}
ref={this._iframeRefChange} ref={this.iframeRefChange}
src={this._sgWidget.embedUrl} src={this.sgWidget.embedUrl}
allowFullScreen={true} allowFullScreen={true}
sandbox={sandboxFlags} sandbox={sandboxFlags}
/> />
@ -407,7 +469,7 @@ export default class AppTile extends React.Component {
// Also wrap the PersistedElement in a div to fix the height, otherwise // Also wrap the PersistedElement in a div to fix the height, otherwise
// AppTile's border is in the wrong place // AppTile's border is in the wrong place
appTileBody = <div className="mx_AppTile_persistedWrapper"> appTileBody = <div className="mx_AppTile_persistedWrapper">
<PersistedElement persistKey={this._persistKey}> <PersistedElement persistKey={this.persistKey}>
{ appTileBody } { appTileBody }
</PersistedElement> </PersistedElement>
</div>; </div>;
@ -429,9 +491,9 @@ export default class AppTile extends React.Component {
if (this.state.menuDisplayed) { if (this.state.menuDisplayed) {
contextMenu = ( contextMenu = (
<RoomWidgetContextMenu <RoomWidgetContextMenu
{...aboveLeftOf(this._contextMenuButton.current.getBoundingClientRect(), null)} {...aboveLeftOf(this.contextMenuButton.current.getBoundingClientRect(), null)}
app={this.props.app} app={this.props.app}
onFinished={this._closeContextMenu} onFinished={this.closeContextMenu}
showUnpin={!this.props.userWidget} showUnpin={!this.props.userWidget}
userWidget={this.props.userWidget} userWidget={this.props.userWidget}
onEditClick={this.props.onEditClick} onEditClick={this.props.onEditClick}
@ -444,21 +506,21 @@ export default class AppTile extends React.Component {
<div className={appTileClasses} id={this.props.app.id}> <div className={appTileClasses} id={this.props.app.id}>
{ this.props.showMenubar && { this.props.showMenubar &&
<div className="mx_AppTileMenuBar"> <div className="mx_AppTileMenuBar">
<span className="mx_AppTileMenuBarTitle" style={{ pointerEvents: (this.props.handleMinimisePointerEvents ? 'all' : false) }}> <span className="mx_AppTileMenuBarTitle" style={{ pointerEvents: (this.props.handleMinimisePointerEvents ? 'all' : "none") }}>
{ this.props.showTitle && this._getTileTitle() } { this.props.showTitle && this.getTileTitle() }
</span> </span>
<span className="mx_AppTileMenuBarWidgets"> <span className="mx_AppTileMenuBarWidgets">
{ this.props.showPopout && <AccessibleButton { this.props.showPopout && <AccessibleButton
className="mx_AppTileMenuBar_iconButton mx_AppTileMenuBar_iconButton_popout" className="mx_AppTileMenuBar_iconButton mx_AppTileMenuBar_iconButton_popout"
title={_t('Popout widget')} title={_t('Popout widget')}
onClick={this._onPopoutWidgetClick} onClick={this.onPopoutWidgetClick}
/> } /> }
<ContextMenuButton <ContextMenuButton
className="mx_AppTileMenuBar_iconButton mx_AppTileMenuBar_iconButton_menu" className="mx_AppTileMenuBar_iconButton mx_AppTileMenuBar_iconButton_menu"
label={_t("Options")} label={_t("Options")}
isExpanded={this.state.menuDisplayed} isExpanded={this.state.menuDisplayed}
inputRef={this._contextMenuButton} inputRef={this.contextMenuButton}
onClick={this._onContextMenuClick} onClick={this.onContextMenuClick}
/> />
</span> </span>
</div> } </div> }
@ -469,49 +531,3 @@ export default class AppTile extends React.Component {
</React.Fragment>; </React.Fragment>;
} }
} }
AppTile.displayName = 'AppTile';
AppTile.propTypes = {
app: PropTypes.object.isRequired,
// If room is not specified then it is an account level widget
// which bypasses permission prompts as it was added explicitly by that user
room: PropTypes.object,
// Specifying 'fullWidth' as true will render the app tile to fill the width of the app drawer continer.
// This should be set to true when there is only one widget in the app drawer, otherwise it should be false.
fullWidth: PropTypes.bool,
// Optional. If set, renders a smaller view of the widget
miniMode: PropTypes.bool,
// UserId of the current user
userId: PropTypes.string.isRequired,
// UserId of the entity that added / modified the widget
creatorUserId: PropTypes.string,
waitForIframeLoad: PropTypes.bool,
showMenubar: PropTypes.bool,
// Optional onEditClickHandler (overrides default behaviour)
onEditClick: PropTypes.func,
// Optional onDeleteClickHandler (overrides default behaviour)
onDeleteClick: PropTypes.func,
// Optional onMinimiseClickHandler
onMinimiseClick: PropTypes.func,
// Optionally hide the tile title
showTitle: PropTypes.bool,
// Optionally handle minimise button pointer events (default false)
handleMinimisePointerEvents: PropTypes.bool,
// Optionally hide the popout widget icon
showPopout: PropTypes.bool,
// Is this an instance of a user widget
userWidget: PropTypes.bool,
// sets the pointer-events property on the iframe
pointerEvents: PropTypes.string,
};
AppTile.defaultProps = {
waitForIframeLoad: true,
showMenubar: true,
showTitle: true,
showPopout: true,
handleMinimisePointerEvents: false,
userWidget: false,
miniMode: false,
};

View file

@ -1,24 +1,20 @@
import React from 'react'; // eslint-disable-line no-unused-vars import React from 'react';
import PropTypes from 'prop-types';
const AppWarning = (props) => { interface IProps {
errorMsg?: string;
}
const AppWarning: React.FC<IProps> = (props) => {
return ( return (
<div className='mx_AppPermissionWarning'> <div className='mx_AppPermissionWarning'>
<div className='mx_AppPermissionWarningImage'> <div className='mx_AppPermissionWarningImage'>
<img src={require("../../../../res/img/warning.svg")} alt='' /> <img src={require("../../../../res/img/warning.svg")} alt='' />
</div> </div>
<div className='mx_AppPermissionWarningText'> <div className='mx_AppPermissionWarningText'>
<span className='mx_AppPermissionWarningTextLabel'>{ props.errorMsg }</span> <span className='mx_AppPermissionWarningTextLabel'>{ props.errorMsg || "Error" }</span>
</div> </div>
</div> </div>
); );
}; };
AppWarning.propTypes = {
errorMsg: PropTypes.string,
};
AppWarning.defaultProps = {
errorMsg: 'Error',
};
export default AppWarning; export default AppWarning;

View file

@ -17,60 +17,61 @@ limitations under the License.
*/ */
import React from "react"; import React from "react";
import PropTypes from "prop-types";
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
interface IProps {
// The primary button which is styled differently and has default focus.
primaryButton: React.ReactNode;
// A node to insert into the cancel button instead of default "Cancel"
cancelButton?: React.ReactNode;
// If true, make the primary button a form submit button (input type="submit")
primaryIsSubmit?: boolean;
// onClick handler for the primary button.
onPrimaryButtonClick?: (ev: React.MouseEvent) => void;
// should there be a cancel button? default: true
hasCancel?: boolean;
// The class of the cancel button, only used if a cancel button is
// enabled
cancelButtonClass?: string;
// onClick handler for the cancel button.
onCancel?: (...args: any[]) => void;
focus?: boolean;
// disables the primary and cancel buttons
disabled?: boolean;
// disables only the primary button
primaryDisabled?: boolean;
// something to stick next to the buttons, optionally
additive?: React.ReactNode;
primaryButtonClass?: string;
}
/** /**
* Basic container for buttons in modal dialogs. * Basic container for buttons in modal dialogs.
*/ */
@replaceableComponent("views.elements.DialogButtons") @replaceableComponent("views.elements.DialogButtons")
export default class DialogButtons extends React.Component { export default class DialogButtons extends React.Component<IProps> {
static propTypes = { public static defaultProps: Partial<IProps> = {
// The primary button which is styled differently and has default focus.
primaryButton: PropTypes.node.isRequired,
// A node to insert into the cancel button instead of default "Cancel"
cancelButton: PropTypes.node,
// If true, make the primary button a form submit button (input type="submit")
primaryIsSubmit: PropTypes.bool,
// onClick handler for the primary button.
onPrimaryButtonClick: PropTypes.func,
// should there be a cancel button? default: true
hasCancel: PropTypes.bool,
// The class of the cancel button, only used if a cancel button is
// enabled
cancelButtonClass: PropTypes.node,
// onClick handler for the cancel button.
onCancel: PropTypes.func,
focus: PropTypes.bool,
// disables the primary and cancel buttons
disabled: PropTypes.bool,
// disables only the primary button
primaryDisabled: PropTypes.bool,
// something to stick next to the buttons, optionally
additive: PropTypes.element,
};
static defaultProps = {
hasCancel: true, hasCancel: true,
disabled: false, disabled: false,
}; };
_onCancelClick = () => { private onCancelClick = (event: React.MouseEvent): void => {
this.props.onCancel(); this.props.onCancel(event);
}; };
render() { public render(): JSX.Element {
let primaryButtonClassName = "mx_Dialog_primary"; let primaryButtonClassName = "mx_Dialog_primary";
if (this.props.primaryButtonClass) { if (this.props.primaryButtonClass) {
primaryButtonClassName += " " + this.props.primaryButtonClass; primaryButtonClassName += " " + this.props.primaryButtonClass;
@ -82,7 +83,7 @@ export default class DialogButtons extends React.Component {
// important: the default type is 'submit' and this button comes before the // important: the default type is 'submit' and this button comes before the
// primary in the DOM so will get form submissions unless we make it not a submit. // primary in the DOM so will get form submissions unless we make it not a submit.
type="button" type="button"
onClick={this._onCancelClick} onClick={this.onCancelClick}
className={this.props.cancelButtonClass} className={this.props.cancelButtonClass}
disabled={this.props.disabled} disabled={this.props.disabled}
> >

View file

@ -14,71 +14,73 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React from 'react'; import React, { ChangeEvent, createRef } from 'react';
import PropTypes from 'prop-types';
import * as sdk from '../../../index';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import AccessibleButton from "./AccessibleButton";
interface IProps {
className?: string;
onChange?: (value: string) => void;
onClear?: () => void;
onJoinClick?: (value: string) => void;
placeholder?: string;
showJoinButton?: boolean;
initialText?: string;
}
interface IState {
value: string;
}
@replaceableComponent("views.elements.DirectorySearchBox") @replaceableComponent("views.elements.DirectorySearchBox")
export default class DirectorySearchBox extends React.Component { export default class DirectorySearchBox extends React.Component<IProps, IState> {
constructor(props) { private input = createRef<HTMLInputElement>();
super(props);
this._collectInput = this._collectInput.bind(this);
this._onClearClick = this._onClearClick.bind(this);
this._onChange = this._onChange.bind(this);
this._onKeyUp = this._onKeyUp.bind(this);
this._onJoinButtonClick = this._onJoinButtonClick.bind(this);
this.input = null; constructor(props: IProps) {
super(props);
this.state = { this.state = {
value: this.props.initialText || '', value: this.props.initialText || '',
}; };
} }
_collectInput(e) { private onClearClick = (): void => {
this.input = e;
}
_onClearClick() {
this.setState({ value: '' }); this.setState({ value: '' });
if (this.input) { if (this.input.current) {
this.input.focus(); this.input.current.focus();
if (this.props.onClear) { if (this.props.onClear) {
this.props.onClear(); this.props.onClear();
} }
} }
} };
_onChange(ev) { private onChange = (ev: ChangeEvent<HTMLInputElement>): void => {
if (!this.input) return; if (!this.input.current) return;
this.setState({ value: ev.target.value }); this.setState({ value: ev.target.value });
if (this.props.onChange) { if (this.props.onChange) {
this.props.onChange(ev.target.value); this.props.onChange(ev.target.value);
} }
} };
_onKeyUp(ev) { private onKeyUp = (ev: React.KeyboardEvent): void => {
if (ev.key == 'Enter' && this.props.showJoinButton) { if (ev.key == 'Enter' && this.props.showJoinButton) {
if (this.props.onJoinClick) { if (this.props.onJoinClick) {
this.props.onJoinClick(this.state.value); this.props.onJoinClick(this.state.value);
} }
} }
} };
_onJoinButtonClick() { private onJoinButtonClick = (): void => {
if (this.props.onJoinClick) { if (this.props.onJoinClick) {
this.props.onJoinClick(this.state.value); this.props.onJoinClick(this.state.value);
} }
} };
render() {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
public render(): JSX.Element {
const searchboxClasses = { const searchboxClasses = {
mx_DirectorySearchBox: true, mx_DirectorySearchBox: true,
}; };
@ -87,7 +89,7 @@ export default class DirectorySearchBox extends React.Component {
let joinButton; let joinButton;
if (this.props.showJoinButton) { if (this.props.showJoinButton) {
joinButton = <AccessibleButton className="mx_DirectorySearchBox_joinButton" joinButton = <AccessibleButton className="mx_DirectorySearchBox_joinButton"
onClick={this._onJoinButtonClick} onClick={this.onJoinButtonClick}
>{ _t("Join") }</AccessibleButton>; >{ _t("Join") }</AccessibleButton>;
} }
@ -97,24 +99,15 @@ export default class DirectorySearchBox extends React.Component {
name="dirsearch" name="dirsearch"
value={this.state.value} value={this.state.value}
className="mx_textinput_icon mx_textinput_search" className="mx_textinput_icon mx_textinput_search"
ref={this._collectInput} ref={this.input}
onChange={this._onChange} onChange={this.onChange}
onKeyUp={this._onKeyUp} onKeyUp={this.onKeyUp}
placeholder={this.props.placeholder} placeholder={this.props.placeholder}
autoFocus autoFocus
/> />
{ joinButton } { joinButton }
<AccessibleButton className="mx_DirectorySearchBox_clear" onClick={this._onClearClick} /> <AccessibleButton className="mx_DirectorySearchBox_clear" onClick={this.onClearClick} />
</div>; </div>;
} }
} }
DirectorySearchBox.propTypes = {
className: PropTypes.string,
onChange: PropTypes.func,
onClear: PropTypes.func,
onJoinClick: PropTypes.func,
placeholder: PropTypes.string,
showJoinButton: PropTypes.bool,
initialText: PropTypes.string,
};

View file

@ -16,33 +16,42 @@ limitations under the License.
*/ */
import React, { createRef } from 'react'; import React, { createRef } from 'react';
import PropTypes from 'prop-types';
import { Key } from "../../../Keyboard"; import { Key } from "../../../Keyboard";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
@replaceableComponent("views.elements.EditableText") enum Phases {
export default class EditableText extends React.Component { Display = "display",
static propTypes = { Edit = "edit",
onValueChanged: PropTypes.func, }
initialValue: PropTypes.string,
label: PropTypes.string, interface IProps {
placeholder: PropTypes.string, onValueChanged?: (value: string, shouldSubmit: boolean) => void;
className: PropTypes.string, initialValue?: string;
labelClassName: PropTypes.string, label?: string;
placeholderClassName: PropTypes.string, placeholder?: string;
className?: string;
labelClassName?: string;
placeholderClassName?: string;
// Overrides blurToSubmit if true // Overrides blurToSubmit if true
blurToCancel: PropTypes.bool, blurToCancel?: boolean;
// Will cause onValueChanged(value, true) to fire on blur // Will cause onValueChanged(value, true) to fire on blur
blurToSubmit: PropTypes.bool, blurToSubmit?: boolean;
editable: PropTypes.bool, editable?: boolean;
}; }
static Phases = { interface IState {
Display: "display", phase: Phases;
Edit: "edit", }
};
static defaultProps = { @replaceableComponent("views.elements.EditableText")
export default class EditableText extends React.Component<IProps, IState> {
// we track value as an JS object field rather than in React state
// as React doesn't play nice with contentEditable.
public value = '';
private placeholder = false;
private editableDiv = createRef<HTMLDivElement>();
public static defaultProps: Partial<IProps> = {
onValueChanged() {}, onValueChanged() {},
initialValue: '', initialValue: '',
label: '', label: '',
@ -53,81 +62,61 @@ export default class EditableText extends React.Component {
blurToSubmit: false, blurToSubmit: false,
}; };
constructor(props) { constructor(props: IProps) {
super(props); super(props);
// we track value as an JS object field rather than in React state this.state = {
// as React doesn't play nice with contentEditable. phase: Phases.Display,
this.value = '';
this.placeholder = false;
this._editable_div = createRef();
}
state = {
phase: EditableText.Phases.Display,
}; };
}
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event // TODO: [REACT-WARNING] Replace with appropriate lifecycle event
// eslint-disable-next-line camelcase // eslint-disable-next-line @typescript-eslint/naming-convention, camelcase
UNSAFE_componentWillReceiveProps(nextProps) { public UNSAFE_componentWillReceiveProps(nextProps: IProps): void {
if (nextProps.initialValue !== this.props.initialValue) { if (nextProps.initialValue !== this.props.initialValue) {
this.value = nextProps.initialValue; this.value = nextProps.initialValue;
if (this._editable_div.current) { if (this.editableDiv.current) {
this.showPlaceholder(!this.value); this.showPlaceholder(!this.value);
} }
} }
} }
componentDidMount() { public componentDidMount(): void {
this.value = this.props.initialValue; this.value = this.props.initialValue;
if (this._editable_div.current) { if (this.editableDiv.current) {
this.showPlaceholder(!this.value); this.showPlaceholder(!this.value);
} }
} }
showPlaceholder = show => { private showPlaceholder = (show: boolean): void => {
if (show) { if (show) {
this._editable_div.current.textContent = this.props.placeholder; this.editableDiv.current.textContent = this.props.placeholder;
this._editable_div.current.setAttribute("class", this.props.className this.editableDiv.current.setAttribute("class", this.props.className
+ " " + this.props.placeholderClassName); + " " + this.props.placeholderClassName);
this.placeholder = true; this.placeholder = true;
this.value = ''; this.value = '';
} else { } else {
this._editable_div.current.textContent = this.value; this.editableDiv.current.textContent = this.value;
this._editable_div.current.setAttribute("class", this.props.className); this.editableDiv.current.setAttribute("class", this.props.className);
this.placeholder = false; this.placeholder = false;
} }
}; };
getValue = () => this.value; private cancelEdit = (): void => {
setValue = value => {
this.value = value;
this.showPlaceholder(!this.value);
};
edit = () => {
this.setState({ this.setState({
phase: EditableText.Phases.Edit, phase: Phases.Display,
});
};
cancelEdit = () => {
this.setState({
phase: EditableText.Phases.Display,
}); });
this.value = this.props.initialValue; this.value = this.props.initialValue;
this.showPlaceholder(!this.value); this.showPlaceholder(!this.value);
this.onValueChanged(false); this.onValueChanged(false);
this._editable_div.current.blur(); this.editableDiv.current.blur();
}; };
onValueChanged = shouldSubmit => { private onValueChanged = (shouldSubmit: boolean): void => {
this.props.onValueChanged(this.value, shouldSubmit); this.props.onValueChanged(this.value, shouldSubmit);
}; };
onKeyDown = ev => { private onKeyDown = (ev: React.KeyboardEvent<HTMLDivElement>): void => {
// console.log("keyDown: textContent=" + ev.target.textContent + ", value=" + this.value + ", placeholder=" + this.placeholder); // console.log("keyDown: textContent=" + ev.target.textContent + ", value=" + this.value + ", placeholder=" + this.placeholder);
if (this.placeholder) { if (this.placeholder) {
@ -142,13 +131,13 @@ export default class EditableText extends React.Component {
// console.log("keyDown: textContent=" + ev.target.textContent + ", value=" + this.value + ", placeholder=" + this.placeholder); // console.log("keyDown: textContent=" + ev.target.textContent + ", value=" + this.value + ", placeholder=" + this.placeholder);
}; };
onKeyUp = ev => { private onKeyUp = (ev: React.KeyboardEvent<HTMLDivElement>): void => {
// console.log("keyUp: textContent=" + ev.target.textContent + ", value=" + this.value + ", placeholder=" + this.placeholder); // console.log("keyUp: textContent=" + ev.target.textContent + ", value=" + this.value + ", placeholder=" + this.placeholder);
if (!ev.target.textContent) { if (!(ev.target as HTMLDivElement).textContent) {
this.showPlaceholder(true); this.showPlaceholder(true);
} else if (!this.placeholder) { } else if (!this.placeholder) {
this.value = ev.target.textContent; this.value = (ev.target as HTMLDivElement).textContent;
} }
if (ev.key === Key.ENTER) { if (ev.key === Key.ENTER) {
@ -160,22 +149,22 @@ export default class EditableText extends React.Component {
// console.log("keyUp: textContent=" + ev.target.textContent + ", value=" + this.value + ", placeholder=" + this.placeholder); // console.log("keyUp: textContent=" + ev.target.textContent + ", value=" + this.value + ", placeholder=" + this.placeholder);
}; };
onClickDiv = ev => { private onClickDiv = (): void => {
if (!this.props.editable) return; if (!this.props.editable) return;
this.setState({ this.setState({
phase: EditableText.Phases.Edit, phase: Phases.Edit,
}); });
}; };
onFocus = ev => { private onFocus = (ev: React.FocusEvent<HTMLDivElement>): void => {
//ev.target.setSelectionRange(0, ev.target.textContent.length); //ev.target.setSelectionRange(0, ev.target.textContent.length);
const node = ev.target.childNodes[0]; const node = ev.target.childNodes[0];
if (node) { if (node) {
const range = document.createRange(); const range = document.createRange();
range.setStart(node, 0); range.setStart(node, 0);
range.setEnd(node, node.length); range.setEnd(node, ev.target.childNodes.length);
const sel = window.getSelection(); const sel = window.getSelection();
sel.removeAllRanges(); sel.removeAllRanges();
@ -183,11 +172,15 @@ export default class EditableText extends React.Component {
} }
}; };
onFinish = (ev, shouldSubmit) => { private onFinish = (
ev: React.KeyboardEvent<HTMLDivElement> | React.FocusEvent<HTMLDivElement>,
shouldSubmit?: boolean,
): void => {
// eslint-disable-next-line @typescript-eslint/no-this-alias
const self = this; const self = this;
const submit = (ev.key === Key.ENTER) || shouldSubmit; const submit = ("key" in ev && ev.key === Key.ENTER) || shouldSubmit;
this.setState({ this.setState({
phase: EditableText.Phases.Display, phase: Phases.Display,
}, () => { }, () => {
if (this.value !== this.props.initialValue) { if (this.value !== this.props.initialValue) {
self.onValueChanged(submit); self.onValueChanged(submit);
@ -195,7 +188,7 @@ export default class EditableText extends React.Component {
}); });
}; };
onBlur = ev => { private onBlur = (ev: React.FocusEvent<HTMLDivElement>): void => {
const sel = window.getSelection(); const sel = window.getSelection();
sel.removeAllRanges(); sel.removeAllRanges();
@ -208,11 +201,11 @@ export default class EditableText extends React.Component {
this.showPlaceholder(!this.value); this.showPlaceholder(!this.value);
}; };
render() { public render(): JSX.Element {
const { className, editable, initialValue, label, labelClassName } = this.props; const { className, editable, initialValue, label, labelClassName } = this.props;
let editableEl; let editableEl;
if (!editable || (this.state.phase === EditableText.Phases.Display && if (!editable || (this.state.phase === Phases.Display &&
(label || labelClassName) && !this.value) (label || labelClassName) && !this.value)
) { ) {
// show the label // show the label
@ -222,7 +215,7 @@ export default class EditableText extends React.Component {
} else { } else {
// show the content editable div, but manually manage its contents as react and contentEditable don't play nice together // show the content editable div, but manually manage its contents as react and contentEditable don't play nice together
editableEl = <div editableEl = <div
ref={this._editable_div} ref={this.editableDiv}
contentEditable={true} contentEditable={true}
className={className} className={className}
onKeyDown={this.onKeyDown} onKeyDown={this.onKeyDown}

View file

@ -15,9 +15,34 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import * as sdk from '../../../index';
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import Spinner from "./Spinner";
import EditableText from "./EditableText";
interface IProps {
/* callback to retrieve the initial value. */
getInitialValue?: () => Promise<string>;
/* initial value; used if getInitialValue is not given */
initialValue?: string;
/* placeholder text to use when the value is empty (and not being
* edited) */
placeholder?: string;
/* callback to update the value. Called with a single argument: the new
* value. */
onSubmit?: (value: string) => Promise<{} | void>;
/* should the input submit when focus is lost? */
blurToSubmit?: boolean;
}
interface IState {
busy: boolean;
errorString: string;
value: string;
}
/** /**
* A component which wraps an EditableText, with a spinner while updates take * A component which wraps an EditableText, with a spinner while updates take
@ -31,50 +56,51 @@ import { replaceableComponent } from "../../../utils/replaceableComponent";
* taken from the 'initialValue' property. * taken from the 'initialValue' property.
*/ */
@replaceableComponent("views.elements.EditableTextContainer") @replaceableComponent("views.elements.EditableTextContainer")
export default class EditableTextContainer extends React.Component { export default class EditableTextContainer extends React.Component<IProps, IState> {
constructor(props) { private unmounted = false;
public static defaultProps: Partial<IProps> = {
initialValue: "",
placeholder: "",
blurToSubmit: false,
onSubmit: () => { return Promise.resolve(); },
};
constructor(props: IProps) {
super(props); super(props);
this._unmounted = false;
this.state = { this.state = {
busy: false, busy: false,
errorString: null, errorString: null,
value: props.initialValue, value: props.initialValue,
}; };
this._onValueChanged = this._onValueChanged.bind(this);
} }
componentDidMount() { public async componentDidMount(): Promise<void> {
if (this.props.getInitialValue === undefined) {
// use whatever was given in the initialValue property. // use whatever was given in the initialValue property.
return; if (this.props.getInitialValue === undefined) return;
}
this.setState({ busy: true }); this.setState({ busy: true });
try {
this.props.getInitialValue().then( const initialValue = await this.props.getInitialValue();
(result) => { if (this.unmounted) return;
if (this._unmounted) { return; }
this.setState({ this.setState({
busy: false, busy: false,
value: result, value: initialValue,
}); });
}, } catch (error) {
(error) => { if (this.unmounted) return;
if (this._unmounted) { return; }
this.setState({ this.setState({
errorString: error.toString(), errorString: error.toString(),
busy: false, busy: false,
}); });
}, }
);
} }
componentWillUnmount() { public componentWillUnmount(): void {
this._unmounted = true; this.unmounted = true;
} }
_onValueChanged(value, shouldSubmit) { private onValueChanged = (value: string, shouldSubmit: boolean): void => {
if (!shouldSubmit) { if (!shouldSubmit) {
return; return;
} }
@ -86,38 +112,36 @@ export default class EditableTextContainer extends React.Component {
this.props.onSubmit(value).then( this.props.onSubmit(value).then(
() => { () => {
if (this._unmounted) { return; } if (this.unmounted) { return; }
this.setState({ this.setState({
busy: false, busy: false,
value: value, value: value,
}); });
}, },
(error) => { (error) => {
if (this._unmounted) { return; } if (this.unmounted) { return; }
this.setState({ this.setState({
errorString: error.toString(), errorString: error.toString(),
busy: false, busy: false,
}); });
}, },
); );
} };
render() { public render(): JSX.Element {
if (this.state.busy) { if (this.state.busy) {
const Loader = sdk.getComponent("elements.Spinner");
return ( return (
<Loader /> <Spinner />
); );
} else if (this.state.errorString) { } else if (this.state.errorString) {
return ( return (
<div className="error">{ this.state.errorString }</div> <div className="error">{ this.state.errorString }</div>
); );
} else { } else {
const EditableText = sdk.getComponent('elements.EditableText');
return ( return (
<EditableText initialValue={this.state.value} <EditableText initialValue={this.state.value}
placeholder={this.props.placeholder} placeholder={this.props.placeholder}
onValueChanged={this._onValueChanged} onValueChanged={this.onValueChanged}
blurToSubmit={this.props.blurToSubmit} blurToSubmit={this.props.blurToSubmit}
/> />
); );
@ -125,28 +149,3 @@ export default class EditableTextContainer extends React.Component {
} }
} }
EditableTextContainer.propTypes = {
/* callback to retrieve the initial value. */
getInitialValue: PropTypes.func,
/* initial value; used if getInitialValue is not given */
initialValue: PropTypes.string,
/* placeholder text to use when the value is empty (and not being
* edited) */
placeholder: PropTypes.string,
/* callback to update the value. Called with a single argument: the new
* value. */
onSubmit: PropTypes.func,
/* should the input submit when focus is lost? */
blurToSubmit: PropTypes.bool,
};
EditableTextContainer.defaultProps = {
initialValue: "",
placeholder: "",
blurToSubmit: false,
onSubmit: function(v) {return Promise.resolve(); },
};

View file

@ -16,13 +16,13 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import * as sdk from '../../../index';
import * as languageHandler from '../../../languageHandler'; import * as languageHandler from '../../../languageHandler';
import SettingsStore from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import Spinner from "./Spinner";
import Dropdown from "./Dropdown";
function languageMatchesSearchQuery(query, language) { function languageMatchesSearchQuery(query, language) {
if (language.label.toUpperCase().includes(query.toUpperCase())) return true; if (language.label.toUpperCase().includes(query.toUpperCase())) return true;
@ -30,11 +30,22 @@ function languageMatchesSearchQuery(query, language) {
return false; return false;
} }
interface IProps {
className?: string;
onOptionChange: (language: string) => void;
value?: string;
disabled?: boolean;
}
interface IState {
searchQuery: string;
langs: string[];
}
@replaceableComponent("views.elements.LanguageDropdown") @replaceableComponent("views.elements.LanguageDropdown")
export default class LanguageDropdown extends React.Component { export default class LanguageDropdown extends React.Component<IProps, IState> {
constructor(props) { constructor(props: IProps) {
super(props); super(props);
this._onSearchChange = this._onSearchChange.bind(this);
this.state = { this.state = {
searchQuery: '', searchQuery: '',
@ -42,7 +53,7 @@ export default class LanguageDropdown extends React.Component {
}; };
} }
componentDidMount() { public componentDidMount(): void {
languageHandler.getAllLanguagesFromJson().then((langs) => { languageHandler.getAllLanguagesFromJson().then((langs) => {
langs.sort(function(a, b) { langs.sort(function(a, b) {
if (a.label < b.label) return -1; if (a.label < b.label) return -1;
@ -63,20 +74,17 @@ export default class LanguageDropdown extends React.Component {
} }
} }
_onSearchChange(search) { private onSearchChange = (search: string): void => {
this.setState({ this.setState({
searchQuery: search, searchQuery: search,
}); });
} };
render() { public render(): JSX.Element {
if (this.state.langs === null) { if (this.state.langs === null) {
const Spinner = sdk.getComponent('elements.Spinner');
return <Spinner />; return <Spinner />;
} }
const Dropdown = sdk.getComponent('elements.Dropdown');
let displayedLanguages; let displayedLanguages;
if (this.state.searchQuery) { if (this.state.searchQuery) {
displayedLanguages = this.state.langs.filter((lang) => { displayedLanguages = this.state.langs.filter((lang) => {
@ -107,7 +115,7 @@ export default class LanguageDropdown extends React.Component {
id="mx_LanguageDropdown" id="mx_LanguageDropdown"
className={this.props.className} className={this.props.className}
onOptionChange={this.props.onOptionChange} onOptionChange={this.props.onOptionChange}
onSearchChange={this._onSearchChange} onSearchChange={this.onSearchChange}
searchEnabled={true} searchEnabled={true}
value={value} value={value}
label={_t("Language Dropdown")} label={_t("Language Dropdown")}
@ -118,8 +126,3 @@ export default class LanguageDropdown extends React.Component {
} }
} }
LanguageDropdown.propTypes = {
className: PropTypes.string,
onOptionChange: PropTypes.func.isRequired,
value: PropTypes.string,
};

View file

@ -15,17 +15,16 @@ limitations under the License.
*/ */
import React from "react"; import React from "react";
import PropTypes from 'prop-types';
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
class ItemRange { class ItemRange {
constructor(topCount, renderCount, bottomCount) { constructor(
this.topCount = topCount; public topCount: number,
this.renderCount = renderCount; public renderCount: number,
this.bottomCount = bottomCount; public bottomCount: number,
} ) { }
contains(range) { public contains(range: ItemRange): boolean {
// don't contain empty ranges // don't contain empty ranges
// as it will prevent clearing the list // as it will prevent clearing the list
// once it is scrolled far enough out of view // once it is scrolled far enough out of view
@ -36,7 +35,7 @@ class ItemRange {
(range.topCount + range.renderCount) <= (this.topCount + this.renderCount); (range.topCount + range.renderCount) <= (this.topCount + this.renderCount);
} }
expand(amount) { public expand(amount: number): ItemRange {
// don't expand ranges that won't render anything // don't expand ranges that won't render anything
if (this.renderCount === 0) { if (this.renderCount === 0) {
return this; return this;
@ -51,20 +50,55 @@ class ItemRange {
); );
} }
totalSize() { public totalSize(): number {
return this.topCount + this.renderCount + this.bottomCount; return this.topCount + this.renderCount + this.bottomCount;
} }
} }
@replaceableComponent("views.elements.LazyRenderList") interface IProps<T> {
export default class LazyRenderList extends React.Component { // height in pixels of the component returned by `renderItem`
constructor(props) { itemHeight: number;
super(props); // function to turn an element of `items` into a react component
renderItem: (item: T) => JSX.Element;
// scrollTop of the viewport (minus the height of any content above this list like other `LazyRenderList`s)
scrollTop: number;
// the height of the viewport this content is scrolled in
height: number;
// all items for the list. These should not be react components, see `renderItem`.
items?: T[];
// the amount of items to scroll before causing a rerender,
// should typically be less than `overflowItems` unless applying
// margins in the parent component when using multiple LazyRenderList in one viewport.
// use 0 to only rerender when items will come into view.
overflowMargin?: number;
// the amount of items to add at the top and bottom to render,
// so not every scroll of causes a rerender.
overflowItems?: number;
this.state = {}; element?: string;
className?: string;
} }
static getDerivedStateFromProps(props, state) { interface IState {
renderRange: ItemRange;
}
@replaceableComponent("views.elements.LazyRenderList")
export default class LazyRenderList<T = any> extends React.Component<IProps<T>, IState> {
public static defaultProps: Partial<IProps<unknown>> = {
overflowItems: 20,
overflowMargin: 5,
};
constructor(props: IProps<T>) {
super(props);
this.state = {
renderRange: null,
};
}
public static getDerivedStateFromProps(props: IProps<unknown>, state: IState): Partial<IState> {
const range = LazyRenderList.getVisibleRangeFromProps(props); const range = LazyRenderList.getVisibleRangeFromProps(props);
const intersectRange = range.expand(props.overflowMargin); const intersectRange = range.expand(props.overflowMargin);
const renderRange = range.expand(props.overflowItems); const renderRange = range.expand(props.overflowItems);
@ -77,7 +111,7 @@ export default class LazyRenderList extends React.Component {
return null; return null;
} }
static getVisibleRangeFromProps(props) { private static getVisibleRangeFromProps(props: IProps<unknown>): ItemRange {
const { items, itemHeight, scrollTop, height } = props; const { items, itemHeight, scrollTop, height } = props;
const length = items ? items.length : 0; const length = items ? items.length : 0;
const topCount = Math.min(Math.max(0, Math.floor(scrollTop / itemHeight)), length); const topCount = Math.min(Math.max(0, Math.floor(scrollTop / itemHeight)), length);
@ -88,7 +122,7 @@ export default class LazyRenderList extends React.Component {
return new ItemRange(topCount, renderCount, bottomCount); return new ItemRange(topCount, renderCount, bottomCount);
} }
render() { public render(): JSX.Element {
const { itemHeight, items, renderItem } = this.props; const { itemHeight, items, renderItem } = this.props;
const { renderRange } = this.state; const { renderRange } = this.state;
const { topCount, renderCount, bottomCount } = renderRange; const { topCount, renderCount, bottomCount } = renderRange;
@ -109,28 +143,3 @@ export default class LazyRenderList extends React.Component {
} }
} }
LazyRenderList.defaultProps = {
overflowItems: 20,
overflowMargin: 5,
};
LazyRenderList.propTypes = {
// height in pixels of the component returned by `renderItem`
itemHeight: PropTypes.number.isRequired,
// function to turn an element of `items` into a react component
renderItem: PropTypes.func.isRequired,
// scrollTop of the viewport (minus the height of any content above this list like other `LazyRenderList`s)
scrollTop: PropTypes.number.isRequired,
// the height of the viewport this content is scrolled in
height: PropTypes.number.isRequired,
// all items for the list. These should not be react components, see `renderItem`.
items: PropTypes.array,
// the amount of items to scroll before causing a rerender,
// should typically be less than `overflowItems` unless applying
// margins in the parent component when using multiple LazyRenderList in one viewport.
// use 0 to only rerender when items will come into view.
overflowMargin: PropTypes.number,
// the amount of items to add at the top and bottom to render,
// so not every scroll of causes a rerender.
overflowItems: PropTypes.number,
};

View file

@ -135,7 +135,7 @@ export default class MemberEventListSummary extends React.Component<IProps> {
const desc = formatCommaSeparatedList(descs); const desc = formatCommaSeparatedList(descs);
return _t('%(nameList)s %(transitionList)s', { nameList: nameList, transitionList: desc }); return _t('%(nameList)s %(transitionList)s', { nameList, transitionList: desc });
}); });
if (!summaries) { if (!summaries) {

View file

@ -16,25 +16,26 @@ limitations under the License.
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import PropTypes from 'prop-types';
import { throttle } from "lodash"; import { throttle } from "lodash";
import ResizeObserver from 'resize-observer-polyfill';
import dis from '../../../dispatcher/dispatcher'; import dis from '../../../dispatcher/dispatcher';
import MatrixClientContext from "../../../contexts/MatrixClientContext"; import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { isNullOrUndefined } from "matrix-js-sdk/src/utils"; import { isNullOrUndefined } from "matrix-js-sdk/src/utils";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import { ActionPayload } from "../../../dispatcher/payloads";
export const getPersistKey = (appId: string) => 'widget_' + appId;
// Shamelessly ripped off Modal.js. There's probably a better way // Shamelessly ripped off Modal.js. There's probably a better way
// of doing reusable widgets like dialog boxes & menus where we go and // of doing reusable widgets like dialog boxes & menus where we go and
// pass in a custom control as the actual body. // pass in a custom control as the actual body.
function getContainer(containerId) { function getContainer(containerId: string): HTMLDivElement {
return document.getElementById(containerId); return document.getElementById(containerId) as HTMLDivElement;
} }
function getOrCreateContainer(containerId) { function getOrCreateContainer(containerId: string): HTMLDivElement {
let container = getContainer(containerId); let container = getContainer(containerId);
if (!container) { if (!container) {
@ -46,7 +47,19 @@ function getOrCreateContainer(containerId) {
return container; return container;
} }
/* interface IProps {
// Unique identifier for this PersistedElement instance
// Any PersistedElements with the same persistKey will use
// the same DOM container.
persistKey: string;
// z-index for the element. Defaults to 9.
zIndex?: number;
style?: React.StyleHTMLAttributes<HTMLDivElement>;
}
/**
* Class of component that renders its children in a separate ReactDOM virtual tree * Class of component that renders its children in a separate ReactDOM virtual tree
* in a container element appended to document.body. * in a container element appended to document.body.
* *
@ -58,42 +71,33 @@ function getOrCreateContainer(containerId) {
* bounding rect as the parent of PE. * bounding rect as the parent of PE.
*/ */
@replaceableComponent("views.elements.PersistedElement") @replaceableComponent("views.elements.PersistedElement")
export default class PersistedElement extends React.Component { export default class PersistedElement extends React.Component<IProps> {
static propTypes = { private resizeObserver: ResizeObserver;
// Unique identifier for this PersistedElement instance private dispatcherRef: string;
// Any PersistedElements with the same persistKey will use private childContainer: HTMLDivElement;
// the same DOM container. private child: HTMLDivElement;
persistKey: PropTypes.string.isRequired,
// z-index for the element. Defaults to 9. constructor(props: IProps) {
zIndex: PropTypes.number, super(props);
};
constructor() { this.resizeObserver = new ResizeObserver(this.repositionChild);
super();
this.collectChildContainer = this.collectChildContainer.bind(this);
this.collectChild = this.collectChild.bind(this);
this._repositionChild = this._repositionChild.bind(this);
this._onAction = this._onAction.bind(this);
this.resizeObserver = new ResizeObserver(this._repositionChild);
// Annoyingly, a resize observer is insufficient, since we also care // Annoyingly, a resize observer is insufficient, since we also care
// about when the element moves on the screen without changing its // about when the element moves on the screen without changing its
// dimensions. Doesn't look like there's a ResizeObserver equivalent // dimensions. Doesn't look like there's a ResizeObserver equivalent
// for this, so we bodge it by listening for document resize and // for this, so we bodge it by listening for document resize and
// the timeline_resize action. // the timeline_resize action.
window.addEventListener('resize', this._repositionChild); window.addEventListener('resize', this.repositionChild);
this._dispatcherRef = dis.register(this._onAction); this.dispatcherRef = dis.register(this.onAction);
} }
/** /**
* Removes the DOM elements created when a PersistedElement with the given * Removes the DOM elements created when a PersistedElement with the given
* persistKey was mounted. The DOM elements will be re-added if another * persistKey was mounted. The DOM elements will be re-added if another
* PeristedElement is mounted in the future. * PersistedElement is mounted in the future.
* *
* @param {string} persistKey Key used to uniquely identify this PersistedElement * @param {string} persistKey Key used to uniquely identify this PersistedElement
*/ */
static destroyElement(persistKey) { public static destroyElement(persistKey: string): void {
const container = getContainer('mx_persistedElement_' + persistKey); const container = getContainer('mx_persistedElement_' + persistKey);
if (container) { if (container) {
container.remove(); container.remove();
@ -104,7 +108,7 @@ export default class PersistedElement extends React.Component {
return Boolean(getContainer('mx_persistedElement_' + persistKey)); return Boolean(getContainer('mx_persistedElement_' + persistKey));
} }
collectChildContainer(ref) { private collectChildContainer = (ref: HTMLDivElement): void => {
if (this.childContainer) { if (this.childContainer) {
this.resizeObserver.unobserve(this.childContainer); this.resizeObserver.unobserve(this.childContainer);
} }
@ -112,48 +116,48 @@ export default class PersistedElement extends React.Component {
if (ref) { if (ref) {
this.resizeObserver.observe(ref); this.resizeObserver.observe(ref);
} }
} };
collectChild(ref) { private collectChild = (ref: HTMLDivElement): void => {
this.child = ref; this.child = ref;
this.updateChild(); this.updateChild();
} };
componentDidMount() { public componentDidMount(): void {
this.updateChild(); this.updateChild();
this.renderApp(); this.renderApp();
} }
componentDidUpdate() { public componentDidUpdate(): void {
this.updateChild(); this.updateChild();
this.renderApp(); this.renderApp();
} }
componentWillUnmount() { public componentWillUnmount(): void {
this.updateChildVisibility(this.child, false); this.updateChildVisibility(this.child, false);
this.resizeObserver.disconnect(); this.resizeObserver.disconnect();
window.removeEventListener('resize', this._repositionChild); window.removeEventListener('resize', this.repositionChild);
dis.unregister(this._dispatcherRef); dis.unregister(this.dispatcherRef);
} }
_onAction(payload) { private onAction = (payload: ActionPayload): void => {
if (payload.action === 'timeline_resize') { if (payload.action === 'timeline_resize') {
this._repositionChild(); this.repositionChild();
} else if (payload.action === 'logout') { } else if (payload.action === 'logout') {
PersistedElement.destroyElement(this.props.persistKey); PersistedElement.destroyElement(this.props.persistKey);
} }
} };
_repositionChild() { private repositionChild = (): void => {
this.updateChildPosition(this.child, this.childContainer); this.updateChildPosition(this.child, this.childContainer);
} };
updateChild() { private updateChild(): void {
this.updateChildPosition(this.child, this.childContainer); this.updateChildPosition(this.child, this.childContainer);
this.updateChildVisibility(this.child, true); this.updateChildVisibility(this.child, true);
} }
renderApp() { private renderApp(): void {
const content = <MatrixClientContext.Provider value={MatrixClientPeg.get()}> const content = <MatrixClientContext.Provider value={MatrixClientPeg.get()}>
<div ref={this.collectChild} style={this.props.style}> <div ref={this.collectChild} style={this.props.style}>
{ this.props.children } { this.props.children }
@ -163,12 +167,12 @@ export default class PersistedElement extends React.Component {
ReactDOM.render(content, getOrCreateContainer('mx_persistedElement_'+this.props.persistKey)); ReactDOM.render(content, getOrCreateContainer('mx_persistedElement_'+this.props.persistKey));
} }
updateChildVisibility(child, visible) { private updateChildVisibility(child: HTMLDivElement, visible: boolean): void {
if (!child) return; if (!child) return;
child.style.display = visible ? 'block' : 'none'; child.style.display = visible ? 'block' : 'none';
} }
updateChildPosition = throttle((child, parent) => { private updateChildPosition = throttle((child: HTMLDivElement, parent: HTMLDivElement): void => {
if (!child || !parent) return; if (!child || !parent) return;
const parentRect = parent.getBoundingClientRect(); const parentRect = parent.getBoundingClientRect();
@ -182,9 +186,8 @@ export default class PersistedElement extends React.Component {
}); });
}, 100, { trailing: true, leading: true }); }, 100, { trailing: true, leading: true });
render() { public render(): JSX.Element {
return <div ref={this.collectChildContainer} />; return <div ref={this.collectChildContainer} />;
} }
} }
export const getPersistKey = (appId) => 'widget_' + appId;

View file

@ -19,47 +19,60 @@ import React from 'react';
import RoomViewStore from '../../../stores/RoomViewStore'; import RoomViewStore from '../../../stores/RoomViewStore';
import ActiveWidgetStore from '../../../stores/ActiveWidgetStore'; import ActiveWidgetStore from '../../../stores/ActiveWidgetStore';
import WidgetUtils from '../../../utils/WidgetUtils'; import WidgetUtils from '../../../utils/WidgetUtils';
import * as sdk from '../../../index';
import { MatrixClientPeg } from '../../../MatrixClientPeg'; import { MatrixClientPeg } from '../../../MatrixClientPeg';
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import { EventSubscription } from 'fbemitter';
import AppTile from "./AppTile";
import { Room } from "matrix-js-sdk/src/models/room";
interface IState {
roomId: string;
persistentWidgetId: string;
}
@replaceableComponent("views.elements.PersistentApp") @replaceableComponent("views.elements.PersistentApp")
export default class PersistentApp extends React.Component { export default class PersistentApp extends React.Component<{}, IState> {
state = { private roomStoreToken: EventSubscription;
constructor() {
super({});
this.state = {
roomId: RoomViewStore.getRoomId(), roomId: RoomViewStore.getRoomId(),
persistentWidgetId: ActiveWidgetStore.getPersistentWidgetId(), persistentWidgetId: ActiveWidgetStore.getPersistentWidgetId(),
}; };
componentDidMount() {
this._roomStoreToken = RoomViewStore.addListener(this._onRoomViewStoreUpdate);
ActiveWidgetStore.on('update', this._onActiveWidgetStoreUpdate);
MatrixClientPeg.get().on("Room.myMembership", this._onMyMembership);
} }
componentWillUnmount() { public componentDidMount(): void {
if (this._roomStoreToken) { this.roomStoreToken = RoomViewStore.addListener(this.onRoomViewStoreUpdate);
this._roomStoreToken.remove(); ActiveWidgetStore.on('update', this.onActiveWidgetStoreUpdate);
MatrixClientPeg.get().on("Room.myMembership", this.onMyMembership);
} }
ActiveWidgetStore.removeListener('update', this._onActiveWidgetStoreUpdate);
public componentWillUnmount(): void {
if (this.roomStoreToken) {
this.roomStoreToken.remove();
}
ActiveWidgetStore.removeListener('update', this.onActiveWidgetStoreUpdate);
if (MatrixClientPeg.get()) { if (MatrixClientPeg.get()) {
MatrixClientPeg.get().removeListener("Room.myMembership", this._onMyMembership); MatrixClientPeg.get().removeListener("Room.myMembership", this.onMyMembership);
} }
} }
_onRoomViewStoreUpdate = payload => { private onRoomViewStoreUpdate = (): void => {
if (RoomViewStore.getRoomId() === this.state.roomId) return; if (RoomViewStore.getRoomId() === this.state.roomId) return;
this.setState({ this.setState({
roomId: RoomViewStore.getRoomId(), roomId: RoomViewStore.getRoomId(),
}); });
}; };
_onActiveWidgetStoreUpdate = () => { private onActiveWidgetStoreUpdate = (): void => {
this.setState({ this.setState({
persistentWidgetId: ActiveWidgetStore.getPersistentWidgetId(), persistentWidgetId: ActiveWidgetStore.getPersistentWidgetId(),
}); });
}; };
_onMyMembership = async (room, membership) => { private onMyMembership = async (room: Room, membership: string): Promise<void> => {
const persistentWidgetInRoomId = ActiveWidgetStore.getRoomId(this.state.persistentWidgetId); const persistentWidgetInRoomId = ActiveWidgetStore.getRoomId(this.state.persistentWidgetId);
if (membership !== "join") { if (membership !== "join") {
// we're not in the room anymore - delete // we're not in the room anymore - delete
@ -69,7 +82,7 @@ export default class PersistentApp extends React.Component {
} }
}; };
render() { public render(): JSX.Element {
if (this.state.persistentWidgetId) { if (this.state.persistentWidgetId) {
const persistentWidgetInRoomId = ActiveWidgetStore.getRoomId(this.state.persistentWidgetId); const persistentWidgetInRoomId = ActiveWidgetStore.getRoomId(this.state.persistentWidgetId);
@ -89,7 +102,6 @@ export default class PersistentApp extends React.Component {
appEvent.getStateKey(), appEvent.getContent(), appEvent.getSender(), appEvent.getStateKey(), appEvent.getContent(), appEvent.getSender(),
persistentWidgetInRoomId, appEvent.getId(), persistentWidgetInRoomId, appEvent.getId(),
); );
const AppTile = sdk.getComponent('elements.AppTile');
return <AppTile return <AppTile
key={app.id} key={app.id}
app={app} app={app}

View file

@ -15,40 +15,52 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import * as Roles from '../../../Roles'; import * as Roles from '../../../Roles';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import Field from "./Field"; import Field from "./Field";
import { Key } from "../../../Keyboard"; import { Key } from "../../../Keyboard";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
@replaceableComponent("views.elements.PowerSelector") const CUSTOM_VALUE = "SELECT_VALUE_CUSTOM";
export default class PowerSelector extends React.Component {
static propTypes = { interface IProps {
value: PropTypes.number.isRequired, value: number;
// The maximum value that can be set with the power selector // The maximum value that can be set with the power selector
maxValue: PropTypes.number.isRequired, maxValue: number;
// Default user power level for the room // Default user power level for the room
usersDefault: PropTypes.number.isRequired, usersDefault: number;
// should the user be able to change the value? false by default. // should the user be able to change the value? false by default.
disabled: PropTypes.bool, disabled?: boolean;
onChange: PropTypes.func, onChange?: (value: number, powerLevelKey: string) => void;
// Optional key to pass as the second argument to `onChange` // Optional key to pass as the second argument to `onChange`
powerLevelKey: PropTypes.string, powerLevelKey?: string;
// The name to annotate the selector with // The name to annotate the selector with
label: PropTypes.string, label?: string;
} }
static defaultProps = { interface IState {
levelRoleMap: {};
// List of power levels to show in the drop-down
options: number[];
customValue: number;
selectValue: number | string;
custom?: boolean;
customLevel?: number;
}
@replaceableComponent("views.elements.PowerSelector")
export default class PowerSelector extends React.Component<IProps, IState> {
public static defaultProps: Partial<IProps> = {
maxValue: Infinity, maxValue: Infinity,
usersDefault: 0, usersDefault: 0,
}; };
constructor(props) { constructor(props: IProps) {
super(props); super(props);
this.state = { this.state = {
@ -62,26 +74,26 @@ export default class PowerSelector extends React.Component {
} }
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event // TODO: [REACT-WARNING] Replace with appropriate lifecycle event
// eslint-disable-next-line camelcase // eslint-disable-next-line camelcase, @typescript-eslint/naming-convention
UNSAFE_componentWillMount() { public UNSAFE_componentWillMount(): void {
this._initStateFromProps(this.props); this.initStateFromProps(this.props);
} }
// eslint-disable-next-line camelcase // eslint-disable-next-line camelcase, @typescript-eslint/naming-convention
UNSAFE_componentWillReceiveProps(newProps) { public UNSAFE_componentWillReceiveProps(newProps: IProps): void {
this._initStateFromProps(newProps); this.initStateFromProps(newProps);
} }
_initStateFromProps(newProps) { private initStateFromProps(newProps: IProps): void {
// This needs to be done now because levelRoleMap has translated strings // This needs to be done now because levelRoleMap has translated strings
const levelRoleMap = Roles.levelRoleMap(newProps.usersDefault); const levelRoleMap = Roles.levelRoleMap(newProps.usersDefault);
const options = Object.keys(levelRoleMap).filter(level => { const options = Object.keys(levelRoleMap).filter(level => {
return ( return (
level === undefined || level === undefined ||
level <= newProps.maxValue || parseInt(level) <= newProps.maxValue ||
level == newProps.value parseInt(level) == newProps.value
); );
}); }).map(level => parseInt(level));
const isCustom = levelRoleMap[newProps.value] === undefined; const isCustom = levelRoleMap[newProps.value] === undefined;
@ -90,32 +102,33 @@ export default class PowerSelector extends React.Component {
options, options,
custom: isCustom, custom: isCustom,
customLevel: newProps.value, customLevel: newProps.value,
selectValue: isCustom ? "SELECT_VALUE_CUSTOM" : newProps.value, selectValue: isCustom ? CUSTOM_VALUE : newProps.value,
}); });
} }
onSelectChange = event => { private onSelectChange = (event: React.ChangeEvent<HTMLSelectElement>): void => {
const isCustom = event.target.value === "SELECT_VALUE_CUSTOM"; const isCustom = event.target.value === CUSTOM_VALUE;
if (isCustom) { if (isCustom) {
this.setState({ custom: true }); this.setState({ custom: true });
} else { } else {
this.props.onChange(event.target.value, this.props.powerLevelKey); const powerLevel = parseInt(event.target.value);
this.setState({ selectValue: event.target.value }); this.props.onChange(powerLevel, this.props.powerLevelKey);
this.setState({ selectValue: powerLevel });
} }
}; };
onCustomChange = event => { private onCustomChange = (event: React.ChangeEvent<HTMLInputElement>): void => {
this.setState({ customValue: event.target.value }); this.setState({ customValue: parseInt(event.target.value) });
}; };
onCustomBlur = event => { private onCustomBlur = (event: React.FocusEvent): void => {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
this.props.onChange(parseInt(this.state.customValue), this.props.powerLevelKey); this.props.onChange(this.state.customValue, this.props.powerLevelKey);
}; };
onCustomKeyDown = event => { private onCustomKeyDown = (event: React.KeyboardEvent<HTMLInputElement>): void => {
if (event.key === Key.ENTER) { if (event.key === Key.ENTER) {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
@ -125,11 +138,11 @@ export default class PowerSelector extends React.Component {
// raising a dialog which causes a blur which causes a dialog which causes a blur and // raising a dialog which causes a blur which causes a dialog which causes a blur and
// so on. By not causing the onChange to be called here, we avoid the loop because we // so on. By not causing the onChange to be called here, we avoid the loop because we
// handle the onBlur safely. // handle the onBlur safely.
event.target.blur(); (event.target as HTMLInputElement).blur();
} }
}; };
render() { public render(): JSX.Element {
let picker; let picker;
const label = typeof this.props.label === "undefined" ? _t("Power level") : this.props.label; const label = typeof this.props.label === "undefined" ? _t("Power level") : this.props.label;
if (this.state.custom) { if (this.state.custom) {
@ -147,14 +160,14 @@ export default class PowerSelector extends React.Component {
); );
} else { } else {
// Each level must have a definition in this.state.levelRoleMap // Each level must have a definition in this.state.levelRoleMap
let options = this.state.options.map((level) => { const options = this.state.options.map((level) => {
return { return {
value: level, value: String(level),
text: Roles.textualPowerLevel(level, this.props.usersDefault), text: Roles.textualPowerLevel(level, this.props.usersDefault),
}; };
}); });
options.push({ value: "SELECT_VALUE_CUSTOM", text: _t("Custom level") }); options.push({ value: CUSTOM_VALUE, text: _t("Custom level") });
options = options.map((op) => { const optionsElements = options.map((op) => {
return <option value={op.value} key={op.value}>{ op.text }</option>; return <option value={op.value} key={op.value}>{ op.text }</option>;
}); });
@ -166,7 +179,7 @@ export default class PowerSelector extends React.Component {
value={String(this.state.selectValue)} value={String(this.state.selectValue)}
disabled={this.props.disabled} disabled={this.props.disabled}
> >
{ options } { optionsElements }
</Field> </Field>
); );
} }

View file

@ -88,7 +88,13 @@ export default class ReplyThread extends React.Component<IProps, IState> {
// could be used here for replies as well... However, the helper // could be used here for replies as well... However, the helper
// currently assumes the relation has a `rel_type`, which older replies // currently assumes the relation has a `rel_type`, which older replies
// do not, so this block is left as-is for now. // do not, so this block is left as-is for now.
const mRelatesTo = ev.getWireContent()['m.relates_to']; //
// We're prefer ev.getContent() over ev.getWireContent() to make sure
// we grab the latest edit with potentially new relations. But we also
// can't just rely on ev.getContent() by itself because historically we
// still show the reply from the original message even though the edit
// event does not include the relation reply.
const mRelatesTo = ev.getContent()['m.relates_to'] || ev.getWireContent()['m.relates_to'];
if (mRelatesTo && mRelatesTo['m.in_reply_to']) { if (mRelatesTo && mRelatesTo['m.in_reply_to']) {
const mInReplyTo = mRelatesTo['m.in_reply_to']; const mInReplyTo = mRelatesTo['m.in_reply_to'];
if (mInReplyTo && mInReplyTo['event_id']) return mInReplyTo['event_id']; if (mInReplyTo && mInReplyTo['event_id']) return mInReplyTo['event_id'];

View file

@ -17,25 +17,34 @@
import React from 'react'; import React from 'react';
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
interface IProps {
reason?: string;
contentHtml: string;
}
interface IState {
visible: boolean;
}
@replaceableComponent("views.elements.Spoiler") @replaceableComponent("views.elements.Spoiler")
export default class Spoiler extends React.Component { export default class Spoiler extends React.Component<IProps, IState> {
constructor(props) { constructor(props: IProps) {
super(props); super(props);
this.state = { this.state = {
visible: false, visible: false,
}; };
} }
toggleVisible(e) { private toggleVisible = (e: React.MouseEvent): void => {
if (!this.state.visible) { if (!this.state.visible) {
// we are un-blurring, we don't want this click to propagate to potential child pills // we are un-blurring, we don't want this click to propagate to potential child pills
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
} }
this.setState({ visible: !this.state.visible }); this.setState({ visible: !this.state.visible });
} };
render() { public render(): JSX.Element {
const reason = this.props.reason ? ( const reason = this.props.reason ? (
<span className="mx_EventTile_spoiler_reason">{ "(" + this.props.reason + ")" }</span> <span className="mx_EventTile_spoiler_reason">{ "(" + this.props.reason + ")" }</span>
) : null; ) : null;
@ -43,7 +52,7 @@ export default class Spoiler extends React.Component {
// as such, we pass the this.props.contentHtml instead and then set the raw // as such, we pass the this.props.contentHtml instead and then set the raw
// HTML content. This is secure as the contents have already been parsed previously // HTML content. This is secure as the contents have already been parsed previously
return ( return (
<span className={"mx_EventTile_spoiler" + (this.state.visible ? " visible" : "")} onClick={this.toggleVisible.bind(this)}> <span className={"mx_EventTile_spoiler" + (this.state.visible ? " visible" : "")} onClick={this.toggleVisible}>
{ reason } { reason }
&nbsp; &nbsp;
<span className="mx_EventTile_spoiler_content" dangerouslySetInnerHTML={{ __html: this.props.contentHtml }} /> <span className="mx_EventTile_spoiler_content" dangerouslySetInnerHTML={{ __html: this.props.contentHtml }} />

View file

@ -15,40 +15,40 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import { highlightBlock } from 'highlight.js'; import { highlightBlock } from 'highlight.js';
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
interface IProps {
className?: string;
children?: React.ReactNode;
}
@replaceableComponent("views.elements.SyntaxHighlight") @replaceableComponent("views.elements.SyntaxHighlight")
export default class SyntaxHighlight extends React.Component { export default class SyntaxHighlight extends React.Component<IProps> {
static propTypes = { private el: HTMLPreElement = null;
className: PropTypes.string,
children: PropTypes.node,
};
constructor(props) { constructor(props: IProps) {
super(props); super(props);
this._ref = this._ref.bind(this);
} }
// componentDidUpdate used here for reusability // componentDidUpdate used here for reusability
componentDidUpdate() { public componentDidUpdate(): void {
if (this._el) highlightBlock(this._el); if (this.el) highlightBlock(this.el);
} }
// call componentDidUpdate because _ref is fired on initial render // call componentDidUpdate because _ref is fired on initial render
// which does not fire componentDidUpdate // which does not fire componentDidUpdate
_ref(el) { private ref = (el: HTMLPreElement): void => {
this._el = el; this.el = el;
this.componentDidUpdate(); this.componentDidUpdate();
} };
render() { public render(): JSX.Element {
const { className, children } = this.props; const { className, children } = this.props;
return <pre className={`${className} mx_SyntaxHighlight`} ref={this._ref}> return <pre className={`${className} mx_SyntaxHighlight`} ref={this.ref}>
<code>{ children }</code> <code>{ children }</code>
</pre>; </pre>;
} }
} }

View file

@ -15,42 +15,44 @@
*/ */
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import * as sdk from '../../../index';
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import Tooltip from "./Tooltip";
interface IProps {
class?: string;
tooltipClass?: string;
tooltip: React.ReactNode;
tooltipProps?: {};
onClick?: (ev?: React.MouseEvent) => void;
}
interface IState {
hover: boolean;
}
@replaceableComponent("views.elements.TextWithTooltip") @replaceableComponent("views.elements.TextWithTooltip")
export default class TextWithTooltip extends React.Component { export default class TextWithTooltip extends React.Component<IProps, IState> {
static propTypes = { constructor(props: IProps) {
class: PropTypes.string, super(props);
tooltipClass: PropTypes.string,
tooltip: PropTypes.node.isRequired,
tooltipProps: PropTypes.object,
};
constructor() {
super();
this.state = { this.state = {
hover: false, hover: false,
}; };
} }
onMouseOver = () => { private onMouseOver = (): void => {
this.setState({ hover: true }); this.setState({ hover: true });
}; };
onMouseLeave = () => { private onMouseLeave = (): void => {
this.setState({ hover: false }); this.setState({ hover: false });
}; };
render() { public render(): JSX.Element {
const Tooltip = sdk.getComponent("elements.Tooltip");
const { class: className, children, tooltip, tooltipClass, tooltipProps, ...props } = this.props; const { class: className, children, tooltip, tooltipClass, tooltipProps, ...props } = this.props;
return ( return (
<span {...props} onMouseOver={this.onMouseOver} onMouseLeave={this.onMouseLeave} className={className}> <span {...props} onMouseOver={this.onMouseOver} onMouseLeave={this.onMouseLeave} onClick={this.props.onClick} className={className}>
{ children } { children }
{ this.state.hover && <Tooltip { this.state.hover && <Tooltip
{...tooltipProps} {...tooltipProps}

View file

@ -15,20 +15,20 @@ limitations under the License.
*/ */
import React from "react"; import React from "react";
import PropTypes from "prop-types";
import { replaceableComponent } from "../../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../../utils/replaceableComponent";
import QRCode from "../QRCode"; import QRCode from "../QRCode";
import { QRCodeData } from "matrix-js-sdk/src/crypto/verification/QRCode";
interface IProps {
qrCodeData: QRCodeData;
}
@replaceableComponent("views.elements.crypto.VerificationQRCode") @replaceableComponent("views.elements.crypto.VerificationQRCode")
export default class VerificationQRCode extends React.PureComponent { export default class VerificationQRCode extends React.PureComponent<IProps> {
static propTypes = { public render(): JSX.Element {
qrCodeData: PropTypes.object.isRequired,
};
render() {
return ( return (
<QRCode <QRCode
data={[{ data: this.props.qrCodeData.buffer, mode: 'byte' }]} data={[{ data: this.props.qrCodeData.getBuffer(), mode: 'byte' }]}
className="mx_VerificationQRCode" className="mx_VerificationQRCode"
width={196} /> width={196} />
); );

View file

@ -106,31 +106,20 @@ export default class ReactionsRowButton extends React.PureComponent<IProps, ISta
} }
const room = this.context.getRoom(mxEvent.getRoomId()); const room = this.context.getRoom(mxEvent.getRoomId());
let label; let label: string;
if (room) { if (room) {
const senders = []; const senders = [];
for (const reactionEvent of reactionEvents) { for (const reactionEvent of reactionEvents) {
const member = room.getMember(reactionEvent.getSender()); const member = room.getMember(reactionEvent.getSender());
const name = member ? member.name : reactionEvent.getSender(); senders.push(member?.name || reactionEvent.getSender());
senders.push(name);
} }
label = _t(
"<reactors/><reactedWith> reacted with %(content)s</reactedWith>", const reactors = formatCommaSeparatedList(senders, 6);
{ if (content) {
content, label = _t("%(reactors)s reacted with %(content)s", { reactors, content });
}, } else {
{ label = reactors;
reactors: () => {
return formatCommaSeparatedList(senders, 6);
},
reactedWith: (sub) => {
if (!content) {
return null;
} }
return sub;
},
},
);
} }
const isPeeking = room.getMyMembership() !== "join"; const isPeeking = room.getMyMembership() !== "join";
return <AccessibleButton return <AccessibleButton

View file

@ -429,7 +429,7 @@ const UserOptionsSection: React.FC<{
if (!isMe) { if (!isMe) {
directMessageButton = ( directMessageButton = (
<AccessibleButton onClick={() => { openDMForUser(cli, member.userId); }} className="mx_UserInfo_field"> <AccessibleButton onClick={() => { openDMForUser(cli, member.userId); }} className="mx_UserInfo_field">
{ _t('Direct message') } { _t("Message") }
</AccessibleButton> </AccessibleButton>
); );
} }
@ -826,7 +826,7 @@ const RoomAdminToolsContainer: React.FC<IBaseRoomProps> = ({
if (canAffectUser && me.powerLevel >= banPowerLevel) { if (canAffectUser && me.powerLevel >= banPowerLevel) {
banButton = <BanToggleButton member={member} startUpdating={startUpdating} stopUpdating={stopUpdating} />; banButton = <BanToggleButton member={member} startUpdating={startUpdating} stopUpdating={stopUpdating} />;
} }
if (canAffectUser && me.powerLevel >= editPowerLevel) { if (canAffectUser && me.powerLevel >= editPowerLevel && !room.isSpaceRoom()) {
muteButton = ( muteButton = (
<MuteToggleButton <MuteToggleButton
member={member} member={member}
@ -1052,8 +1052,7 @@ const PowerLevelEditor: React.FC<{
const cli = useContext(MatrixClientContext); const cli = useContext(MatrixClientContext);
const [selectedPowerLevel, setSelectedPowerLevel] = useState(user.powerLevel); const [selectedPowerLevel, setSelectedPowerLevel] = useState(user.powerLevel);
const onPowerChange = useCallback(async (powerLevelStr: string) => { const onPowerChange = useCallback(async (powerLevel: number) => {
const powerLevel = parseInt(powerLevelStr, 10);
setSelectedPowerLevel(powerLevel); setSelectedPowerLevel(powerLevel);
const applyPowerChange = (roomId, target, powerLevel, powerLevelEvent) => { const applyPowerChange = (roomId, target, powerLevel, powerLevelEvent) => {

View file

@ -28,7 +28,7 @@ import { SAS } from "matrix-js-sdk/src/crypto/verification/SAS";
import VerificationQRCode from "../elements/crypto/VerificationQRCode"; import VerificationQRCode from "../elements/crypto/VerificationQRCode";
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import SdkConfig from "../../../SdkConfig"; import SdkConfig from "../../../SdkConfig";
import E2EIcon from "../rooms/E2EIcon"; import E2EIcon, { E2EState } from "../rooms/E2EIcon";
import { Phase } from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest"; import { Phase } from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest";
import Spinner from "../elements/Spinner"; import Spinner from "../elements/Spinner";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
@ -189,7 +189,7 @@ export default class VerificationPanel extends React.PureComponent<IProps, IStat
// Element Web doesn't support scanning yet, so assume here we're the client being scanned. // Element Web doesn't support scanning yet, so assume here we're the client being scanned.
body = <React.Fragment> body = <React.Fragment>
<p>{ description }</p> <p>{ description }</p>
<E2EIcon isUser={true} status="verified" size={128} hideTooltip={true} /> <E2EIcon isUser={true} status={E2EState.Verified} size={128} hideTooltip={true} />
<div className="mx_VerificationPanel_reciprocateButtons"> <div className="mx_VerificationPanel_reciprocateButtons">
<AccessibleButton <AccessibleButton
kind="danger" kind="danger"
@ -252,7 +252,7 @@ export default class VerificationPanel extends React.PureComponent<IProps, IStat
<div className="mx_UserInfo_container mx_VerificationPanel_verified_section"> <div className="mx_UserInfo_container mx_VerificationPanel_verified_section">
<h3>{ _t("Verified") }</h3> <h3>{ _t("Verified") }</h3>
<p>{ description }</p> <p>{ description }</p>
<E2EIcon isUser={true} status="verified" size={128} hideTooltip={true} /> <E2EIcon isUser={true} status={E2EState.Verified} size={128} hideTooltip={true} />
{ text ? <p>{ text }</p> : null } { text ? <p>{ text }</p> : null }
<AccessibleButton kind="primary" className="mx_UserInfo_wideButton" onClick={this.props.onClose}> <AccessibleButton kind="primary" className="mx_UserInfo_wideButton" onClick={this.props.onClose}>
{ _t("Got it") } { _t("Got it") }

View file

@ -97,7 +97,6 @@ const WidgetCard: React.FC<IProps> = ({ room, widgetId, onClose }) => {
<AppTile <AppTile
app={app} app={app}
fullWidth fullWidth
show
showMenubar={false} showMenubar={false}
room={room} room={room}
userId={cli.getUserId()} userId={cli.getUserId()}

View file

@ -15,27 +15,43 @@ limitations under the License.
*/ */
import React, { createRef } from 'react'; import React, { createRef } from 'react';
import PropTypes from 'prop-types';
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { MatrixClientPeg } from "../../../MatrixClientPeg";
import Field from "../elements/Field"; import Field from "../elements/Field";
import * as sdk from "../../../index";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import { mediaFromMxc } from "../../../customisations/Media"; import { mediaFromMxc } from "../../../customisations/Media";
import AccessibleButton from "../elements/AccessibleButton";
import AvatarSetting from "../settings/AvatarSetting";
interface IProps {
roomId: string;
}
interface IState {
originalDisplayName: string;
displayName: string;
originalAvatarUrl: string;
avatarUrl: string;
avatarFile: File;
originalTopic: string;
topic: string;
enableProfileSave: boolean;
canSetName: boolean;
canSetTopic: boolean;
canSetAvatar: boolean;
}
// TODO: Merge with ProfileSettings? // TODO: Merge with ProfileSettings?
@replaceableComponent("views.room_settings.RoomProfileSettings") @replaceableComponent("views.room_settings.RoomProfileSettings")
export default class RoomProfileSettings extends React.Component { export default class RoomProfileSettings extends React.Component<IProps, IState> {
static propTypes = { private avatarUpload = createRef<HTMLInputElement>();
roomId: PropTypes.string.isRequired,
};
constructor(props) { constructor(props: IProps) {
super(props); super(props);
const client = MatrixClientPeg.get(); const client = MatrixClientPeg.get();
const room = client.getRoom(props.roomId); const room = client.getRoom(props.roomId);
if (!room) throw new Error("Expected a room for ID: ", props.roomId); if (!room) throw new Error(`Expected a room for ID: ${props.roomId}`);
const avatarEvent = room.currentState.getStateEvents("m.room.avatar", ""); const avatarEvent = room.currentState.getStateEvents("m.room.avatar", "");
let avatarUrl = avatarEvent && avatarEvent.getContent() ? avatarEvent.getContent()["url"] : null; let avatarUrl = avatarEvent && avatarEvent.getContent() ? avatarEvent.getContent()["url"] : null;
@ -60,17 +76,15 @@ export default class RoomProfileSettings extends React.Component {
canSetTopic: room.currentState.maySendStateEvent('m.room.topic', client.getUserId()), canSetTopic: room.currentState.maySendStateEvent('m.room.topic', client.getUserId()),
canSetAvatar: room.currentState.maySendStateEvent('m.room.avatar', client.getUserId()), canSetAvatar: room.currentState.maySendStateEvent('m.room.avatar', client.getUserId()),
}; };
this._avatarUpload = createRef();
} }
_uploadAvatar = () => { private uploadAvatar = (): void => {
this._avatarUpload.current.click(); this.avatarUpload.current.click();
}; };
_removeAvatar = () => { private removeAvatar = (): void => {
// clear file upload field so same file can be selected // clear file upload field so same file can be selected
this._avatarUpload.current.value = ""; this.avatarUpload.current.value = "";
this.setState({ this.setState({
avatarUrl: null, avatarUrl: null,
avatarFile: null, avatarFile: null,
@ -78,7 +92,7 @@ export default class RoomProfileSettings extends React.Component {
}); });
}; };
_cancelProfileChanges = async (e) => { private cancelProfileChanges = async (e: React.MouseEvent): Promise<void> => {
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
@ -92,7 +106,7 @@ export default class RoomProfileSettings extends React.Component {
}); });
}; };
_saveProfile = async (e) => { private saveProfile = async (e: React.FormEvent): Promise<void> => {
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
@ -100,35 +114,46 @@ export default class RoomProfileSettings extends React.Component {
this.setState({ enableProfileSave: false }); this.setState({ enableProfileSave: false });
const client = MatrixClientPeg.get(); const client = MatrixClientPeg.get();
const newState = {};
let originalDisplayName: string;
let avatarUrl: string;
let originalAvatarUrl: string;
let originalTopic: string;
let avatarFile: File;
// TODO: What do we do about errors? // TODO: What do we do about errors?
const displayName = this.state.displayName.trim(); const displayName = this.state.displayName.trim();
if (this.state.originalDisplayName !== this.state.displayName) { if (this.state.originalDisplayName !== this.state.displayName) {
await client.setRoomName(this.props.roomId, displayName); await client.setRoomName(this.props.roomId, displayName);
newState.originalDisplayName = displayName; originalDisplayName = displayName;
newState.displayName = displayName;
} }
if (this.state.avatarFile) { if (this.state.avatarFile) {
const uri = await client.uploadContent(this.state.avatarFile); const uri = await client.uploadContent(this.state.avatarFile);
await client.sendStateEvent(this.props.roomId, 'm.room.avatar', { url: uri }, ''); await client.sendStateEvent(this.props.roomId, 'm.room.avatar', { url: uri }, '');
newState.avatarUrl = mediaFromMxc(uri).getSquareThumbnailHttp(96); avatarUrl = mediaFromMxc(uri).getSquareThumbnailHttp(96);
newState.originalAvatarUrl = newState.avatarUrl; originalAvatarUrl = avatarUrl;
newState.avatarFile = null; avatarFile = null;
} else if (this.state.originalAvatarUrl !== this.state.avatarUrl) { } else if (this.state.originalAvatarUrl !== this.state.avatarUrl) {
await client.sendStateEvent(this.props.roomId, 'm.room.avatar', {}, ''); await client.sendStateEvent(this.props.roomId, 'm.room.avatar', {}, '');
} }
if (this.state.originalTopic !== this.state.topic) { if (this.state.originalTopic !== this.state.topic) {
await client.setRoomTopic(this.props.roomId, this.state.topic); await client.setRoomTopic(this.props.roomId, this.state.topic);
newState.originalTopic = this.state.topic; originalTopic = this.state.topic;
} }
this.setState(newState); this.setState({
originalAvatarUrl,
avatarUrl,
originalDisplayName,
originalTopic,
displayName,
avatarFile,
});
}; };
_onDisplayNameChanged = (e) => { private onDisplayNameChanged = (e: React.ChangeEvent<HTMLInputElement>): void => {
this.setState({ displayName: e.target.value }); this.setState({ displayName: e.target.value });
if (this.state.originalDisplayName === e.target.value) { if (this.state.originalDisplayName === e.target.value) {
this.setState({ enableProfileSave: false }); this.setState({ enableProfileSave: false });
@ -137,7 +162,7 @@ export default class RoomProfileSettings extends React.Component {
} }
}; };
_onTopicChanged = (e) => { private onTopicChanged = (e: React.ChangeEvent<HTMLTextAreaElement>): void => {
this.setState({ topic: e.target.value }); this.setState({ topic: e.target.value });
if (this.state.originalTopic === e.target.value) { if (this.state.originalTopic === e.target.value) {
this.setState({ enableProfileSave: false }); this.setState({ enableProfileSave: false });
@ -146,7 +171,7 @@ export default class RoomProfileSettings extends React.Component {
} }
}; };
_onAvatarChanged = (e) => { private onAvatarChanged = (e: React.ChangeEvent<HTMLInputElement>): void => {
if (!e.target.files || !e.target.files.length) { if (!e.target.files || !e.target.files.length) {
this.setState({ this.setState({
avatarUrl: this.state.originalAvatarUrl, avatarUrl: this.state.originalAvatarUrl,
@ -160,7 +185,7 @@ export default class RoomProfileSettings extends React.Component {
const reader = new FileReader(); const reader = new FileReader();
reader.onload = (ev) => { reader.onload = (ev) => {
this.setState({ this.setState({
avatarUrl: ev.target.result, avatarUrl: String(ev.target.result),
avatarFile: file, avatarFile: file,
enableProfileSave: true, enableProfileSave: true,
}); });
@ -168,10 +193,7 @@ export default class RoomProfileSettings extends React.Component {
reader.readAsDataURL(file); reader.readAsDataURL(file);
}; };
render() { public render(): JSX.Element {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
const AvatarSetting = sdk.getComponent('settings.AvatarSetting');
let profileSettingsButtons; let profileSettingsButtons;
if ( if (
this.state.canSetName || this.state.canSetName ||
@ -181,14 +203,14 @@ export default class RoomProfileSettings extends React.Component {
profileSettingsButtons = ( profileSettingsButtons = (
<div className="mx_ProfileSettings_buttons"> <div className="mx_ProfileSettings_buttons">
<AccessibleButton <AccessibleButton
onClick={this._cancelProfileChanges} onClick={this.cancelProfileChanges}
kind="link" kind="link"
disabled={!this.state.enableProfileSave} disabled={!this.state.enableProfileSave}
> >
{ _t("Cancel") } { _t("Cancel") }
</AccessibleButton> </AccessibleButton>
<AccessibleButton <AccessibleButton
onClick={this._saveProfile} onClick={this.saveProfile}
kind="primary" kind="primary"
disabled={!this.state.enableProfileSave} disabled={!this.state.enableProfileSave}
> >
@ -200,16 +222,16 @@ export default class RoomProfileSettings extends React.Component {
return ( return (
<form <form
onSubmit={this._saveProfile} onSubmit={this.saveProfile}
autoComplete="off" autoComplete="off"
noValidate={true} noValidate={true}
className="mx_ProfileSettings_profileForm" className="mx_ProfileSettings_profileForm"
> >
<input <input
type="file" type="file"
ref={this._avatarUpload} ref={this.avatarUpload}
className="mx_ProfileSettings_avatarUpload" className="mx_ProfileSettings_avatarUpload"
onChange={this._onAvatarChanged} onChange={this.onAvatarChanged}
accept="image/*" accept="image/*"
/> />
<div className="mx_ProfileSettings_profile"> <div className="mx_ProfileSettings_profile">
@ -219,7 +241,7 @@ export default class RoomProfileSettings extends React.Component {
type="text" type="text"
value={this.state.displayName} value={this.state.displayName}
autoComplete="off" autoComplete="off"
onChange={this._onDisplayNameChanged} onChange={this.onDisplayNameChanged}
disabled={!this.state.canSetName} disabled={!this.state.canSetName}
/> />
<Field <Field
@ -230,7 +252,7 @@ export default class RoomProfileSettings extends React.Component {
type="text" type="text"
value={this.state.topic} value={this.state.topic}
autoComplete="off" autoComplete="off"
onChange={this._onTopicChanged} onChange={this.onTopicChanged}
element="textarea" element="textarea"
/> />
</div> </div>
@ -238,8 +260,8 @@ export default class RoomProfileSettings extends React.Component {
avatarUrl={this.state.avatarUrl} avatarUrl={this.state.avatarUrl}
avatarName={this.state.displayName || this.props.roomId} avatarName={this.state.displayName || this.props.roomId}
avatarAltText={_t("Room avatar")} avatarAltText={_t("Room avatar")}
uploadAvatar={this.state.canSetAvatar ? this._uploadAvatar : undefined} uploadAvatar={this.state.canSetAvatar ? this.uploadAvatar : undefined}
removeAvatar={this.state.canSetAvatar ? this._removeAvatar : undefined} /> removeAvatar={this.state.canSetAvatar ? this.removeAvatar : undefined} />
</div> </div>
{ profileSettingsButtons } { profileSettingsButtons }
</form> </form>

View file

@ -18,8 +18,6 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import * as sdk from "../../../index";
import { _t, _td } from '../../../languageHandler'; import { _t, _td } from '../../../languageHandler';
import SettingsStore from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
import dis from "../../../dispatcher/dispatcher"; import dis from "../../../dispatcher/dispatcher";
@ -27,21 +25,22 @@ import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { Action } from "../../../dispatcher/actions"; import { Action } from "../../../dispatcher/actions";
import { SettingLevel } from "../../../settings/SettingLevel"; import { SettingLevel } from "../../../settings/SettingLevel";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import { Room } from "matrix-js-sdk/src/models/room";
import SettingsFlag from "../elements/SettingsFlag";
interface IProps {
room: Room;
}
@replaceableComponent("views.room_settings.UrlPreviewSettings") @replaceableComponent("views.room_settings.UrlPreviewSettings")
export default class UrlPreviewSettings extends React.Component { export default class UrlPreviewSettings extends React.Component<IProps> {
static propTypes = { private onClickUserSettings = (e: React.MouseEvent): void => {
room: PropTypes.object,
};
_onClickUserSettings = (e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
dis.fire(Action.ViewUserSettings); dis.fire(Action.ViewUserSettings);
}; };
render() { public render(): JSX.Element {
const SettingsFlag = sdk.getComponent("elements.SettingsFlag");
const roomId = this.props.room.roomId; const roomId = this.props.room.roomId;
const isEncrypted = MatrixClientPeg.get().isRoomEncrypted(roomId); const isEncrypted = MatrixClientPeg.get().isRoomEncrypted(roomId);
@ -54,18 +53,18 @@ export default class UrlPreviewSettings extends React.Component {
if (accountEnabled) { if (accountEnabled) {
previewsForAccount = ( previewsForAccount = (
_t("You have <a>enabled</a> URL previews by default.", {}, { _t("You have <a>enabled</a> URL previews by default.", {}, {
'a': (sub)=><a onClick={this._onClickUserSettings} href=''>{ sub }</a>, 'a': (sub)=><a onClick={this.onClickUserSettings} href=''>{ sub }</a>,
}) })
); );
} else { } else {
previewsForAccount = ( previewsForAccount = (
_t("You have <a>disabled</a> URL previews by default.", {}, { _t("You have <a>disabled</a> URL previews by default.", {}, {
'a': (sub)=><a onClick={this._onClickUserSettings} href=''>{ sub }</a>, 'a': (sub)=><a onClick={this.onClickUserSettings} href=''>{ sub }</a>,
}) })
); );
} }
if (SettingsStore.canSetValue("urlPreviewsEnabled", roomId, "room")) { if (SettingsStore.canSetValue("urlPreviewsEnabled", roomId, SettingLevel.ROOM)) {
previewsForRoom = ( previewsForRoom = (
<label> <label>
<SettingsFlag <SettingsFlag

View file

@ -16,7 +16,6 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames'; import classNames from 'classnames';
import { Resizable } from "re-resizable"; import { Resizable } from "re-resizable";
@ -26,8 +25,6 @@ import * as sdk from '../../../index';
import * as ScalarMessaging from '../../../ScalarMessaging'; import * as ScalarMessaging from '../../../ScalarMessaging';
import WidgetUtils from '../../../utils/WidgetUtils'; import WidgetUtils from '../../../utils/WidgetUtils';
import WidgetEchoStore from "../../../stores/WidgetEchoStore"; import WidgetEchoStore from "../../../stores/WidgetEchoStore";
import { IntegrationManagers } from "../../../integrations/IntegrationManagers";
import SettingsStore from "../../../settings/SettingsStore";
import ResizeNotifier from "../../../utils/ResizeNotifier"; import ResizeNotifier from "../../../utils/ResizeNotifier";
import ResizeHandle from "../elements/ResizeHandle"; import ResizeHandle from "../elements/ResizeHandle";
import Resizer from "../../../resizer/resizer"; import Resizer from "../../../resizer/resizer";
@ -37,60 +34,74 @@ import { clamp, percentageOf, percentageWithin } from "../../../utils/numbers";
import { useStateCallback } from "../../../hooks/useStateCallback"; import { useStateCallback } from "../../../hooks/useStateCallback";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import UIStore from "../../../stores/UIStore"; import UIStore from "../../../stores/UIStore";
import { Room } from "matrix-js-sdk/src/models/room";
import { IApp } from "../../../stores/WidgetStore";
import { ActionPayload } from "../../../dispatcher/payloads";
interface IProps {
userId: string;
room: Room;
resizeNotifier: ResizeNotifier;
showApps?: boolean; // Should apps be rendered
maxHeight: number;
}
interface IState {
apps: IApp[];
resizingVertical: boolean; // true when changing the height of the apps drawer
resizingHorizontal: boolean; // true when chagning the distribution of the width between widgets
resizing: boolean;
}
@replaceableComponent("views.rooms.AppsDrawer") @replaceableComponent("views.rooms.AppsDrawer")
export default class AppsDrawer extends React.Component { export default class AppsDrawer extends React.Component<IProps, IState> {
static propTypes = { private resizeContainer: HTMLDivElement;
userId: PropTypes.string.isRequired, private resizer: Resizer;
room: PropTypes.object.isRequired, private dispatcherRef: string;
resizeNotifier: PropTypes.instanceOf(ResizeNotifier).isRequired, public static defaultProps: Partial<IProps> = {
showApps: PropTypes.bool, // Should apps be rendered
};
static defaultProps = {
showApps: true, showApps: true,
}; };
constructor(props) { constructor(props: IProps) {
super(props); super(props);
this.state = { this.state = {
apps: this._getApps(), apps: this.getApps(),
resizingVertical: false, // true when changing the height of the apps drawer resizingVertical: false,
resizingHorizontal: false, // true when chagning the distribution of the width between widgets resizingHorizontal: false,
resizing: false,
}; };
this._resizeContainer = null; this.resizer = this.createResizer();
this.resizer = this._createResizer();
this.props.resizeNotifier.on("isResizing", this.onIsResizing); this.props.resizeNotifier.on("isResizing", this.onIsResizing);
} }
componentDidMount() { public componentDidMount(): void {
ScalarMessaging.startListening(); ScalarMessaging.startListening();
WidgetLayoutStore.instance.on(WidgetLayoutStore.emissionForRoom(this.props.room), this._updateApps); WidgetLayoutStore.instance.on(WidgetLayoutStore.emissionForRoom(this.props.room), this.updateApps);
this.dispatcherRef = dis.register(this.onAction); this.dispatcherRef = dis.register(this.onAction);
} }
componentWillUnmount() { public componentWillUnmount(): void {
ScalarMessaging.stopListening(); ScalarMessaging.stopListening();
WidgetLayoutStore.instance.off(WidgetLayoutStore.emissionForRoom(this.props.room), this._updateApps); WidgetLayoutStore.instance.off(WidgetLayoutStore.emissionForRoom(this.props.room), this.updateApps);
if (this.dispatcherRef) dis.unregister(this.dispatcherRef); if (this.dispatcherRef) dis.unregister(this.dispatcherRef);
if (this._resizeContainer) { if (this.resizeContainer) {
this.resizer.detach(); this.resizer.detach();
} }
this.props.resizeNotifier.off("isResizing", this.onIsResizing); this.props.resizeNotifier.off("isResizing", this.onIsResizing);
} }
onIsResizing = (resizing) => { private onIsResizing = (resizing: boolean): void => {
// This one is the vertical, ie. change height of apps drawer // This one is the vertical, ie. change height of apps drawer
this.setState({ resizingVertical: resizing }); this.setState({ resizingVertical: resizing });
if (!resizing) { if (!resizing) {
this._relaxResizer(); this.relaxResizer();
} }
}; };
_createResizer() { private createResizer(): Resizer {
// This is the horizontal one, changing the distribution of the width between the app tiles // This is the horizontal one, changing the distribution of the width between the app tiles
// (ie. a vertical resize handle because, the handle itself is vertical...) // (ie. a vertical resize handle because, the handle itself is vertical...)
const classNames = { const classNames = {
@ -100,11 +111,11 @@ export default class AppsDrawer extends React.Component {
}; };
const collapseConfig = { const collapseConfig = {
onResizeStart: () => { onResizeStart: () => {
this._resizeContainer.classList.add("mx_AppsDrawer_resizing"); this.resizeContainer.classList.add("mx_AppsDrawer_resizing");
this.setState({ resizingHorizontal: true }); this.setState({ resizingHorizontal: true });
}, },
onResizeStop: () => { onResizeStop: () => {
this._resizeContainer.classList.remove("mx_AppsDrawer_resizing"); this.resizeContainer.classList.remove("mx_AppsDrawer_resizing");
WidgetLayoutStore.instance.setResizerDistributions( WidgetLayoutStore.instance.setResizerDistributions(
this.props.room, Container.Top, this.props.room, Container.Top,
this.state.apps.slice(1).map((_, i) => this.resizer.forHandleAt(i).size), this.state.apps.slice(1).map((_, i) => this.resizer.forHandleAt(i).size),
@ -113,13 +124,13 @@ export default class AppsDrawer extends React.Component {
}, },
}; };
// pass a truthy container for now, we won't call attach until we update it // pass a truthy container for now, we won't call attach until we update it
const resizer = new Resizer({}, PercentageDistributor, collapseConfig); const resizer = new Resizer(null, PercentageDistributor, collapseConfig);
resizer.setClassNames(classNames); resizer.setClassNames(classNames);
return resizer; return resizer;
} }
_collectResizer = (ref) => { private collectResizer = (ref: HTMLDivElement): void => {
if (this._resizeContainer) { if (this.resizeContainer) {
this.resizer.detach(); this.resizer.detach();
} }
@ -127,22 +138,22 @@ export default class AppsDrawer extends React.Component {
this.resizer.container = ref; this.resizer.container = ref;
this.resizer.attach(); this.resizer.attach();
} }
this._resizeContainer = ref; this.resizeContainer = ref;
this._loadResizerPreferences(); this.loadResizerPreferences();
}; };
_getAppsHash = (apps) => apps.map(app => app.id).join("~"); private getAppsHash = (apps: IApp[]): string => apps.map(app => app.id).join("~");
componentDidUpdate(prevProps, prevState) { public componentDidUpdate(prevProps: IProps, prevState: IState): void {
if (prevProps.userId !== this.props.userId || prevProps.room !== this.props.room) { if (prevProps.userId !== this.props.userId || prevProps.room !== this.props.room) {
// Room has changed, update apps // Room has changed, update apps
this._updateApps(); this.updateApps();
} else if (this._getAppsHash(this.state.apps) !== this._getAppsHash(prevState.apps)) { } else if (this.getAppsHash(this.state.apps) !== this.getAppsHash(prevState.apps)) {
this._loadResizerPreferences(); this.loadResizerPreferences();
} }
} }
_relaxResizer = () => { private relaxResizer = (): void => {
const distributors = this.resizer.getDistributors(); const distributors = this.resizer.getDistributors();
// relax all items if they had any overconstrained flexboxes // relax all items if they had any overconstrained flexboxes
@ -150,7 +161,7 @@ export default class AppsDrawer extends React.Component {
distributors.forEach(d => d.finish()); distributors.forEach(d => d.finish());
}; };
_loadResizerPreferences = () => { private loadResizerPreferences = (): void => {
const distributions = WidgetLayoutStore.instance.getResizerDistributions(this.props.room, Container.Top); const distributions = WidgetLayoutStore.instance.getResizerDistributions(this.props.room, Container.Top);
if (this.state.apps && (this.state.apps.length - 1) === distributions.length) { if (this.state.apps && (this.state.apps.length - 1) === distributions.length) {
distributions.forEach((size, i) => { distributions.forEach((size, i) => {
@ -168,11 +179,11 @@ export default class AppsDrawer extends React.Component {
} }
}; };
isResizing() { private isResizing(): boolean {
return this.state.resizingVertical || this.state.resizingHorizontal; return this.state.resizingVertical || this.state.resizingHorizontal;
} }
onAction = (action) => { private onAction = (action: ActionPayload): void => {
const hideWidgetKey = this.props.room.roomId + '_hide_widget_drawer'; const hideWidgetKey = this.props.room.roomId + '_hide_widget_drawer';
switch (action.action) { switch (action.action) {
case 'appsDrawer': case 'appsDrawer':
@ -190,23 +201,15 @@ export default class AppsDrawer extends React.Component {
} }
}; };
_getApps = () => WidgetLayoutStore.instance.getContainerWidgets(this.props.room, Container.Top); private getApps = (): IApp[] => WidgetLayoutStore.instance.getContainerWidgets(this.props.room, Container.Top);
_updateApps = () => { private updateApps = (): void => {
this.setState({ this.setState({
apps: this._getApps(), apps: this.getApps(),
}); });
}; };
_launchManageIntegrations() { public render(): JSX.Element {
if (SettingsStore.getValue("feature_many_integration_managers")) {
IntegrationManagers.sharedInstance().openAll();
} else {
IntegrationManagers.sharedInstance().getPrimaryManager().open(this.props.room, 'add_integ');
}
}
render() {
if (!this.props.showApps) return <div />; if (!this.props.showApps) return <div />;
const apps = this.state.apps.map((app, index, arr) => { const apps = this.state.apps.map((app, index, arr) => {
@ -257,7 +260,7 @@ export default class AppsDrawer extends React.Component {
className="mx_AppsContainer_resizer" className="mx_AppsContainer_resizer"
resizeNotifier={this.props.resizeNotifier} resizeNotifier={this.props.resizeNotifier}
> >
<div className="mx_AppsContainer" ref={this._collectResizer}> <div className="mx_AppsContainer" ref={this.collectResizer}>
{ apps.map((app, i) => { { apps.map((app, i) => {
if (i < 1) return app; if (i < 1) return app;
return <React.Fragment key={app.key}> return <React.Fragment key={app.key}>
@ -273,7 +276,18 @@ export default class AppsDrawer extends React.Component {
} }
} }
const PersistentVResizer = ({ interface IPersistentResizerProps {
room: Room;
minHeight: number;
maxHeight: number;
className: string;
handleWrapperClass: string;
handleClass: string;
resizeNotifier: ResizeNotifier;
children: React.ReactNode;
}
const PersistentVResizer: React.FC<IPersistentResizerProps> = ({
room, room,
minHeight, minHeight,
maxHeight, maxHeight,
@ -303,7 +317,7 @@ const PersistentVResizer = ({
}); });
return <Resizable return <Resizable
size={{ height: Math.min(height, maxHeight) }} size={{ height: Math.min(height, maxHeight), width: null }}
minHeight={minHeight} minHeight={minHeight}
maxHeight={maxHeight} maxHeight={maxHeight}
onResizeStart={() => { onResizeStart={() => {

View file

@ -50,7 +50,8 @@ import { AutocompleteAction, getKeyBindingsManager, MessageComposerAction } from
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
// matches emoticons which follow the start of a line or whitespace // matches emoticons which follow the start of a line or whitespace
const REGEX_EMOTICON_WHITESPACE = new RegExp('(?:^|\\s)(' + EMOTICON_REGEX.source + ')\\s$'); const REGEX_EMOTICON_WHITESPACE = new RegExp('(?:^|\\s)(' + EMOTICON_REGEX.source + ')\\s|:^$');
export const REGEX_EMOTICON = new RegExp('(?:^|\\s)(' + EMOTICON_REGEX.source + ')$');
const IS_MAC = navigator.platform.indexOf("Mac") !== -1; const IS_MAC = navigator.platform.indexOf("Mac") !== -1;
@ -161,7 +162,7 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
} }
} }
private replaceEmoticon = (caretPosition: DocumentPosition): number => { public replaceEmoticon(caretPosition: DocumentPosition, regex: RegExp): number {
const { model } = this.props; const { model } = this.props;
const range = model.startRange(caretPosition); const range = model.startRange(caretPosition);
// expand range max 8 characters backwards from caretPosition, // expand range max 8 characters backwards from caretPosition,
@ -170,9 +171,9 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
range.expandBackwardsWhile((index, offset) => { range.expandBackwardsWhile((index, offset) => {
const part = model.parts[index]; const part = model.parts[index];
n -= 1; n -= 1;
return n >= 0 && (part.type === Type.Plain || part.type === Type.PillCandidate); return n >= 0 && [Type.Plain, Type.PillCandidate, Type.Newline].includes(part.type);
}); });
const emoticonMatch = REGEX_EMOTICON_WHITESPACE.exec(range.text); const emoticonMatch = regex.exec(range.text);
if (emoticonMatch) { if (emoticonMatch) {
const query = emoticonMatch[1].replace("-", ""); const query = emoticonMatch[1].replace("-", "");
// try both exact match and lower-case, this means that xd won't match xD but :P will match :p // try both exact match and lower-case, this means that xd won't match xD but :P will match :p
@ -180,18 +181,25 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
if (data) { if (data) {
const { partCreator } = model; const { partCreator } = model;
const hasPrecedingSpace = emoticonMatch[0][0] === " "; const firstMatch = emoticonMatch[0];
const moveStart = firstMatch[0] === " " ? 1 : 0;
// we need the range to only comprise of the emoticon // we need the range to only comprise of the emoticon
// because we'll replace the whole range with an emoji, // because we'll replace the whole range with an emoji,
// so move the start forward to the start of the emoticon. // so move the start forward to the start of the emoticon.
// Take + 1 because index is reported without the possible preceding space. // Take + 1 because index is reported without the possible preceding space.
range.moveStart(emoticonMatch.index + (hasPrecedingSpace ? 1 : 0)); range.moveStartForwards(emoticonMatch.index + moveStart);
// If the end is a trailing space/newline move end backwards, so that we don't replace it
if (["\n", " "].includes(firstMatch[firstMatch.length - 1])) {
range.moveEndBackwards(1);
}
// this returns the amount of added/removed characters during the replace // this returns the amount of added/removed characters during the replace
// so the caret position can be adjusted. // so the caret position can be adjusted.
return range.replace([partCreator.plain(data.unicode + " ")]); return range.replace([partCreator.plain(data.unicode)]);
}
} }
} }
};
private updateEditorState = (selection: Caret, inputType?: string, diff?: IDiff): void => { private updateEditorState = (selection: Caret, inputType?: string, diff?: IDiff): void => {
renderModel(this.editorRef.current, this.props.model); renderModel(this.editorRef.current, this.props.model);
@ -607,8 +615,7 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
}; };
private configureEmoticonAutoReplace = (): void => { private configureEmoticonAutoReplace = (): void => {
const shouldReplace = SettingsStore.getValue('MessageComposerInput.autoReplaceEmoji'); this.props.model.setTransformCallback(this.transform);
this.props.model.setTransformCallback(shouldReplace ? this.replaceEmoticon : null);
}; };
private configureShouldShowPillAvatar = (): void => { private configureShouldShowPillAvatar = (): void => {
@ -621,6 +628,11 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
this.setState({ surroundWith }); this.setState({ surroundWith });
}; };
private transform = (documentPosition: DocumentPosition): void => {
const shouldReplace = SettingsStore.getValue('MessageComposerInput.autoReplaceEmoji');
if (shouldReplace) this.replaceEmoticon(documentPosition, REGEX_EMOTICON_WHITESPACE);
};
componentWillUnmount() { componentWillUnmount() {
document.removeEventListener("selectionchange", this.onSelectionChange); document.removeEventListener("selectionchange", this.onSelectionChange);
this.editorRef.current.removeEventListener("input", this.onInput, true); this.editorRef.current.removeEventListener("input", this.onInput, true);

View file

@ -16,41 +16,51 @@ limitations under the License.
*/ */
import React, { useState } from "react"; import React, { useState } from "react";
import PropTypes from "prop-types";
import classNames from 'classnames'; import classNames from 'classnames';
import { _t, _td } from '../../../languageHandler'; import { _t, _td } from '../../../languageHandler';
import AccessibleButton from "../elements/AccessibleButton"; import AccessibleButton from "../elements/AccessibleButton";
import Tooltip from "../elements/Tooltip"; import Tooltip from "../elements/Tooltip";
import { E2EStatus } from "../../../utils/ShieldUtils";
export const E2E_STATE = { export enum E2EState {
VERIFIED: "verified", Verified = "verified",
WARNING: "warning", Warning = "warning",
UNKNOWN: "unknown", Unknown = "unknown",
NORMAL: "normal", Normal = "normal",
UNAUTHENTICATED: "unauthenticated", Unauthenticated = "unauthenticated",
}
const crossSigningUserTitles: { [key in E2EState]?: string } = {
[E2EState.Warning]: _td("This user has not verified all of their sessions."),
[E2EState.Normal]: _td("You have not verified this user."),
[E2EState.Verified]: _td("You have verified this user. This user has verified all of their sessions."),
};
const crossSigningRoomTitles: { [key in E2EState]?: string } = {
[E2EState.Warning]: _td("Someone is using an unknown session"),
[E2EState.Normal]: _td("This room is end-to-end encrypted"),
[E2EState.Verified]: _td("Everyone in this room is verified"),
}; };
const crossSigningUserTitles = { interface IProps {
[E2E_STATE.WARNING]: _td("This user has not verified all of their sessions."), isUser?: boolean;
[E2E_STATE.NORMAL]: _td("You have not verified this user."), status?: E2EState | E2EStatus;
[E2E_STATE.VERIFIED]: _td("You have verified this user. This user has verified all of their sessions."), className?: string;
}; size?: number;
const crossSigningRoomTitles = { onClick?: () => void;
[E2E_STATE.WARNING]: _td("Someone is using an unknown session"), hideTooltip?: boolean;
[E2E_STATE.NORMAL]: _td("This room is end-to-end encrypted"), bordered?: boolean;
[E2E_STATE.VERIFIED]: _td("Everyone in this room is verified"), }
};
const E2EIcon = ({ isUser, status, className, size, onClick, hideTooltip, bordered }) => { const E2EIcon: React.FC<IProps> = ({ isUser, status, className, size, onClick, hideTooltip, bordered }) => {
const [hover, setHover] = useState(false); const [hover, setHover] = useState(false);
const classes = classNames({ const classes = classNames({
mx_E2EIcon: true, mx_E2EIcon: true,
mx_E2EIcon_bordered: bordered, mx_E2EIcon_bordered: bordered,
mx_E2EIcon_warning: status === E2E_STATE.WARNING, mx_E2EIcon_warning: status === E2EState.Warning,
mx_E2EIcon_normal: status === E2E_STATE.NORMAL, mx_E2EIcon_normal: status === E2EState.Normal,
mx_E2EIcon_verified: status === E2E_STATE.VERIFIED, mx_E2EIcon_verified: status === E2EState.Verified,
}, className); }, className);
let e2eTitle; let e2eTitle;
@ -92,12 +102,4 @@ const E2EIcon = ({ isUser, status, className, size, onClick, hideTooltip, border
</div>; </div>;
}; };
E2EIcon.propTypes = {
isUser: PropTypes.bool,
status: PropTypes.oneOf(Object.values(E2E_STATE)),
className: PropTypes.string,
size: PropTypes.number,
onClick: PropTypes.func,
};
export default E2EIcon; export default E2EIcon;

View file

@ -27,7 +27,7 @@ import { findEditableEvent } from '../../../utils/EventUtils';
import { parseEvent } from '../../../editor/deserialize'; import { parseEvent } from '../../../editor/deserialize';
import { CommandPartCreator, Part, PartCreator, Type } from '../../../editor/parts'; import { CommandPartCreator, Part, PartCreator, Type } from '../../../editor/parts';
import EditorStateTransfer from '../../../utils/EditorStateTransfer'; import EditorStateTransfer from '../../../utils/EditorStateTransfer';
import BasicMessageComposer from "./BasicMessageComposer"; import BasicMessageComposer, { REGEX_EMOTICON } from "./BasicMessageComposer";
import MatrixClientContext from "../../../contexts/MatrixClientContext"; import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { Command, CommandCategories, getCommand } from '../../../SlashCommands'; import { Command, CommandCategories, getCommand } from '../../../SlashCommands';
import { Action } from "../../../dispatcher/actions"; import { Action } from "../../../dispatcher/actions";
@ -42,6 +42,7 @@ import ErrorDialog from "../dialogs/ErrorDialog";
import QuestionDialog from "../dialogs/QuestionDialog"; import QuestionDialog from "../dialogs/QuestionDialog";
import { ActionPayload } from "../../../dispatcher/payloads"; import { ActionPayload } from "../../../dispatcher/payloads";
import AccessibleButton from '../elements/AccessibleButton'; import AccessibleButton from '../elements/AccessibleButton';
import SettingsStore from "../../../settings/SettingsStore";
function getHtmlReplyFallback(mxEvent: MatrixEvent): string { function getHtmlReplyFallback(mxEvent: MatrixEvent): string {
const html = mxEvent.getContent().formatted_body; const html = mxEvent.getContent().formatted_body;
@ -315,6 +316,14 @@ export default class EditMessageComposer extends React.Component<IProps, IState>
private sendEdit = async (): Promise<void> => { private sendEdit = async (): Promise<void> => {
const startTime = CountlyAnalytics.getTimestamp(); const startTime = CountlyAnalytics.getTimestamp();
const editedEvent = this.props.editState.getEvent(); const editedEvent = this.props.editState.getEvent();
// Replace emoticon at the end of the message
if (SettingsStore.getValue('MessageComposerInput.autoReplaceEmoji')) {
const caret = this.editorRef.current?.getCaret();
const position = this.model.positionForOffset(caret.offset, caret.atNodeEnd);
this.editorRef.current?.replaceEmoticon(position, REGEX_EMOTICON);
}
const editContent = createEditContent(this.model, editedEvent); const editContent = createEditContent(this.model, editedEvent);
const newContent = editContent["m.new_content"]; const newContent = editContent["m.new_content"];

View file

@ -20,7 +20,7 @@ import React from 'react';
import AccessibleButton from '../elements/AccessibleButton'; import AccessibleButton from '../elements/AccessibleButton';
import { _td } from '../../../languageHandler'; import { _td } from '../../../languageHandler';
import classNames from "classnames"; import classNames from "classnames";
import E2EIcon from './E2EIcon'; import E2EIcon, { E2EState } from './E2EIcon';
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import BaseAvatar from '../avatars/BaseAvatar'; import BaseAvatar from '../avatars/BaseAvatar';
import PresenceLabel from "./PresenceLabel"; import PresenceLabel from "./PresenceLabel";
@ -75,7 +75,7 @@ interface IProps {
suppressOnHover?: boolean; suppressOnHover?: boolean;
showPresence?: boolean; showPresence?: boolean;
subtextLabel?: string; subtextLabel?: string;
e2eStatus?: string; e2eStatus?: E2EState;
powerStatus?: PowerStatus; powerStatus?: PowerStatus;
} }

View file

@ -21,7 +21,7 @@ import { EventType } from "matrix-js-sdk/src/@types/event";
import { EventStatus, MatrixEvent } from "matrix-js-sdk/src/models/event"; import { EventStatus, MatrixEvent } from "matrix-js-sdk/src/models/event";
import { Relations } from "matrix-js-sdk/src/models/relations"; import { Relations } from "matrix-js-sdk/src/models/relations";
import { RoomMember } from "matrix-js-sdk/src/models/room-member"; import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { Thread } from 'matrix-js-sdk/src/models/thread'; import { Thread, ThreadEvent } from 'matrix-js-sdk/src/models/thread';
import ReplyThread from "../elements/ReplyThread"; import ReplyThread from "../elements/ReplyThread";
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
@ -33,7 +33,7 @@ import { formatTime } from "../../../DateUtils";
import { MatrixClientPeg } from '../../../MatrixClientPeg'; import { MatrixClientPeg } from '../../../MatrixClientPeg';
import { ALL_RULE_TYPES } from "../../../mjolnir/BanList"; import { ALL_RULE_TYPES } from "../../../mjolnir/BanList";
import MatrixClientContext from "../../../contexts/MatrixClientContext"; import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { E2E_STATE } from "./E2EIcon"; import { E2EState } from "./E2EIcon";
import { toRem } from "../../../utils/units"; import { toRem } from "../../../utils/units";
import { WidgetType } from "../../../widgets/WidgetType"; import { WidgetType } from "../../../widgets/WidgetType";
import RoomAvatar from "../avatars/RoomAvatar"; import RoomAvatar from "../avatars/RoomAvatar";
@ -464,8 +464,8 @@ export default class EventTile extends React.Component<IProps, IState> {
} }
if (SettingsStore.getValue("feature_thread")) { if (SettingsStore.getValue("feature_thread")) {
this.props.mxEvent.once("Thread.ready", this.updateThread); this.props.mxEvent.once(ThreadEvent.Ready, this.updateThread);
this.props.mxEvent.on("Thread.update", this.updateThread); this.props.mxEvent.on(ThreadEvent.Update, this.updateThread);
} }
} }
@ -521,7 +521,7 @@ export default class EventTile extends React.Component<IProps, IState> {
const thread = this.state.thread; const thread = this.state.thread;
const room = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId()); const room = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId());
if (!thread || this.props.showThreadInfo === false) { if (!thread || this.props.showThreadInfo === false || thread.length <= 1) {
return null; return null;
} }
@ -605,7 +605,7 @@ export default class EventTile extends React.Component<IProps, IState> {
if (encryptionInfo.mismatchedSender) { if (encryptionInfo.mismatchedSender) {
// something definitely wrong is going on here // something definitely wrong is going on here
this.setState({ this.setState({
verified: E2E_STATE.WARNING, verified: E2EState.Warning,
}, this.props.onHeightChanged); // Decryption may have caused a change in size }, this.props.onHeightChanged); // Decryption may have caused a change in size
return; return;
} }
@ -613,7 +613,7 @@ export default class EventTile extends React.Component<IProps, IState> {
if (!userTrust.isCrossSigningVerified()) { if (!userTrust.isCrossSigningVerified()) {
// user is not verified, so default to everything is normal // user is not verified, so default to everything is normal
this.setState({ this.setState({
verified: E2E_STATE.NORMAL, verified: E2EState.Normal,
}, this.props.onHeightChanged); // Decryption may have caused a change in size }, this.props.onHeightChanged); // Decryption may have caused a change in size
return; return;
} }
@ -623,27 +623,27 @@ export default class EventTile extends React.Component<IProps, IState> {
); );
if (!eventSenderTrust) { if (!eventSenderTrust) {
this.setState({ this.setState({
verified: E2E_STATE.UNKNOWN, verified: E2EState.Unknown,
}, this.props.onHeightChanged); // Decryption may have caused a change in size }, this.props.onHeightChanged); // Decryption may have caused a change in size
return; return;
} }
if (!eventSenderTrust.isVerified()) { if (!eventSenderTrust.isVerified()) {
this.setState({ this.setState({
verified: E2E_STATE.WARNING, verified: E2EState.Warning,
}, this.props.onHeightChanged); // Decryption may have caused a change in size }, this.props.onHeightChanged); // Decryption may have caused a change in size
return; return;
} }
if (!encryptionInfo.authenticated) { if (!encryptionInfo.authenticated) {
this.setState({ this.setState({
verified: E2E_STATE.UNAUTHENTICATED, verified: E2EState.Unauthenticated,
}, this.props.onHeightChanged); // Decryption may have caused a change in size }, this.props.onHeightChanged); // Decryption may have caused a change in size
return; return;
} }
this.setState({ this.setState({
verified: E2E_STATE.VERIFIED, verified: E2EState.Verified,
}, this.props.onHeightChanged); // Decryption may have caused a change in size }, this.props.onHeightChanged); // Decryption may have caused a change in size
} }
@ -850,13 +850,13 @@ export default class EventTile extends React.Component<IProps, IState> {
// event is encrypted, display padlock corresponding to whether or not it is verified // event is encrypted, display padlock corresponding to whether or not it is verified
if (ev.isEncrypted()) { if (ev.isEncrypted()) {
if (this.state.verified === E2E_STATE.NORMAL) { if (this.state.verified === E2EState.Normal) {
return; // no icon if we've not even cross-signed the user return; // no icon if we've not even cross-signed the user
} else if (this.state.verified === E2E_STATE.VERIFIED) { } else if (this.state.verified === E2EState.Verified) {
return; // no icon for verified return; // no icon for verified
} else if (this.state.verified === E2E_STATE.UNAUTHENTICATED) { } else if (this.state.verified === E2EState.Unauthenticated) {
return (<E2ePadlockUnauthenticated />); return (<E2ePadlockUnauthenticated />);
} else if (this.state.verified === E2E_STATE.UNKNOWN) { } else if (this.state.verified === E2EState.Unknown) {
return (<E2ePadlockUnknown />); return (<E2ePadlockUnknown />);
} else { } else {
return (<E2ePadlockUnverified />); return (<E2ePadlockUnverified />);
@ -961,9 +961,9 @@ export default class EventTile extends React.Component<IProps, IState> {
mx_EventTile_lastInSection: this.props.lastInSection, mx_EventTile_lastInSection: this.props.lastInSection,
mx_EventTile_contextual: this.props.contextual, mx_EventTile_contextual: this.props.contextual,
mx_EventTile_actionBarFocused: this.state.actionBarFocused, mx_EventTile_actionBarFocused: this.state.actionBarFocused,
mx_EventTile_verified: !isBubbleMessage && this.state.verified === E2E_STATE.VERIFIED, mx_EventTile_verified: !isBubbleMessage && this.state.verified === E2EState.Verified,
mx_EventTile_unverified: !isBubbleMessage && this.state.verified === E2E_STATE.WARNING, mx_EventTile_unverified: !isBubbleMessage && this.state.verified === E2EState.Warning,
mx_EventTile_unknown: !isBubbleMessage && this.state.verified === E2E_STATE.UNKNOWN, mx_EventTile_unknown: !isBubbleMessage && this.state.verified === E2EState.Unknown,
mx_EventTile_bad: isEncryptionFailure, mx_EventTile_bad: isEncryptionFailure,
mx_EventTile_emote: msgtype === 'm.emote', mx_EventTile_emote: msgtype === 'm.emote',
mx_EventTile_noSender: this.props.hideSender, mx_EventTile_noSender: this.props.hideSender,
@ -1192,7 +1192,11 @@ export default class EventTile extends React.Component<IProps, IState> {
} }
default: { default: {
const thread = ReplyThread.makeThread( let thread;
// When the "showHiddenEventsInTimeline" lab is enabled,
// avoid showing replies for hidden events (events without tiles)
if (haveTileForEvent(this.props.mxEvent)) {
thread = ReplyThread.makeThread(
this.props.mxEvent, this.props.mxEvent,
this.props.onHeightChanged, this.props.onHeightChanged,
this.props.permalinkCreator, this.props.permalinkCreator,
@ -1200,6 +1204,7 @@ export default class EventTile extends React.Component<IProps, IState> {
this.props.layout, this.props.layout,
this.props.alwaysShowTimestamps || this.state.hover, this.props.alwaysShowTimestamps || this.state.hover,
); );
}
const isOwnEvent = this.props.mxEvent?.sender?.userId === MatrixClientPeg.get().getUserId(); const isOwnEvent = this.props.mxEvent?.sender?.userId === MatrixClientPeg.get().getUserId();

View file

@ -32,6 +32,7 @@ import {
ContextMenu, ContextMenu,
useContextMenu, useContextMenu,
MenuItem, MenuItem,
AboveLeftOf,
} from "../../structures/ContextMenu"; } from "../../structures/ContextMenu";
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import ReplyPreview from "./ReplyPreview"; import ReplyPreview from "./ReplyPreview";
@ -56,7 +57,7 @@ let instanceCount = 0;
const NARROW_MODE_BREAKPOINT = 500; const NARROW_MODE_BREAKPOINT = 500;
interface IComposerAvatarProps { interface IComposerAvatarProps {
me: object; me: RoomMember;
} }
function ComposerAvatar(props: IComposerAvatarProps) { function ComposerAvatar(props: IComposerAvatarProps) {
@ -511,7 +512,7 @@ export default class MessageComposer extends React.Component<IProps, IState> {
null, null,
]; ];
let menuPosition; let menuPosition: AboveLeftOf | undefined;
if (this.ref.current) { if (this.ref.current) {
const contentRect = this.ref.current.getBoundingClientRect(); const contentRect = this.ref.current.getBoundingClientRect();
menuPosition = aboveLeftOf(contentRect); menuPosition = aboveLeftOf(contentRect);

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React from "react"; import React, { MouseEvent } from "react";
import classNames from "classnames"; import classNames from "classnames";
import { formatCount } from "../../../utils/FormattingUtils"; import { formatCount } from "../../../utils/FormattingUtils";
import SettingsStore from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
@ -22,6 +22,9 @@ import AccessibleButton from "../elements/AccessibleButton";
import { XOR } from "../../../@types/common"; import { XOR } from "../../../@types/common";
import { NOTIFICATION_STATE_UPDATE, NotificationState } from "../../../stores/notifications/NotificationState"; import { NOTIFICATION_STATE_UPDATE, NotificationState } from "../../../stores/notifications/NotificationState";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import Tooltip from "../elements/Tooltip";
import { _t } from "../../../languageHandler";
import { NotificationColor } from "../../../stores/notifications/NotificationColor";
interface IProps { interface IProps {
notification: NotificationState; notification: NotificationState;
@ -39,6 +42,7 @@ interface IProps {
} }
interface IClickableProps extends IProps, React.InputHTMLAttributes<Element> { interface IClickableProps extends IProps, React.InputHTMLAttributes<Element> {
showUnsentTooltip?: boolean;
/** /**
* If specified will return an AccessibleButton instead of a div. * If specified will return an AccessibleButton instead of a div.
*/ */
@ -47,6 +51,7 @@ interface IClickableProps extends IProps, React.InputHTMLAttributes<Element> {
interface IState { interface IState {
showCounts: boolean; // whether or not to show counts. Independent of props.forceCount showCounts: boolean; // whether or not to show counts. Independent of props.forceCount
showTooltip: boolean;
} }
@replaceableComponent("views.rooms.NotificationBadge") @replaceableComponent("views.rooms.NotificationBadge")
@ -59,6 +64,7 @@ export default class NotificationBadge extends React.PureComponent<XOR<IProps, I
this.state = { this.state = {
showCounts: SettingsStore.getValue("Notifications.alwaysShowBadgeCounts", this.roomId), showCounts: SettingsStore.getValue("Notifications.alwaysShowBadgeCounts", this.roomId),
showTooltip: false,
}; };
this.countWatcherRef = SettingsStore.watchSetting( this.countWatcherRef = SettingsStore.watchSetting(
@ -93,9 +99,22 @@ export default class NotificationBadge extends React.PureComponent<XOR<IProps, I
this.forceUpdate(); // notification state changed - update this.forceUpdate(); // notification state changed - update
}; };
private onMouseOver = (e: MouseEvent) => {
e.stopPropagation();
this.setState({
showTooltip: true,
});
};
private onMouseLeave = () => {
this.setState({
showTooltip: false,
});
};
public render(): React.ReactElement { public render(): React.ReactElement {
/* eslint @typescript-eslint/no-unused-vars: ["error", { "ignoreRestSiblings": true }] */ /* eslint @typescript-eslint/no-unused-vars: ["error", { "ignoreRestSiblings": true }] */
const { notification, forceCount, roomId, onClick, ...props } = this.props; const { notification, showUnsentTooltip, forceCount, roomId, onClick, ...props } = this.props;
// Don't show a badge if we don't need to // Don't show a badge if we don't need to
if (notification.isIdle) return null; if (notification.isIdle) return null;
@ -124,9 +143,24 @@ export default class NotificationBadge extends React.PureComponent<XOR<IProps, I
}); });
if (onClick) { if (onClick) {
let label: string;
let tooltip: JSX.Element;
if (showUnsentTooltip && this.state.showTooltip && notification.color === NotificationColor.Unsent) {
label = _t("Message didn't send. Click for info.");
tooltip = <Tooltip className="mx_RoleButton_tooltip" label={label} />;
}
return ( return (
<AccessibleButton {...props} className={classes} onClick={onClick}> <AccessibleButton
aria-label={label}
{...props}
className={classes}
onClick={onClick}
onMouseOver={this.onMouseOver}
onMouseLeave={this.onMouseLeave}
>
<span className="mx_NotificationBadge_count">{ symbol }</span> <span className="mx_NotificationBadge_count">{ symbol }</span>
{ tooltip }
</AccessibleButton> </AccessibleButton>
); );
} }

View file

@ -547,7 +547,7 @@ export default class RoomList extends React.PureComponent<IProps, IState> {
const unfilteredHistorical = unfilteredLists[DefaultTagID.Archived] || []; const unfilteredHistorical = unfilteredLists[DefaultTagID.Archived] || [];
const unfilteredFavourite = unfilteredLists[DefaultTagID.Favourite] || []; const unfilteredFavourite = unfilteredLists[DefaultTagID.Favourite] || [];
// show a prompt to join/create rooms if the user is in 0 rooms and no historical // show a prompt to join/create rooms if the user is in 0 rooms and no historical
if (unfilteredRooms.length < 1 && unfilteredHistorical < 1 && unfilteredFavourite < 1) { if (unfilteredRooms.length < 1 && unfilteredHistorical.length < 1 && unfilteredFavourite.length < 1) {
explorePrompt = <div className="mx_RoomList_explorePrompt"> explorePrompt = <div className="mx_RoomList_explorePrompt">
<div>{ _t("Use the + to make a new room or explore existing ones below") }</div> <div>{ _t("Use the + to make a new room or explore existing ones below") }</div>
<AccessibleButton <AccessibleButton

View file

@ -14,8 +14,13 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React from 'react'; import React from "react";
import PropTypes from 'prop-types'; import { Room } from "matrix-js-sdk/src/models/room";
import { MatrixError } from "matrix-js-sdk/src/http-api";
import { EventType, RoomType } from "matrix-js-sdk/src/@types/event";
import { IJoinRuleEventContent, JoinRule } from "matrix-js-sdk/src/@types/partials";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import * as sdk from '../../../index'; import * as sdk from '../../../index';
import { MatrixClientPeg } from '../../../MatrixClientPeg'; import { MatrixClientPeg } from '../../../MatrixClientPeg';
import dis from '../../../dispatcher/dispatcher'; import dis from '../../../dispatcher/dispatcher';
@ -27,91 +32,102 @@ import { CommunityPrototypeStore } from "../../../stores/CommunityPrototypeStore
import { UPDATE_EVENT } from "../../../stores/AsyncStore"; import { UPDATE_EVENT } from "../../../stores/AsyncStore";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import InviteReason from "../elements/InviteReason"; import InviteReason from "../elements/InviteReason";
import { IOOBData } from "../../../stores/ThreepidInviteStore";
import Spinner from "../elements/Spinner";
import AccessibleButton from "../elements/AccessibleButton";
const MemberEventHtmlReasonField = "io.element.html_reason"; const MemberEventHtmlReasonField = "io.element.html_reason";
const MessageCase = Object.freeze({ enum MessageCase {
NotLoggedIn: "NotLoggedIn", NotLoggedIn = "NotLoggedIn",
Joining: "Joining", Joining = "Joining",
Loading: "Loading", Loading = "Loading",
Rejecting: "Rejecting", Rejecting = "Rejecting",
Kicked: "Kicked", Kicked = "Kicked",
Banned: "Banned", Banned = "Banned",
OtherThreePIDError: "OtherThreePIDError", OtherThreePIDError = "OtherThreePIDError",
InvitedEmailNotFoundInAccount: "InvitedEmailNotFoundInAccount", InvitedEmailNotFoundInAccount = "InvitedEmailNotFoundInAccount",
InvitedEmailNoIdentityServer: "InvitedEmailNoIdentityServer", InvitedEmailNoIdentityServer = "InvitedEmailNoIdentityServer",
InvitedEmailMismatch: "InvitedEmailMismatch", InvitedEmailMismatch = "InvitedEmailMismatch",
Invite: "Invite", Invite = "Invite",
ViewingRoom: "ViewingRoom", ViewingRoom = "ViewingRoom",
RoomNotFound: "RoomNotFound", RoomNotFound = "RoomNotFound",
OtherError: "OtherError", OtherError = "OtherError",
}); }
@replaceableComponent("views.rooms.RoomPreviewBar") interface IProps {
export default class RoomPreviewBar extends React.Component {
static propTypes = {
onJoinClick: PropTypes.func,
onRejectClick: PropTypes.func,
onRejectAndIgnoreClick: PropTypes.func,
onForgetClick: PropTypes.func,
// if inviterName is specified, the preview bar will shown an invite to the room. // if inviterName is specified, the preview bar will shown an invite to the room.
// You should also specify onRejectClick if specifiying inviterName // You should also specify onRejectClick if specifying inviterName
inviterName: PropTypes.string, inviterName?: string;
// If invited by 3rd party invite, the email address the invite was sent to // If invited by 3rd party invite, the email address the invite was sent to
invitedEmail: PropTypes.string, invitedEmail?: string;
// For third party invites, information passed about the room out-of-band // For third party invites, information passed about the room out-of-band
oobData: PropTypes.object, oobData?: IOOBData;
// For third party invites, a URL for a 3pid invite signing service // For third party invites, a URL for a 3pid invite signing service
signUrl: PropTypes.string, signUrl?: string;
// A standard client/server API error object. If supplied, indicates that the // A standard client/server API error object. If supplied, indicates that the
// caller was unable to fetch details about the room for the given reason. // caller was unable to fetch details about the room for the given reason.
error: PropTypes.object, error?: MatrixError;
canPreview: PropTypes.bool, canPreview?: boolean;
previewLoading: PropTypes.bool, previewLoading?: boolean;
room: PropTypes.object, room?: Room;
// When a spinner is present, a spinnerState can be specified to indicate the loading?: boolean;
// purpose of the spinner. joining?: boolean;
spinner: PropTypes.bool, rejecting?: boolean;
spinnerState: PropTypes.oneOf(["joining"]),
loading: PropTypes.bool,
joining: PropTypes.bool,
rejecting: PropTypes.bool,
// The alias that was used to access this room, if appropriate // The alias that was used to access this room, if appropriate
// If given, this will be how the room is referred to (eg. // If given, this will be how the room is referred to (eg.
// in error messages). // in error messages).
roomAlias: PropTypes.string, roomAlias?: string;
};
onJoinClick?(): void;
onRejectClick?(): void;
onRejectAndIgnoreClick?(): void;
onForgetClick?(): void;
}
interface IState {
busy: boolean;
accountEmails?: string[];
invitedEmailMxid?: string;
threePidFetchError?: MatrixError;
}
@replaceableComponent("views.rooms.RoomPreviewBar")
export default class RoomPreviewBar extends React.Component<IProps, IState> {
static defaultProps = { static defaultProps = {
onJoinClick() {}, onJoinClick() {},
}; };
state = { constructor(props) {
super(props);
this.state = {
busy: false, busy: false,
}; };
}
componentDidMount() { componentDidMount() {
this._checkInvitedEmail(); this.checkInvitedEmail();
CommunityPrototypeStore.instance.on(UPDATE_EVENT, this._onCommunityUpdate); CommunityPrototypeStore.instance.on(UPDATE_EVENT, this.onCommunityUpdate);
} }
componentDidUpdate(prevProps, prevState) { componentDidUpdate(prevProps, prevState) {
if (this.props.invitedEmail !== prevProps.invitedEmail || this.props.inviterName !== prevProps.inviterName) { if (this.props.invitedEmail !== prevProps.invitedEmail || this.props.inviterName !== prevProps.inviterName) {
this._checkInvitedEmail(); this.checkInvitedEmail();
} }
} }
componentWillUnmount() { componentWillUnmount() {
CommunityPrototypeStore.instance.off(UPDATE_EVENT, this._onCommunityUpdate); CommunityPrototypeStore.instance.off(UPDATE_EVENT, this.onCommunityUpdate);
} }
async _checkInvitedEmail() { private async checkInvitedEmail() {
// If this is an invite and we've been told what email address was // If this is an invite and we've been told what email address was
// invited, fetch the user's account emails and discovery bindings so we // invited, fetch the user's account emails and discovery bindings so we
// can check them against the email that was invited. // can check them against the email that was invited.
@ -121,8 +137,7 @@ export default class RoomPreviewBar extends React.Component {
// Gather the account 3PIDs // Gather the account 3PIDs
const account3pids = await MatrixClientPeg.get().getThreePids(); const account3pids = await MatrixClientPeg.get().getThreePids();
this.setState({ this.setState({
accountEmails: account3pids.threepids accountEmails: account3pids.threepids.filter(b => b.medium === 'email').map(b => b.address),
.filter(b => b.medium === 'email').map(b => b.address),
}); });
// If we have an IS connected, use that to lookup the email and // If we have an IS connected, use that to lookup the email and
// check the bound MXID. // check the bound MXID.
@ -146,21 +161,21 @@ export default class RoomPreviewBar extends React.Component {
} }
} }
_onCommunityUpdate = (roomId) => { private onCommunityUpdate = (roomId: string): void => {
if (this.props.room && this.props.room.roomId !== roomId) { if (this.props.room && this.props.room.roomId !== roomId) {
return; return;
} }
this.forceUpdate(); // we have nothing to update this.forceUpdate(); // we have nothing to update
}; };
_getMessageCase() { private getMessageCase(): MessageCase {
const isGuest = MatrixClientPeg.get().isGuest(); const isGuest = MatrixClientPeg.get().isGuest();
if (isGuest) { if (isGuest) {
return MessageCase.NotLoggedIn; return MessageCase.NotLoggedIn;
} }
const myMember = this._getMyMember(); const myMember = this.getMyMember();
if (myMember) { if (myMember) {
if (myMember.isKicked()) { if (myMember.isKicked()) {
@ -195,7 +210,7 @@ export default class RoomPreviewBar extends React.Component {
} }
return MessageCase.Invite; return MessageCase.Invite;
} else if (this.props.error) { } else if (this.props.error) {
if (this.props.error.errcode == 'M_NOT_FOUND') { if ((this.props.error as MatrixError).errcode == 'M_NOT_FOUND') {
return MessageCase.RoomNotFound; return MessageCase.RoomNotFound;
} else { } else {
return MessageCase.OtherError; return MessageCase.OtherError;
@ -205,8 +220,8 @@ export default class RoomPreviewBar extends React.Component {
} }
} }
_getKickOrBanInfo() { private getKickOrBanInfo(): { memberName?: string, reason?: string } {
const myMember = this._getMyMember(); const myMember = this.getMyMember();
if (!myMember) { if (!myMember) {
return {}; return {};
} }
@ -219,24 +234,19 @@ export default class RoomPreviewBar extends React.Component {
return { memberName, reason }; return { memberName, reason };
} }
_joinRule() { private joinRule(): JoinRule {
const room = this.props.room; return this.props.room?.currentState
if (room) { .getStateEvents(EventType.RoomJoinRules, "")?.getContent<IJoinRuleEventContent>().join_rule;
const joinRules = room.currentState.getStateEvents('m.room.join_rules', '');
if (joinRules) {
return joinRules.getContent().join_rule;
}
}
} }
_communityProfile() { private communityProfile(): { displayName?: string, avatarMxc?: string } {
if (this.props.room) return CommunityPrototypeStore.instance.getInviteProfile(this.props.room.roomId); if (this.props.room) return CommunityPrototypeStore.instance.getInviteProfile(this.props.room.roomId);
return { displayName: null, avatarMxc: null }; return { displayName: null, avatarMxc: null };
} }
_roomName(atStart = false) { private roomName(atStart = false): string {
let name = this.props.room ? this.props.room.name : this.props.roomAlias; let name = this.props.room ? this.props.room.name : this.props.roomAlias;
const profile = this._communityProfile(); const profile = this.communityProfile();
if (profile.displayName) name = profile.displayName; if (profile.displayName) name = profile.displayName;
if (name) { if (name) {
return name; return name;
@ -247,14 +257,11 @@ export default class RoomPreviewBar extends React.Component {
} }
} }
_getMyMember() { private getMyMember(): RoomMember {
return ( return this.props.room?.getMember(MatrixClientPeg.get().getUserId());
this.props.room &&
this.props.room.getMember(MatrixClientPeg.get().getUserId())
);
} }
_getInviteMember() { private getInviteMember(): RoomMember {
const { room } = this.props; const { room } = this.props;
if (!room) { if (!room) {
return; return;
@ -268,8 +275,8 @@ export default class RoomPreviewBar extends React.Component {
return room.currentState.getMember(inviterUserId); return room.currentState.getMember(inviterUserId);
} }
_isDMInvite() { private isDMInvite(): boolean {
const myMember = this._getMyMember(); const myMember = this.getMyMember();
if (!myMember) { if (!myMember) {
return false; return false;
} }
@ -278,7 +285,7 @@ export default class RoomPreviewBar extends React.Component {
return memberContent.membership === "invite" && memberContent.is_direct; return memberContent.membership === "invite" && memberContent.is_direct;
} }
_makeScreenAfterLogin() { private makeScreenAfterLogin(): { screen: string, params: Record<string, any> } {
return { return {
screen: 'room', screen: 'room',
params: { params: {
@ -291,18 +298,16 @@ export default class RoomPreviewBar extends React.Component {
}; };
} }
onLoginClick = () => { private onLoginClick = () => {
dis.dispatch({ action: 'start_login', screenAfterLogin: this._makeScreenAfterLogin() }); dis.dispatch({ action: 'start_login', screenAfterLogin: this.makeScreenAfterLogin() });
}; };
onRegisterClick = () => { private onRegisterClick = () => {
dis.dispatch({ action: 'start_registration', screenAfterLogin: this._makeScreenAfterLogin() }); dis.dispatch({ action: 'start_registration', screenAfterLogin: this.makeScreenAfterLogin() });
}; };
render() { render() {
const brand = SdkConfig.get().brand; const brand = SdkConfig.get().brand;
const Spinner = sdk.getComponent('elements.Spinner');
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
let showSpinner = false; let showSpinner = false;
let title; let title;
@ -315,10 +320,10 @@ export default class RoomPreviewBar extends React.Component {
let footer; let footer;
const extraComponents = []; const extraComponents = [];
const messageCase = this._getMessageCase(); const messageCase = this.getMessageCase();
switch (messageCase) { switch (messageCase) {
case MessageCase.Joining: { case MessageCase.Joining: {
title = _t("Joining room …"); title = this.props.oobData?.roomType === RoomType.Space ? _t("Joining space …") : _t("Joining room …");
showSpinner = true; showSpinner = true;
break; break;
} }
@ -349,12 +354,12 @@ export default class RoomPreviewBar extends React.Component {
break; break;
} }
case MessageCase.Kicked: { case MessageCase.Kicked: {
const { memberName, reason } = this._getKickOrBanInfo(); const { memberName, reason } = this.getKickOrBanInfo();
title = _t("You were kicked from %(roomName)s by %(memberName)s", title = _t("You were kicked from %(roomName)s by %(memberName)s",
{ memberName, roomName: this._roomName() }); { memberName, roomName: this.roomName() });
subTitle = reason ? _t("Reason: %(reason)s", { reason }) : null; subTitle = reason ? _t("Reason: %(reason)s", { reason }) : null;
if (this._joinRule() === "invite") { if (this.joinRule() === "invite") {
primaryActionLabel = _t("Forget this room"); primaryActionLabel = _t("Forget this room");
primaryActionHandler = this.props.onForgetClick; primaryActionHandler = this.props.onForgetClick;
} else { } else {
@ -366,9 +371,9 @@ export default class RoomPreviewBar extends React.Component {
break; break;
} }
case MessageCase.Banned: { case MessageCase.Banned: {
const { memberName, reason } = this._getKickOrBanInfo(); const { memberName, reason } = this.getKickOrBanInfo();
title = _t("You were banned from %(roomName)s by %(memberName)s", title = _t("You were banned from %(roomName)s by %(memberName)s",
{ memberName, roomName: this._roomName() }); { memberName, roomName: this.roomName() });
subTitle = reason ? _t("Reason: %(reason)s", { reason }) : null; subTitle = reason ? _t("Reason: %(reason)s", { reason }) : null;
primaryActionLabel = _t("Forget this room"); primaryActionLabel = _t("Forget this room");
primaryActionHandler = this.props.onForgetClick; primaryActionHandler = this.props.onForgetClick;
@ -376,8 +381,8 @@ export default class RoomPreviewBar extends React.Component {
} }
case MessageCase.OtherThreePIDError: { case MessageCase.OtherThreePIDError: {
title = _t("Something went wrong with your invite to %(roomName)s", title = _t("Something went wrong with your invite to %(roomName)s",
{ roomName: this._roomName() }); { roomName: this.roomName() });
const joinRule = this._joinRule(); const joinRule = this.joinRule();
const errCodeMessage = _t( const errCodeMessage = _t(
"An error (%(errcode)s) was returned while trying to validate your " + "An error (%(errcode)s) was returned while trying to validate your " +
"invite. You could try to pass this information on to a room admin.", "invite. You could try to pass this information on to a room admin.",
@ -410,7 +415,7 @@ export default class RoomPreviewBar extends React.Component {
"This invite to %(roomName)s was sent to %(email)s which is not " + "This invite to %(roomName)s was sent to %(email)s which is not " +
"associated with your account", "associated with your account",
{ {
roomName: this._roomName(), roomName: this.roomName(),
email: this.props.invitedEmail, email: this.props.invitedEmail,
}, },
); );
@ -427,7 +432,7 @@ export default class RoomPreviewBar extends React.Component {
title = _t( title = _t(
"This invite to %(roomName)s was sent to %(email)s", "This invite to %(roomName)s was sent to %(email)s",
{ {
roomName: this._roomName(), roomName: this.roomName(),
email: this.props.invitedEmail, email: this.props.invitedEmail,
}, },
); );
@ -443,7 +448,7 @@ export default class RoomPreviewBar extends React.Component {
title = _t( title = _t(
"This invite to %(roomName)s was sent to %(email)s", "This invite to %(roomName)s was sent to %(email)s",
{ {
roomName: this._roomName(), roomName: this.roomName(),
email: this.props.invitedEmail, email: this.props.invitedEmail,
}, },
); );
@ -458,11 +463,11 @@ export default class RoomPreviewBar extends React.Component {
case MessageCase.Invite: { case MessageCase.Invite: {
const RoomAvatar = sdk.getComponent("views.avatars.RoomAvatar"); const RoomAvatar = sdk.getComponent("views.avatars.RoomAvatar");
const oobData = Object.assign({}, this.props.oobData, { const oobData = Object.assign({}, this.props.oobData, {
avatarUrl: this._communityProfile().avatarMxc, avatarUrl: this.communityProfile().avatarMxc,
}); });
const avatar = <RoomAvatar room={this.props.room} oobData={oobData} />; const avatar = <RoomAvatar room={this.props.room} oobData={oobData} />;
const inviteMember = this._getInviteMember(); const inviteMember = this.getInviteMember();
let inviterElement; let inviterElement;
if (inviteMember) { if (inviteMember) {
inviterElement = <span> inviterElement = <span>
@ -474,7 +479,7 @@ export default class RoomPreviewBar extends React.Component {
inviterElement = (<span className="mx_RoomPreviewBar_inviter">{ this.props.inviterName }</span>); inviterElement = (<span className="mx_RoomPreviewBar_inviter">{ this.props.inviterName }</span>);
} }
const isDM = this._isDMInvite(); const isDM = this.isDMInvite();
if (isDM) { if (isDM) {
title = _t("Do you want to chat with %(user)s?", title = _t("Do you want to chat with %(user)s?",
{ user: inviteMember.name }); { user: inviteMember.name });
@ -485,7 +490,7 @@ export default class RoomPreviewBar extends React.Component {
primaryActionLabel = _t("Start chatting"); primaryActionLabel = _t("Start chatting");
} else { } else {
title = _t("Do you want to join %(roomName)s?", title = _t("Do you want to join %(roomName)s?",
{ roomName: this._roomName() }); { roomName: this.roomName() });
subTitle = [ subTitle = [
avatar, avatar,
_t("<userName/> invited you", {}, { userName: () => inviterElement }), _t("<userName/> invited you", {}, { userName: () => inviterElement }),
@ -519,22 +524,22 @@ export default class RoomPreviewBar extends React.Component {
case MessageCase.ViewingRoom: { case MessageCase.ViewingRoom: {
if (this.props.canPreview) { if (this.props.canPreview) {
title = _t("You're previewing %(roomName)s. Want to join it?", title = _t("You're previewing %(roomName)s. Want to join it?",
{ roomName: this._roomName() }); { roomName: this.roomName() });
} else { } else {
title = _t("%(roomName)s can't be previewed. Do you want to join it?", title = _t("%(roomName)s can't be previewed. Do you want to join it?",
{ roomName: this._roomName(true) }); { roomName: this.roomName(true) });
} }
primaryActionLabel = _t("Join the discussion"); primaryActionLabel = _t("Join the discussion");
primaryActionHandler = this.props.onJoinClick; primaryActionHandler = this.props.onJoinClick;
break; break;
} }
case MessageCase.RoomNotFound: { case MessageCase.RoomNotFound: {
title = _t("%(roomName)s does not exist.", { roomName: this._roomName(true) }); title = _t("%(roomName)s does not exist.", { roomName: this.roomName(true) });
subTitle = _t("This room doesn't exist. Are you sure you're at the right place?"); subTitle = _t("This room doesn't exist. Are you sure you're at the right place?");
break; break;
} }
case MessageCase.OtherError: { case MessageCase.OtherError: {
title = _t("%(roomName)s is not accessible at this time.", { roomName: this._roomName(true) }); title = _t("%(roomName)s is not accessible at this time.", { roomName: this.roomName(true) });
subTitle = [ subTitle = [
_t("Try again later, or ask a room admin to check if you have access."), _t("Try again later, or ask a room admin to check if you have access."),
_t( _t(

View file

@ -670,6 +670,7 @@ export default class RoomSublist extends React.Component<IProps, IState> {
onClick={this.onBadgeClick} onClick={this.onBadgeClick}
tabIndex={tabIndex} tabIndex={tabIndex}
aria-label={ariaLabel} aria-label={ariaLabel}
showUnsentTooltip={true}
/> />
); );

View file

@ -17,7 +17,6 @@ limitations under the License.
import React, { createRef } from "react"; import React, { createRef } from "react";
import { Room } from "matrix-js-sdk/src/models/room"; import { Room } from "matrix-js-sdk/src/models/room";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import classNames from "classnames"; import classNames from "classnames";
import { RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex"; import { RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex";
import AccessibleButton, { ButtonEvent } from "../../views/elements/AccessibleButton"; import AccessibleButton, { ButtonEvent } from "../../views/elements/AccessibleButton";
@ -51,8 +50,6 @@ import IconizedContextMenu, {
} from "../context_menus/IconizedContextMenu"; } from "../context_menus/IconizedContextMenu";
import { CommunityPrototypeStore, IRoomProfile } from "../../../stores/CommunityPrototypeStore"; import { CommunityPrototypeStore, IRoomProfile } from "../../../stores/CommunityPrototypeStore";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import { getUnsentMessages } from "../../structures/RoomStatusBar";
import { StaticNotificationState } from "../../../stores/notifications/StaticNotificationState";
interface IProps { interface IProps {
room: Room; room: Room;
@ -68,7 +65,6 @@ interface IState {
notificationsMenuPosition: PartialDOMRect; notificationsMenuPosition: PartialDOMRect;
generalMenuPosition: PartialDOMRect; generalMenuPosition: PartialDOMRect;
messagePreview?: string; messagePreview?: string;
hasUnsentEvents: boolean;
} }
const messagePreviewId = (roomId: string) => `mx_RoomTile_messagePreview_${roomId}`; const messagePreviewId = (roomId: string) => `mx_RoomTile_messagePreview_${roomId}`;
@ -95,7 +91,6 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
selected: ActiveRoomObserver.activeRoomId === this.props.room.roomId, selected: ActiveRoomObserver.activeRoomId === this.props.room.roomId,
notificationsMenuPosition: null, notificationsMenuPosition: null,
generalMenuPosition: null, generalMenuPosition: null,
hasUnsentEvents: this.countUnsentEvents() > 0,
// generatePreview() will return nothing if the user has previews disabled // generatePreview() will return nothing if the user has previews disabled
messagePreview: "", messagePreview: "",
@ -106,11 +101,7 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
this.roomProps = EchoChamber.forRoom(this.props.room); this.roomProps = EchoChamber.forRoom(this.props.room);
} }
private countUnsentEvents(): number { private onRoomNameUpdate = (room: Room) => {
return getUnsentMessages(this.props.room).length;
}
private onRoomNameUpdate = (room) => {
this.forceUpdate(); this.forceUpdate();
}; };
@ -118,11 +109,6 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
this.forceUpdate(); // notification state changed - update this.forceUpdate(); // notification state changed - update
}; };
private onLocalEchoUpdated = (ev: MatrixEvent, room: Room) => {
if (room?.roomId !== this.props.room.roomId) return;
this.setState({ hasUnsentEvents: this.countUnsentEvents() > 0 });
};
private onRoomPropertyUpdate = (property: CachedRoomKey) => { private onRoomPropertyUpdate = (property: CachedRoomKey) => {
if (property === CachedRoomKey.NotificationVolume) this.onNotificationUpdate(); if (property === CachedRoomKey.NotificationVolume) this.onNotificationUpdate();
// else ignore - not important for this tile // else ignore - not important for this tile
@ -178,12 +164,11 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
); );
this.notificationState.on(NOTIFICATION_STATE_UPDATE, this.onNotificationUpdate); this.notificationState.on(NOTIFICATION_STATE_UPDATE, this.onNotificationUpdate);
this.roomProps.on(PROPERTY_UPDATED, this.onRoomPropertyUpdate); this.roomProps.on(PROPERTY_UPDATED, this.onRoomPropertyUpdate);
this.roomProps.on("Room.name", this.onRoomNameUpdate); this.props.room?.on("Room.name", this.onRoomNameUpdate);
CommunityPrototypeStore.instance.on( CommunityPrototypeStore.instance.on(
CommunityPrototypeStore.getUpdateEventName(this.props.room.roomId), CommunityPrototypeStore.getUpdateEventName(this.props.room.roomId),
this.onCommunityUpdate, this.onCommunityUpdate,
); );
MatrixClientPeg.get().on("Room.localEchoUpdated", this.onLocalEchoUpdated);
} }
public componentWillUnmount() { public componentWillUnmount() {
@ -208,7 +193,6 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
CommunityPrototypeStore.getUpdateEventName(this.props.room.roomId), CommunityPrototypeStore.getUpdateEventName(this.props.room.roomId),
this.onCommunityUpdate, this.onCommunityUpdate,
); );
MatrixClientPeg.get()?.removeListener("Room.localEchoUpdated", this.onLocalEchoUpdated);
} }
private onAction = (payload: ActionPayload) => { private onAction = (payload: ActionPayload) => {
@ -587,20 +571,8 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
/>; />;
let badge: React.ReactNode; let badge: React.ReactNode;
if (!this.props.isMinimized) { if (!this.props.isMinimized && this.notificationState) {
// aria-hidden because we summarise the unread count/highlight status in a manual aria-label below // aria-hidden because we summarise the unread count/highlight status in a manual aria-label below
if (this.state.hasUnsentEvents) {
// hardcode the badge to a danger state when there's unsent messages
badge = (
<div className="mx_RoomTile_badgeContainer" aria-hidden="true">
<NotificationBadge
notification={StaticNotificationState.RED_EXCLAMATION}
forceCount={false}
roomId={this.props.room.roomId}
/>
</div>
);
} else if (this.notificationState) {
badge = ( badge = (
<div className="mx_RoomTile_badgeContainer" aria-hidden="true"> <div className="mx_RoomTile_badgeContainer" aria-hidden="true">
<NotificationBadge <NotificationBadge
@ -611,7 +583,6 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
</div> </div>
); );
} }
}
let messagePreview = null; let messagePreview = null;
if (this.showMessagePreview && this.state.messagePreview) { if (this.showMessagePreview && this.state.messagePreview) {

View file

@ -31,8 +31,8 @@ import {
textSerialize, textSerialize,
unescapeMessage, unescapeMessage,
} from '../../../editor/serialize'; } from '../../../editor/serialize';
import BasicMessageComposer, { REGEX_EMOTICON } from "./BasicMessageComposer";
import { CommandPartCreator, Part, PartCreator, SerializedPart, Type } from '../../../editor/parts'; import { CommandPartCreator, Part, PartCreator, SerializedPart, Type } from '../../../editor/parts';
import BasicMessageComposer from "./BasicMessageComposer";
import ReplyThread from "../elements/ReplyThread"; import ReplyThread from "../elements/ReplyThread";
import { findEditableEvent } from '../../../utils/EventUtils'; import { findEditableEvent } from '../../../utils/EventUtils';
import SendHistoryManager from "../../../SendHistoryManager"; import SendHistoryManager from "../../../SendHistoryManager";
@ -347,15 +347,24 @@ export default class SendMessageComposer extends React.Component<IProps> {
} }
public async sendMessage(): Promise<void> { public async sendMessage(): Promise<void> {
if (this.model.isEmpty) { const model = this.model;
if (model.isEmpty) {
return; return;
} }
// Replace emoticon at the end of the message
if (SettingsStore.getValue('MessageComposerInput.autoReplaceEmoji')) {
const caret = this.editorRef.current?.getCaret();
const position = model.positionForOffset(caret.offset, caret.atNodeEnd);
this.editorRef.current?.replaceEmoticon(position, REGEX_EMOTICON);
}
const replyToEvent = this.props.replyToEvent; const replyToEvent = this.props.replyToEvent;
let shouldSend = true; let shouldSend = true;
let content; let content;
if (!containsEmote(this.model) && this.isSlashCommand()) { if (!containsEmote(model) && this.isSlashCommand()) {
const [cmd, args, commandText] = this.getSlashCommand(); const [cmd, args, commandText] = this.getSlashCommand();
if (cmd) { if (cmd) {
if (cmd.category === CommandCategories.messages) { if (cmd.category === CommandCategories.messages) {
@ -400,7 +409,7 @@ export default class SendMessageComposer extends React.Component<IProps> {
} }
} }
if (isQuickReaction(this.model)) { if (isQuickReaction(model)) {
shouldSend = false; shouldSend = false;
this.sendQuickReaction(); this.sendQuickReaction();
} }
@ -410,7 +419,7 @@ export default class SendMessageComposer extends React.Component<IProps> {
const { roomId } = this.props.room; const { roomId } = this.props.room;
if (!content) { if (!content) {
content = createMessageContent( content = createMessageContent(
this.model, model,
replyToEvent, replyToEvent,
this.props.replyInThread, this.props.replyInThread,
this.props.permalinkCreator, this.props.permalinkCreator,
@ -446,9 +455,9 @@ export default class SendMessageComposer extends React.Component<IProps> {
CountlyAnalytics.instance.trackSendMessage(startTime, prom, roomId, false, !!replyToEvent, content); CountlyAnalytics.instance.trackSendMessage(startTime, prom, roomId, false, !!replyToEvent, content);
} }
this.sendHistoryManager.save(this.model, replyToEvent); this.sendHistoryManager.save(model, replyToEvent);
// clear composer // clear composer
this.model.reset([]); model.reset([]);
this.editorRef.current?.clearUndoHistory(); this.editorRef.current?.clearUndoHistory();
this.editorRef.current?.focus(); this.editorRef.current?.focus();
this.clearStoredEditorState(); this.clearStoredEditorState();

View file

@ -32,6 +32,7 @@ import { replaceableComponent } from "../../../utils/replaceableComponent";
import { ActionPayload } from '../../../dispatcher/payloads'; import { ActionPayload } from '../../../dispatcher/payloads';
import ScalarAuthClient from '../../../ScalarAuthClient'; import ScalarAuthClient from '../../../ScalarAuthClient';
import GenericElementContextMenu from "../context_menus/GenericElementContextMenu"; import GenericElementContextMenu from "../context_menus/GenericElementContextMenu";
import { IApp } from "../../../stores/WidgetStore";
// This should be below the dialog level (4000), but above the rest of the UI (1000-2000). // This should be below the dialog level (4000), but above the rest of the UI (1000-2000).
// We sit in a context menu, so this should be given to the context menu. // We sit in a context menu, so this should be given to the context menu.
@ -256,12 +257,16 @@ export default class Stickerpicker extends React.PureComponent<IProps, IState> {
stickerpickerWidget.content.name = stickerpickerWidget.content.name || _t("Stickerpack"); stickerpickerWidget.content.name = stickerpickerWidget.content.name || _t("Stickerpack");
// FIXME: could this use the same code as other apps? // FIXME: could this use the same code as other apps?
const stickerApp = { const stickerApp: IApp = {
id: stickerpickerWidget.id, id: stickerpickerWidget.id,
url: stickerpickerWidget.content.url, url: stickerpickerWidget.content.url,
name: stickerpickerWidget.content.name, name: stickerpickerWidget.content.name,
type: stickerpickerWidget.content.type, type: stickerpickerWidget.content.type,
data: stickerpickerWidget.content.data, data: stickerpickerWidget.content.data,
roomId: stickerpickerWidget.content.roomId,
eventId: stickerpickerWidget.content.eventId,
avatar_url: stickerpickerWidget.content.avatar_url,
creatorUserId: stickerpickerWidget.content.creatorUserId,
}; };
stickersContent = ( stickersContent = (
@ -287,9 +292,7 @@ export default class Stickerpicker extends React.PureComponent<IProps, IState> {
onEditClick={this.launchManageIntegrations} onEditClick={this.launchManageIntegrations}
onDeleteClick={this.removeStickerpickerWidgets} onDeleteClick={this.removeStickerpickerWidgets}
showTitle={false} showTitle={false}
showCancel={false}
showPopout={false} showPopout={false}
onMinimiseClick={this.onHideStickersClick}
handleMinimisePointerEvents={true} handleMinimisePointerEvents={true}
userWidget={true} userWidget={true}
/> />
@ -345,16 +348,6 @@ export default class Stickerpicker extends React.PureComponent<IProps, IState> {
}); });
}; };
/**
* Trigger hiding of the sticker picker overlay
* @param {Event} ev Event that triggered the function call
*/
private onHideStickersClick = (ev: React.MouseEvent): void => {
if (this.props.showStickers) {
this.props.setShowStickers(false);
}
};
/** /**
* Called when the window is resized * Called when the window is resized
*/ */

View file

@ -28,6 +28,7 @@ import { MatrixClientPeg } from "../../../MatrixClientPeg";
import Modal from "../../../Modal"; import Modal from "../../../Modal";
import ManageRestrictedJoinRuleDialog from "../dialogs/ManageRestrictedJoinRuleDialog"; import ManageRestrictedJoinRuleDialog from "../dialogs/ManageRestrictedJoinRuleDialog";
import RoomUpgradeWarningDialog from "../dialogs/RoomUpgradeWarningDialog"; import RoomUpgradeWarningDialog from "../dialogs/RoomUpgradeWarningDialog";
import QuestionDialog from "../dialogs/QuestionDialog";
import { upgradeRoom } from "../../../utils/RoomUpgrade"; import { upgradeRoom } from "../../../utils/RoomUpgrade";
import { arrayHasDiff } from "../../../utils/arrays"; import { arrayHasDiff } from "../../../utils/arrays";
import { useLocalEcho } from "../../../hooks/useLocalEcho"; import { useLocalEcho } from "../../../hooks/useLocalEcho";
@ -207,13 +208,37 @@ const JoinRuleSettings = ({ room, promptUpgrade, onError, beforeChange, closeSet
} else if (preferredRestrictionVersion) { } else if (preferredRestrictionVersion) {
// Block this action on a room upgrade otherwise it'd make their room unjoinable // Block this action on a room upgrade otherwise it'd make their room unjoinable
const targetVersion = preferredRestrictionVersion; const targetVersion = preferredRestrictionVersion;
Modal.createTrackedDialog('Restricted join rule upgrade', '', RoomUpgradeWarningDialog, {
const modal = Modal.createTrackedDialog('Restricted join rule upgrade', '', RoomUpgradeWarningDialog, {
roomId: room.roomId, roomId: room.roomId,
targetVersion, targetVersion,
description: _t("This upgrade will allow members of selected spaces " + description: _t("This upgrade will allow members of selected spaces " +
"access to this room without an invite."), "access to this room without an invite."),
onFinished: async (resp) => { });
const [resp] = await modal.finished;
if (!resp?.continue) return; if (!resp?.continue) return;
const userId = cli.getUserId();
const unableToUpdateSomeParents = Array.from(SpaceStore.instance.getKnownParents(room.roomId))
.some(roomId => !cli.getRoom(roomId)?.currentState.maySendStateEvent(EventType.SpaceChild, userId));
if (unableToUpdateSomeParents) {
const modal = Modal.createTrackedDialog<[boolean]>('Parent relink warning', '', QuestionDialog, {
title: _t("Before you upgrade"),
description: (
<div>{ _t("This room is in some spaces youre not an admin of. " +
"In those spaces, the old room will still be shown, " +
"but people will be prompted to join the new one.") }</div>
),
hasCancelButton: true,
button: _t("Upgrade anyway"),
danger: true,
});
const [shouldUpgrade] = await modal.finished;
if (!shouldUpgrade) return;
}
const roomId = await upgradeRoom(room, targetVersion, resp.invite, true, true, true); const roomId = await upgradeRoom(room, targetVersion, resp.invite, true, true, true);
closeSettingsFn(); closeSettingsFn();
// switch to the new room in the background // switch to the new room in the background
@ -226,8 +251,7 @@ const JoinRuleSettings = ({ room, promptUpgrade, onError, beforeChange, closeSet
action: "open_room_settings", action: "open_room_settings",
initial_tab_id: ROOM_SECURITY_TAB, initial_tab_id: ROOM_SECURITY_TAB,
}); });
},
});
return; return;
} }

View file

@ -137,7 +137,7 @@ export default class RolesRoomSettingsTab extends React.Component<IProps> {
} }
} }
private onPowerLevelsChanged = (inputValue: string, powerLevelKey: string) => { private onPowerLevelsChanged = (value: number, powerLevelKey: string) => {
const client = MatrixClientPeg.get(); const client = MatrixClientPeg.get();
const room = client.getRoom(this.props.roomId); const room = client.getRoom(this.props.roomId);
const plEvent = room.currentState.getStateEvents(EventType.RoomPowerLevels, ''); const plEvent = room.currentState.getStateEvents(EventType.RoomPowerLevels, '');
@ -148,8 +148,6 @@ export default class RolesRoomSettingsTab extends React.Component<IProps> {
const eventsLevelPrefix = "event_levels_"; const eventsLevelPrefix = "event_levels_";
const value = parseInt(inputValue);
if (powerLevelKey.startsWith(eventsLevelPrefix)) { if (powerLevelKey.startsWith(eventsLevelPrefix)) {
// deep copy "events" object, Object.assign itself won't deep copy // deep copy "events" object, Object.assign itself won't deep copy
plContent["events"] = Object.assign({}, plContent["events"] || {}); plContent["events"] = Object.assign({}, plContent["events"] || {});
@ -181,7 +179,7 @@ export default class RolesRoomSettingsTab extends React.Component<IProps> {
}); });
}; };
private onUserPowerLevelChanged = (value: string, powerLevelKey: string) => { private onUserPowerLevelChanged = (value: number, powerLevelKey: string) => {
const client = MatrixClientPeg.get(); const client = MatrixClientPeg.get();
const room = client.getRoom(this.props.roomId); const room = client.getRoom(this.props.roomId);
const plEvent = room.currentState.getStateEvents(EventType.RoomPowerLevels, ''); const plEvent = room.currentState.getStateEvents(EventType.RoomPowerLevels, '');

View file

@ -28,7 +28,6 @@ import { replaceableComponent } from "../../../../../utils/replaceableComponent"
import SettingsFlag from '../../../elements/SettingsFlag'; import SettingsFlag from '../../../elements/SettingsFlag';
import * as KeyboardShortcuts from "../../../../../accessibility/KeyboardShortcuts"; import * as KeyboardShortcuts from "../../../../../accessibility/KeyboardShortcuts";
import AccessibleButton from "../../../elements/AccessibleButton"; import AccessibleButton from "../../../elements/AccessibleButton";
import SpaceStore from "../../../../../stores/SpaceStore";
import GroupAvatar from "../../../avatars/GroupAvatar"; import GroupAvatar from "../../../avatars/GroupAvatar";
import dis from "../../../../../dispatcher/dispatcher"; import dis from "../../../../../dispatcher/dispatcher";
import GroupActions from "../../../../../actions/GroupActions"; import GroupActions from "../../../../../actions/GroupActions";
@ -145,7 +144,7 @@ export default class PreferencesUserSettingsTab extends React.Component<IProps,
]; ];
static COMMUNITIES_SETTINGS = [ static COMMUNITIES_SETTINGS = [
// TODO: part of delabsing move the toggle here - https://github.com/vector-im/element-web/issues/18088 "showCommunitiesInsteadOfSpaces",
]; ];
static KEYBINDINGS_SETTINGS = [ static KEYBINDINGS_SETTINGS = [
@ -286,9 +285,17 @@ export default class PreferencesUserSettingsTab extends React.Component<IProps,
SettingsStore.setValue("readMarkerOutOfViewThresholdMs", null, SettingLevel.DEVICE, e.target.value); SettingsStore.setValue("readMarkerOutOfViewThresholdMs", null, SettingLevel.DEVICE, e.target.value);
}; };
private renderGroup(settingIds: string[]): React.ReactNodeArray { private renderGroup(
return settingIds.filter(SettingsStore.isEnabled).map(i => { settingIds: string[],
return <SettingsFlag key={i} name={i} level={SettingLevel.ACCOUNT} />; level = SettingLevel.ACCOUNT,
includeDisabled = false,
): React.ReactNodeArray {
if (!includeDisabled) {
settingIds = settingIds.filter(SettingsStore.isEnabled);
}
return settingIds.map(i => {
return <SettingsFlag key={i} name={i} level={level} />;
}); });
} }
@ -334,10 +341,10 @@ export default class PreferencesUserSettingsTab extends React.Component<IProps,
{ this.renderGroup(PreferencesUserSettingsTab.ROOM_LIST_SETTINGS) } { this.renderGroup(PreferencesUserSettingsTab.ROOM_LIST_SETTINGS) }
</div> </div>
{ SpaceStore.spacesEnabled && <div className="mx_SettingsTab_section"> <div className="mx_SettingsTab_section">
<span className="mx_SettingsTab_subheading">{ _t("Spaces") }</span> <span className="mx_SettingsTab_subheading">{ _t("Spaces") }</span>
{ this.renderGroup(PreferencesUserSettingsTab.SPACES_SETTINGS) } { this.renderGroup(PreferencesUserSettingsTab.SPACES_SETTINGS, SettingLevel.ACCOUNT, true) }
</div> } </div>
<div className="mx_SettingsTab_section"> <div className="mx_SettingsTab_section">
<span className="mx_SettingsTab_subheading">{ _t("Communities") }</span> <span className="mx_SettingsTab_subheading">{ _t("Communities") }</span>
@ -349,7 +356,7 @@ export default class PreferencesUserSettingsTab extends React.Component<IProps,
<p>{ _t("If a community isn't shown you may not have permission to convert it.") }</p> <p>{ _t("If a community isn't shown you may not have permission to convert it.") }</p>
<CommunityMigrator onFinished={this.props.closeSettingsFn} /> <CommunityMigrator onFinished={this.props.closeSettingsFn} />
</details> </details>
{ this.renderGroup(PreferencesUserSettingsTab.COMMUNITIES_SETTINGS) } { this.renderGroup(PreferencesUserSettingsTab.COMMUNITIES_SETTINGS, SettingLevel.DEVICE) }
</div> </div>
<div className="mx_SettingsTab_section"> <div className="mx_SettingsTab_section">

View file

@ -97,9 +97,8 @@ const spaceNameValidator = withValidation({
], ],
}); });
const nameToAlias = (name: string, domain: string): string => { const nameToLocalpart = (name: string): string => {
const localpart = name.trim().toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9_-]+/gi, ""); return name.trim().toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9_-]+/gi, "");
return `#${localpart}:${domain}`;
}; };
// XXX: Temporary for the Spaces release only // XXX: Temporary for the Spaces release only
@ -118,9 +117,7 @@ export const SpaceFeedbackPrompt = ({ onClick }: { onClick?: () => void }) => {
"Your feedback will help inform the next versions."), "Your feedback will help inform the next versions."),
rageshakeLabel: "spaces-feedback", rageshakeLabel: "spaces-feedback",
rageshakeData: Object.fromEntries([ rageshakeData: Object.fromEntries([
"feature_spaces.all_rooms", "Spaces.allRoomsInHome",
"feature_spaces.space_member_dms",
"feature_spaces.space_dm_badges",
].map(k => [k, SettingsStore.getValue(k)])), ].map(k => [k, SettingsStore.getValue(k)])),
}); });
}} }}
@ -176,8 +173,9 @@ export const SpaceCreateForm: React.FC<ISpaceCreateFormProps> = ({
value={name} value={name}
onChange={ev => { onChange={ev => {
const newName = ev.target.value; const newName = ev.target.value;
if (!alias || alias === nameToAlias(name, domain)) { if (!alias || alias === `#${nameToLocalpart(name)}:${domain}`) {
setAlias(nameToAlias(newName, domain)); setAlias(`#${nameToLocalpart(newName)}:${domain}`);
aliasFieldRef.current?.validate({ allowEmpty: true });
} }
setName(newName); setName(newName);
}} }}
@ -194,7 +192,7 @@ export const SpaceCreateForm: React.FC<ISpaceCreateFormProps> = ({
onChange={setAlias} onChange={setAlias}
domain={domain} domain={domain}
value={alias} value={alias}
placeholder={name ? nameToAlias(name, domain) : _t("e.g. my-space")} placeholder={name ? nameToLocalpart(name) : _t("e.g. my-space")}
label={_t("Address")} label={_t("Address")}
disabled={busy} disabled={busy}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
@ -217,6 +215,7 @@ export const SpaceCreateForm: React.FC<ISpaceCreateFormProps> = ({
}; };
const SpaceCreateMenu = ({ onFinished }) => { const SpaceCreateMenu = ({ onFinished }) => {
const cli = useContext(MatrixClientContext);
const [visibility, setVisibility] = useState<Visibility>(null); const [visibility, setVisibility] = useState<Visibility>(null);
const [busy, setBusy] = useState<boolean>(false); const [busy, setBusy] = useState<boolean>(false);
@ -233,14 +232,18 @@ const SpaceCreateMenu = ({ onFinished }) => {
setBusy(true); setBusy(true);
// require & validate the space name field // require & validate the space name field
if (!await spaceNameField.current.validate({ allowEmpty: false })) { if (!(await spaceNameField.current.validate({ allowEmpty: false }))) {
spaceNameField.current.focus(); spaceNameField.current.focus();
spaceNameField.current.validate({ allowEmpty: false, focused: true }); spaceNameField.current.validate({ allowEmpty: false, focused: true });
setBusy(false); setBusy(false);
return; return;
} }
// validate the space name alias field but do not require it
if (visibility === Visibility.Public && !await spaceAliasField.current.validate({ allowEmpty: true })) { // validate the space alias field but do not require it
const aliasLocalpart = alias.substring(1, alias.length - cli.getDomain().length - 1);
if (visibility === Visibility.Public && aliasLocalpart &&
(await spaceAliasField.current.validate({ allowEmpty: true })) === false
) {
spaceAliasField.current.focus(); spaceAliasField.current.focus();
spaceAliasField.current.validate({ allowEmpty: true, focused: true }); spaceAliasField.current.validate({ allowEmpty: true, focused: true });
setBusy(false); setBusy(false);
@ -248,7 +251,13 @@ const SpaceCreateMenu = ({ onFinished }) => {
} }
try { try {
await createSpace(name, visibility === Visibility.Public, alias, topic, avatar); await createSpace(
name,
visibility === Visibility.Public,
aliasLocalpart ? alias : undefined,
topic,
avatar,
);
onFinished(); onFinished();
} catch (e) { } catch (e) {
@ -290,13 +299,13 @@ const SpaceCreateMenu = ({ onFinished }) => {
/> />
<p> <p>
{ _t("You can also create a Space from a <a>community</a>.", {}, { { _t("You can also make Spaces from <a>communities</a>.", {}, {
a: sub => <AccessibleButton kind="link" onClick={onCreateSpaceFromCommunityClick}> a: sub => <AccessibleButton kind="link" onClick={onCreateSpaceFromCommunityClick}>
{ sub } { sub }
</AccessibleButton>, </AccessibleButton>,
}) } }) }
<br /> <br />
{ _t("To join an existing space you'll need an invite.") } { _t("To join a space you'll need an invite.") }
</p> </p>
<SpaceFeedbackPrompt onClick={onFinished} /> <SpaceFeedbackPrompt onClick={onFinished} />

View file

@ -151,12 +151,19 @@ const CreateSpaceButton = ({
} }
const onNewClick = menuDisplayed ? closeMenu : () => { const onNewClick = menuDisplayed ? closeMenu : () => {
// persist that the user has interacted with this, use it to dismiss the beta dot
localStorage.setItem("mx_seenSpaces", "1");
if (!isPanelCollapsed) setPanelCollapsed(true); if (!isPanelCollapsed) setPanelCollapsed(true);
openMenu(); openMenu();
}; };
let betaDot: JSX.Element;
if (!localStorage.getItem("mx_seenSpaces") && !SpaceStore.instance.spacePanelSpaces.length) {
betaDot = <div className="mx_BetaDot" />;
}
return <li return <li
className={classNames("mx_SpaceItem", { className={classNames("mx_SpaceItem mx_SpaceItem_new", {
"collapsed": isPanelCollapsed, "collapsed": isPanelCollapsed,
})} })}
role="treeitem" role="treeitem"
@ -169,6 +176,7 @@ const CreateSpaceButton = ({
onClick={onNewClick} onClick={onNewClick}
isNarrow={isPanelCollapsed} isNarrow={isPanelCollapsed}
/> />
{ betaDot }
{ contextMenu } { contextMenu }
</li>; </li>;

View file

@ -93,6 +93,7 @@ export const SpaceButton: React.FC<IButtonProps> = ({
notification={notificationState} notification={notificationState}
aria-label={ariaLabel} aria-label={ariaLabel}
tabIndex={tabIndex} tabIndex={tabIndex}
showUnsentTooltip={true}
/> />
</div>; </div>;
} }

View file

@ -214,6 +214,8 @@ export default class CallView extends React.Component<IProps, IState> {
this.setState({ this.setState({
primaryFeed: primary, primaryFeed: primary,
secondaryFeeds: secondary, secondaryFeeds: secondary,
micMuted: this.props.call.isMicrophoneMuted(),
vidMuted: this.props.call.isLocalVideoMuted(),
}); });
}; };
@ -258,18 +260,14 @@ export default class CallView extends React.Component<IProps, IState> {
return { primary, secondary }; return { primary, secondary };
} }
private onMicMuteClick = (): void => { private onMicMuteClick = async (): Promise<void> => {
const newVal = !this.state.micMuted; const newVal = !this.state.micMuted;
this.setState({ micMuted: await this.props.call.setMicrophoneMuted(newVal) });
this.props.call.setMicrophoneMuted(newVal);
this.setState({ micMuted: newVal });
}; };
private onVidMuteClick = (): void => { private onVidMuteClick = async (): Promise<void> => {
const newVal = !this.state.vidMuted; const newVal = !this.state.vidMuted;
this.setState({ vidMuted: await this.props.call.setLocalVideoMuted(newVal) });
this.props.call.setLocalVideoMuted(newVal);
this.setState({ vidMuted: newVal });
}; };
private onScreenshareClick = async (): Promise<void> => { private onScreenshareClick = async (): Promise<void> => {
@ -277,9 +275,13 @@ export default class CallView extends React.Component<IProps, IState> {
if (this.state.screensharing) { if (this.state.screensharing) {
isScreensharing = await this.props.call.setScreensharingEnabled(false); isScreensharing = await this.props.call.setScreensharingEnabled(false);
} else { } else {
if (window.electron?.getDesktopCapturerSources) {
const { finished } = Modal.createDialog(DesktopCapturerSourcePicker); const { finished } = Modal.createDialog(DesktopCapturerSourcePicker);
const [source] = await finished; const [source] = await finished;
isScreensharing = await this.props.call.setScreensharingEnabled(true, source); isScreensharing = await this.props.call.setScreensharingEnabled(true, source);
} else {
isScreensharing = await this.props.call.setScreensharingEnabled(true);
}
} }
this.setState({ this.setState({

View file

@ -32,13 +32,20 @@ export default class Range {
this._end = bIsLarger ? positionB : positionA; this._end = bIsLarger ? positionB : positionA;
} }
public moveStart(delta: number): void { public moveStartForwards(delta: number): void {
this._start = this._start.forwardsWhile(this.model, () => { this._start = this._start.forwardsWhile(this.model, () => {
delta -= 1; delta -= 1;
return delta >= 0; return delta >= 0;
}); });
} }
public moveEndBackwards(delta: number): void {
this._end = this._end.backwardsWhile(this.model, () => {
delta -= 1;
return delta >= 0;
});
}
public trim(): void { public trim(): void {
this._start = this._start.forwardsWhile(this.model, whitespacePredicate); this._start = this._start.forwardsWhile(this.model, whitespacePredicate);
this._end = this._end.backwardsWhile(this.model, whitespacePredicate); this._end = this._end.backwardsWhile(this.model, whitespacePredicate);

View file

@ -20,7 +20,11 @@ import type { EventEmitter } from "events";
type Handler = (...args: any[]) => void; type Handler = (...args: any[]) => void;
// Hook to wrap event emitter on and removeListener in hook lifecycle // Hook to wrap event emitter on and removeListener in hook lifecycle
export const useEventEmitter = (emitter: EventEmitter, eventName: string | symbol, handler: Handler) => { export const useEventEmitter = (
emitter: EventEmitter | undefined,
eventName: string | symbol,
handler: Handler,
) => {
// Create a ref that stores handler // Create a ref that stores handler
const savedHandler = useRef(handler); const savedHandler = useRef(handler);
@ -51,7 +55,11 @@ export const useEventEmitter = (emitter: EventEmitter, eventName: string | symbo
type Mapper<T> = (...args: any[]) => T; type Mapper<T> = (...args: any[]) => T;
export const useEventEmitterState = <T>(emitter: EventEmitter, eventName: string | symbol, fn: Mapper<T>): T => { export const useEventEmitterState = <T>(
emitter: EventEmitter | undefined,
eventName: string | symbol,
fn: Mapper<T>,
): T => {
const [value, setValue] = useState<T>(fn()); const [value, setValue] = useState<T>(fn());
const handler = useCallback((...args: any[]) => { const handler = useCallback((...args: any[]) => {
setValue(fn(...args)); setValue(fn(...args));

View file

@ -25,7 +25,7 @@ const defaultMapper: Mapper<RoomState> = (roomState: RoomState) => roomState;
// Hook to simplify watching Matrix Room state // Hook to simplify watching Matrix Room state
export const useRoomState = <T extends any = RoomState>( export const useRoomState = <T extends any = RoomState>(
room: Room, room?: Room,
mapper: Mapper<T> = defaultMapper as Mapper<T>, mapper: Mapper<T> = defaultMapper as Mapper<T>,
): T => { ): T => {
const [value, setValue] = useState<T>(room ? mapper(room.currentState) : undefined); const [value, setValue] = useState<T>(room ? mapper(room.currentState) : undefined);

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