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:
build:
runs-on: ubuntu-latest
env:
PR_NUMBER: ${{github.event.number}}
steps:
- uses: actions/checkout@v2
- name: Build

View file

@ -5,6 +5,8 @@ on:
jobs:
build:
runs-on: ubuntu-latest
env:
PR_NUMBER: ${{github.event.number}}
steps:
- uses: actions/checkout@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)
===================================================================================================

View file

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

View file

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

View file

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

View file

@ -215,9 +215,10 @@ $SpaceRoomViewInnerWidth: 428px;
}
}
> .mx_BaseAvatar_image,
> .mx_BaseAvatar > .mx_BaseAvatar_image {
border-radius: 12px;
> .mx_RoomAvatar_isSpaceRoom {
&.mx_BaseAvatar_image, .mx_BaseAvatar_image {
border-radius: 12px;
}
}
h1.mx_SpaceRoomView_preview_name {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -21,6 +21,17 @@ limitations under the License.
.mx_SettingsTab_section {
margin-bottom: 30px;
> details {
> summary {
cursor: pointer;
color: $primary-content;
}
& + .mx_SettingsFlag {
margin-top: 20px;
}
}
}
.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;
$authpage-body-bg-color: $background;
$authpage-primary-color: $primary-content;
$dark-panel-bg-color: $header-panel-bg-color;
$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
$authpage-lang-color: var(--timeline-text-color);
$roomheader-color: var(--timeline-text-color);
// was #232f32
$authpage-primary-color: var(--timeline-text-color);
// --roomlist-text-secondary-color
$roomtile-preview-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();
}
PosthogAnalytics.instance.updateAnonymityFromSettings(credentials.userId);
Analytics.setLoggedIn(credentials.guest, credentials.homeserverUrl);
MatrixClientPeg.replaceUsingCreds(credentials);
PosthogAnalytics.instance.updateAnonymityFromSettings(credentials.userId);
const client = MatrixClientPeg.get();
if (credentials.freshLogin && SettingsStore.getValue("feature_dehydration")) {

View file

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

View file

@ -18,6 +18,8 @@ import posthog, { PostHog } from 'posthog-js';
import PlatformPeg from './PlatformPeg';
import SdkConfig from './SdkConfig';
import SettingsStore from './settings/SettingsStore';
import { MatrixClientPeg } from "./MatrixClientPeg";
import { MatrixClient } from "matrix-js-sdk/src/client";
/* 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
* enabled, events are not sent (this detection is built into posthog and turned on via the
* `respect_dnt` flag being passed to `posthog.init`).
* - If the `feature_pseudonymous_analytics_opt_in` labs flag is `true`, track pseudonomously, i.e.
* hash all matrix identifiers in tracking events (user IDs, room IDs etc) using SHA-256.
* - Otherwise, if the existing `analyticsOptIn` flag is `true`, track anonymously, i.e.
* redact all matrix identifiers in tracking events.
* - If the `feature_pseudonymous_analytics_opt_in` labs flag is `true`, track pseudonomously by maintaining
* a randomised analytics ID in account_data for that user (shared between devices) and sending it to posthog to
identify the user.
* - 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.
*/
@ -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([
"register", "login", "forgot_password", "soft_logout", "new", "settings", "welcome", "home", "start", "directory",
"start_sso", "start_cas", "groups", "complete_security", "post_registration", "room", "user", "group",
@ -89,7 +86,6 @@ export async function getRedactedCurrentLocation(
anonymity: Anonymity,
): Promise<string> {
// Redact PII from the current location.
// If anonymous is true, redact entirely, if false, substitute it with a hash.
// For known screens, assumes a URL structure of /<screen name>/might/be/pii
if (origin.startsWith('file://')) {
pathname = "/<redacted_file_scheme_url>/";
@ -99,17 +95,13 @@ export async function getRedactedCurrentLocation(
if (hash == "") {
hashStr = "";
} else {
let [beforeFirstSlash, screen, ...parts] = hash.split("/");
let [beforeFirstSlash, screen] = hash.split("/");
if (!whitelistedScreens.has(screen)) {
screen = "<redacted_screen_name>";
}
for (let i = 0; i < parts.length; i++) {
parts[i] = anonymity === Anonymity.Anonymous ? `<redacted>` : await hashHex(parts[i]);
}
hashStr = `${beforeFirstSlash}/${screen}/${parts.join("/")}`;
hashStr = `${beforeFirstSlash}/${screen}/<redacted>`;
}
return origin + pathname + hashStr;
}
@ -123,15 +115,15 @@ export class PosthogAnalytics {
/* Wrapper for Posthog analytics.
* 3 modes of anonymity are supported, governed by this.anonymity
* - Anonymity.Disabled means *no data* is passed to posthog
* - Anonymity.Anonymous means all identifers will be redacted before being passed to posthog
* - Anonymity.Pseudonymous means all identifiers will be hashed via SHA-256 before being passed
* to Posthog
* - Anonymity.Anonymous means no identifier is passed to posthog
* - Anonymity.Pseudonymous means an analytics ID stored in account_data and shared between devices
* is passed to posthog.
*
* To update anonymity, call updateAnonymityFromSettings() or you can set it directly via setAnonymity().
*
* To pass an event to Posthog:
*
* 1. Declare a type for the event, extending IAnonymousEvent, IPseudonymousEvent or IRoomEvent.
* 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
* Anonymous or Disabled; Anonymous events will be dropped when anonymity is Disabled.
*/
@ -141,6 +133,7 @@ export class PosthogAnalytics {
private enabled = false;
private static _instance = null;
private platformSuperProperties = {};
private static ANALYTICS_ID_EVENT_TYPE = "im.vector.web.analytics_id";
public static get instance(): PosthogAnalytics {
if (!this._instance) {
@ -274,9 +267,32 @@ export class PosthogAnalytics {
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) {
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);
}
public async trackRoomEvent<E extends IRoomEvent>(
eventName: E["eventName"],
roomId: string,
properties: Omit<E["properties"], "roomId">,
): Promise<void> {
const updatedProperties = {
...properties,
hashedRoomId: roomId ? await hashHex(roomId) : null,
};
await this.trackPseudonymousEvent(eventName, updatedProperties);
}
public async trackPageView(durationMs: number): Promise<void> {
const hash = window.location.hash;
@ -349,7 +353,7 @@ export class PosthogAnalytics {
// Identify the user (via hashed user ID) to posthog if anonymity is pseudonmyous
this.setAnonymity(PosthogAnalytics.getAnonymityFromSettings());
if (userId && this.getAnonymity() == Anonymity.Pseudonymous) {
await this.identifyUser(userId);
await this.identifyUser(MatrixClientPeg.get(), PosthogAnalytics.getRandomAnalyticsId);
}
}
}

View file

@ -48,11 +48,6 @@ export default class Resend {
// XXX: temporary logging to try to diagnose
// https://github.com/vector-im/element-web/issues/3148
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 BaseDialog from "../../../../components/views/dialogs/BaseDialog";
import DialogButtons from "../../../../components/views/elements/DialogButtons";
import { IDialogProps } from "../../../../components/views/dialogs/IDialogProps";
interface IProps {
onFinished: (confirmed: boolean) => void;
}
interface IProps extends IDialogProps {}
interface IState {
eventIndexSize: number;

View file

@ -322,10 +322,16 @@ export class ContextMenu extends React.PureComponent<IProps, IState> {
const menuClasses = classNames({
'mx_ContextualMenu': true,
'mx_ContextualMenu_left': !hasChevron && position.left,
'mx_ContextualMenu_right': !hasChevron && position.right,
'mx_ContextualMenu_top': !hasChevron && position.top,
'mx_ContextualMenu_bottom': !hasChevron && position.bottom,
/**
* In some cases we may get the number of 0, which still means that we're supposed to properly
* add the specific position class, but as it was falsy things didn't work as intended.
* 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_right': chevronFace === ChevronFace.Right,
'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
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;
let top = elementRect.top + (elementRect.height / 2) + window.pageYOffset;
top -= chevronOffset + 8; // where 8 is half the height of the chevron
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,
// 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 buttonRight = elementRect.right + window.pageXOffset;

View file

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

View file

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

View file

@ -146,19 +146,13 @@ class GroupFilterPanel extends React.Component<IGroupFilterPanelProps, IGroupFil
mx_GroupFilterPanel_items_selected: itemsSelected,
});
let betaDot;
if (SettingsStore.getBetaInfo("feature_spaces") && !localStorage.getItem("mx_seenSpacesBeta")) {
betaDot = <div className="mx_BetaDot" />;
}
let createButton = (
<ActionButton
tooltip
label={_t("Communities")}
action="toggle_my_groups"
className="mx_TagTile mx_TagTile_plus">
{ betaDot }
</ActionButton>
className="mx_TagTile mx_TagTile_plus"
/>
);
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.
*/
import React from "react";
import PropTypes from "prop-types";
import React, { createRef } from "react";
import AutoHideScrollbar from "./AutoHideScrollbar";
import { replaceableComponent } from "../../utils/replaceableComponent";
interface IProps {
// If true, the scrollbar will append mx_IndicatorScrollbar_leftOverflowIndicator
// and mx_IndicatorScrollbar_rightOverflowIndicator elements to the list for positioning
// by the parent element.
trackHorizontalOverflow?: boolean;
// 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
// with no vertical scroll opportunity.
verticalScrollsHorizontally?: boolean;
children: React.ReactNode;
className: string;
}
interface IState {
leftIndicatorOffset: number | string;
rightIndicatorOffset: number | string;
}
@replaceableComponent("structures.IndicatorScrollbar")
export default class IndicatorScrollbar extends React.Component {
static propTypes = {
// If true, the scrollbar will append mx_IndicatorScrollbar_leftOverflowIndicator
// and mx_IndicatorScrollbar_rightOverflowIndicator elements to the list for positioning
// by the parent element.
trackHorizontalOverflow: PropTypes.bool,
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
// 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
// with no vertical scroll opportunity.
verticalScrollsHorizontally: PropTypes.bool,
};
constructor(props) {
constructor(props: IProps) {
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 = {
leftIndicatorOffset: 0,
@ -49,30 +54,19 @@ export default class IndicatorScrollbar extends React.Component {
};
}
moveToOrigin() {
if (!this._scrollElement) return;
this._scrollElement.scrollLeft = 0;
this._scrollElement.scrollTop = 0;
}
_collectScroller(scroller) {
if (scroller && !this._scrollElement) {
this._scrollElement = scroller;
private collectScroller = (scroller: HTMLDivElement): void => {
if (scroller && !this.scrollElement) {
this.scrollElement = scroller;
// 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
this._scrollElement.addEventListener("scroll", this.checkOverflow, { passive: true });
this.scrollElement.addEventListener("scroll", this.checkOverflow, { passive: true });
this.checkOverflow();
}
}
};
_collectScrollerComponent(autoHideScrollbar) {
this._autoHideScrollbar = autoHideScrollbar;
}
componentDidUpdate(prevProps) {
const prevLen = prevProps && prevProps.children && prevProps.children.length || 0;
const curLen = this.props.children && this.props.children.length || 0;
public componentDidUpdate(prevProps: IProps): void {
const prevLen = React.Children.count(prevProps.children);
const curLen = React.Children.count(this.props.children);
// check overflow only if amount of children changes.
// if we don't guard here, we end up with an infinite
// render > componentDidUpdate > checkOverflow > setState > render loop
@ -81,62 +75,58 @@ export default class IndicatorScrollbar extends React.Component {
}
}
componentDidMount() {
public componentDidMount(): void {
this.checkOverflow();
}
checkOverflow() {
const hasTopOverflow = this._scrollElement.scrollTop > 0;
const hasBottomOverflow = this._scrollElement.scrollHeight >
(this._scrollElement.scrollTop + this._scrollElement.clientHeight);
const hasLeftOverflow = this._scrollElement.scrollLeft > 0;
const hasRightOverflow = this._scrollElement.scrollWidth >
(this._scrollElement.scrollLeft + this._scrollElement.clientWidth);
private checkOverflow = (): void => {
const hasTopOverflow = this.scrollElement.scrollTop > 0;
const hasBottomOverflow = this.scrollElement.scrollHeight >
(this.scrollElement.scrollTop + this.scrollElement.clientHeight);
const hasLeftOverflow = this.scrollElement.scrollLeft > 0;
const hasRightOverflow = this.scrollElement.scrollWidth >
(this.scrollElement.scrollLeft + this.scrollElement.clientWidth);
if (hasTopOverflow) {
this._scrollElement.classList.add("mx_IndicatorScrollbar_topOverflow");
this.scrollElement.classList.add("mx_IndicatorScrollbar_topOverflow");
} else {
this._scrollElement.classList.remove("mx_IndicatorScrollbar_topOverflow");
this.scrollElement.classList.remove("mx_IndicatorScrollbar_topOverflow");
}
if (hasBottomOverflow) {
this._scrollElement.classList.add("mx_IndicatorScrollbar_bottomOverflow");
this.scrollElement.classList.add("mx_IndicatorScrollbar_bottomOverflow");
} else {
this._scrollElement.classList.remove("mx_IndicatorScrollbar_bottomOverflow");
this.scrollElement.classList.remove("mx_IndicatorScrollbar_bottomOverflow");
}
if (hasLeftOverflow) {
this._scrollElement.classList.add("mx_IndicatorScrollbar_leftOverflow");
this.scrollElement.classList.add("mx_IndicatorScrollbar_leftOverflow");
} else {
this._scrollElement.classList.remove("mx_IndicatorScrollbar_leftOverflow");
this.scrollElement.classList.remove("mx_IndicatorScrollbar_leftOverflow");
}
if (hasRightOverflow) {
this._scrollElement.classList.add("mx_IndicatorScrollbar_rightOverflow");
this.scrollElement.classList.add("mx_IndicatorScrollbar_rightOverflow");
} else {
this._scrollElement.classList.remove("mx_IndicatorScrollbar_rightOverflow");
this.scrollElement.classList.remove("mx_IndicatorScrollbar_rightOverflow");
}
if (this.props.trackHorizontalOverflow) {
this.setState({
// 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
rightIndicatorOffset: hasRightOverflow ? `-${this._scrollElement.scrollLeft}px` : '0',
rightIndicatorOffset: hasRightOverflow ? `-${this.scrollElement.scrollLeft}px` : '0',
});
}
}
};
getScrollTop() {
return this._autoHideScrollbar.getScrollTop();
}
componentWillUnmount() {
if (this._scrollElement) {
this._scrollElement.removeEventListener("scroll", this.checkOverflow);
public componentWillUnmount(): void {
if (this.scrollElement) {
this.scrollElement.removeEventListener("scroll", this.checkOverflow);
}
}
onMouseWheel = (e) => {
if (this.props.verticalScrollsHorizontally && this._scrollElement) {
private onMouseWheel = (e: React.WheelEvent): void => {
if (this.props.verticalScrollsHorizontally && this.scrollElement) {
// 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
// strange ways. Should be positive.
@ -150,19 +140,19 @@ export default class IndicatorScrollbar extends React.Component {
// for at least the next 1 minute.
const now = new Date().getTime();
if (Math.abs(e.deltaX) > 0) {
this._likelyTrackpadUser = true;
this._checkAgainForTrackpad = now + (1 * 60 * 1000);
this.likelyTrackpadUser = true;
this.checkAgainForTrackpad = now + (1 * 60 * 1000);
} else {
// if we haven't seen any horizontal scrolling for a while, assume
// the user might have plugged in a mousewheel
if (this._likelyTrackpadUser && now >= this._checkAgainForTrackpad) {
this._likelyTrackpadUser = false;
if (this.likelyTrackpadUser && now >= this.checkAgainForTrackpad) {
this.likelyTrackpadUser = false;
}
}
// don't mess with the horizontal scroll for trackpad users
// See https://github.com/vector-im/element-web/issues/10005
if (this._likelyTrackpadUser) {
if (this.likelyTrackpadUser) {
return;
}
@ -178,13 +168,13 @@ export default class IndicatorScrollbar extends React.Component {
// noinspection JSSuspiciousNameCombination
const val = Math.abs(e.deltaY) < 25 ? (e.deltaY + additionalScroll) : e.deltaY;
this._scrollElement.scrollLeft += val * yRetention;
this.scrollElement.scrollLeft += val * yRetention;
}
}
};
render() {
// eslint-disable-next-line no-unused-vars
public render(): JSX.Element {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { children, trackHorizontalOverflow, verticalScrollsHorizontally, ...otherProps } = this.props;
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;
return (<AutoHideScrollbar
ref={this._collectScrollerComponent}
wrappedRef={this._collectScroller}
ref={this.autoHideScrollbar}
wrappedRef={this.collectScroller}
onWheel={this.onMouseWheel}
{...otherProps}
>

View file

@ -76,7 +76,6 @@ const LeftPanelWidget: React.FC = () => {
<AppTile
app={app}
fullWidth
show
showMenubar={false}
userWidget
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 CustomRoomTagPanel from './CustomRoomTagPanel';
import { mediaFromMxc } from "../../customisations/Media";
import LegacyCommunityPreview from "./LegacyCommunityPreview";
// 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.
@ -629,11 +630,15 @@ class LoggedInView extends React.Component<IProps, IState> {
pageElement = <UserView userId={this.props.currentUserId} resizeNotifier={this.props.resizeNotifier} />;
break;
case PageTypes.GroupView:
pageElement = <GroupView
groupId={this.props.currentGroupId}
isNew={this.props.currentGroupIsNew}
resizeNotifier={this.props.resizeNotifier}
/>;
if (SpaceStore.spacesEnabled) {
pageElement = <LegacyCommunityPreview groupId={this.props.currentGroupId} />;
} else {
pageElement = <GroupView
groupId={this.props.currentGroupId}
isNew={this.props.currentGroupIsNew}
resizeNotifier={this.props.resizeNotifier}
/>;
}
break;
}

View file

@ -16,25 +16,35 @@ limitations under the License.
*/
import React from 'react';
import { Resizable } from 're-resizable';
import { NumberSize, Resizable } from 're-resizable';
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")
export default class MainSplit extends React.Component {
_onResizeStart = () => {
export default class MainSplit extends React.Component<IProps> {
private onResizeStart = (): void => {
this.props.resizeNotifier.startResizing();
};
_onResize = () => {
private onResize = (): void => {
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();
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);
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 panelView = this.props.panel;
@ -56,7 +66,7 @@ export default class MainSplit extends React.Component {
let children;
if (hasResizer) {
children = <Resizable
defaultSize={this._loadSidePanelSize()}
defaultSize={this.loadSidePanelSize()}
minWidth={264}
maxWidth="50%"
enable={{
@ -69,9 +79,9 @@ export default class MainSplit extends React.Component {
bottomLeft: false,
topLeft: false,
}}
onResizeStart={this._onResizeStart}
onResize={this._onResize}
onResizeStop={this._onResizeStop}
onResizeStart={this.onResizeStart}
onResize={this.onResize}
onResizeStop={this.onResizeStop}
className="mx_RightPanel_ResizeWrapper"
handleClasses={{ left: "mx_RightPanel_ResizeHandle" }}
>

View file

@ -143,7 +143,7 @@ export enum Views {
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
// 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,
});
} else if (screen.indexOf('group/') === 0) {
if (SpaceStore.spacesEnabled) {
dis.dispatch({ action: "view_home_page" });
return;
}
const groupId = screen.substring(6);
// TODO: Check valid group ID
@ -1897,15 +1892,10 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
onSendEvent(roomId: string, event: MatrixEvent) {
const cli = MatrixClientPeg.get();
if (!cli) {
dis.dispatch({ action: 'message_send_failed' });
return;
}
if (!cli) return;
cli.sendEvent(roomId, event.getType(), event.getContent()).then(() => {
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
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
&& SettingsStore.getValue("feature_thread")) {
return false;

View file

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

View file

@ -15,95 +15,110 @@ limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import { _t, _td } from '../../languageHandler';
import { MatrixClientPeg } from '../../MatrixClientPeg';
import Resend from '../../Resend';
import dis from '../../dispatcher/dispatcher';
import { messageForResourceLimitError } from '../../utils/ErrorUtils';
import { Action } from "../../dispatcher/actions";
import { replaceableComponent } from "../../utils/replaceableComponent";
import { EventStatus } from "matrix-js-sdk/src/models/event";
import { EventStatus, MatrixEvent } from "matrix-js-sdk/src/models/event";
import NotificationBadge from "../views/rooms/NotificationBadge";
import { StaticNotificationState } from "../../stores/notifications/StaticNotificationState";
import AccessibleButton from "../views/elements/AccessibleButton";
import InlineSpinner from "../views/elements/InlineSpinner";
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_EXPANDED = 1;
const STATUS_BAR_EXPANDED_LARGE = 2;
export function getUnsentMessages(room) {
export function getUnsentMessages(room: Room): MatrixEvent[] {
if (!room) { return []; }
return room.getPendingEvents().filter(function(ev) {
return ev.status === EventStatus.NOT_SENT;
});
}
interface IProps {
// the room this statusbar is representing.
room: Room;
// 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.
isPeeking?: boolean;
// callback for when the user clicks on the 'resend all' button in the
// 'unsent messages' bar
onResendAllClick?: () => void;
// callback for when the user clicks on the 'cancel all' button in the
// 'unsent messages' bar
onCancelAllClick?: () => void;
// callback for when the user clicks on the 'invite others' button in the
// 'you are alone' bar
onInviteClick?: () => void;
// 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
// component.
onResize?: () => void;
// callback for when the status bar can be hidden from view, as it is
// not displaying anything
onHidden?: () => void;
// callback for when the status bar is displaying something and should
// be visible
onVisible?: () => void;
}
interface IState {
syncState: SyncState;
syncStateData: ISyncStateData;
unsentMessages: MatrixEvent[];
isResending: boolean;
}
@replaceableComponent("structures.RoomStatusBar")
export default class RoomStatusBar extends React.PureComponent {
static propTypes = {
// the room this statusbar is representing.
room: PropTypes.object.isRequired,
export default class RoomStatusBar extends React.PureComponent<IProps, IState> {
public static contextType = MatrixClientContext;
// 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.
isPeeking: PropTypes.bool,
constructor(props: IProps, context: typeof MatrixClientContext) {
super(props, context);
// callback for when the user clicks on the 'resend all' button in the
// 'unsent messages' bar
onResendAllClick: PropTypes.func,
// callback for when the user clicks on the 'cancel all' button in the
// 'unsent messages' bar
onCancelAllClick: PropTypes.func,
// callback for when the user clicks on the 'invite others' button in the
// 'you are alone' bar
onInviteClick: PropTypes.func,
// 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
// component.
onResize: PropTypes.func,
// callback for when the status bar can be hidden from view, as it is
// not displaying anything
onHidden: PropTypes.func,
// callback for when the status bar is displaying something and should
// be visible
onVisible: PropTypes.func,
};
state = {
syncState: MatrixClientPeg.get().getSyncState(),
syncStateData: MatrixClientPeg.get().getSyncStateData(),
unsentMessages: getUnsentMessages(this.props.room),
isResending: false,
};
componentDidMount() {
MatrixClientPeg.get().on("sync", this.onSyncStateChange);
MatrixClientPeg.get().on("Room.localEchoUpdated", this._onRoomLocalEchoUpdated);
this._checkSize();
this.state = {
syncState: this.context.getSyncState(),
syncStateData: this.context.getSyncStateData(),
unsentMessages: getUnsentMessages(this.props.room),
isResending: false,
};
}
componentDidUpdate() {
this._checkSize();
public componentDidMount(): void {
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...
const client = MatrixClientPeg.get();
const client = this.context;
if (client) {
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") {
return;
}
@ -113,7 +128,7 @@ export default class RoomStatusBar extends React.PureComponent {
});
};
_onResendAllClick = () => {
private onResendAllClick = (): void => {
Resend.resendUnsentEvents(this.props.room).then(() => {
this.setState({ isResending: false });
});
@ -121,12 +136,12 @@ export default class RoomStatusBar extends React.PureComponent {
dis.fire(Action.FocusSendMessageComposer);
};
_onCancelAllClick = () => {
private onCancelAllClick = (): void => {
Resend.cancelUnsentEvents(this.props.room);
dis.fire(Action.FocusSendMessageComposer);
};
_onRoomLocalEchoUpdated = (event, room, oldEventId, oldStatus) => {
private onRoomLocalEchoUpdated = (ev: MatrixEvent, room: Room) => {
if (room.roomId !== this.props.room.roomId) return;
const messages = getUnsentMessages(this.props.room);
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
_checkSize() {
if (this._getSize()) {
private checkSize(): void {
if (this.getSize()) {
if (this.props.onVisible) this.props.onVisible();
} else {
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
// changed - so we use '0' to indicate normal size, and other values to
// indicate other sizes.
_getSize() {
if (this._shouldShowConnectionError()) {
private getSize(): number {
if (this.shouldShowConnectionError()) {
return STATUS_BAR_EXPANDED;
} else if (this.state.unsentMessages.length > 0 || this.state.isResending) {
return STATUS_BAR_EXPANDED_LARGE;
@ -156,7 +171,7 @@ export default class RoomStatusBar extends React.PureComponent {
return STATUS_BAR_HIDDEN;
}
_shouldShowConnectionError() {
private shouldShowConnectionError(): boolean {
// no conn bar trumps the "some not sent" msg since you can't resend without
// a connection!
// 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(
this.state.syncStateData &&
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;
}
_getUnsentMessageContent() {
private getUnsentMessageContent(): JSX.Element {
const unsentMessages = this.state.unsentMessages;
let title;
@ -221,10 +236,10 @@ export default class RoomStatusBar extends React.PureComponent {
}
let buttonRow = <>
<AccessibleButton onClick={this._onCancelAllClick} className="mx_RoomStatusBar_unsentCancelAllBtn">
<AccessibleButton onClick={this.onCancelAllClick} className="mx_RoomStatusBar_unsentCancelAllBtn">
{ _t("Delete all") }
</AccessibleButton>
<AccessibleButton onClick={this._onResendAllClick} className="mx_RoomStatusBar_unsentResendAllBtn">
<AccessibleButton onClick={this.onResendAllClick} className="mx_RoomStatusBar_unsentResendAllBtn">
{ _t("Retry all") }
</AccessibleButton>
</>;
@ -260,8 +275,8 @@ export default class RoomStatusBar extends React.PureComponent {
</>;
}
render() {
if (this._shouldShowConnectionError()) {
public render(): JSX.Element {
if (this.shouldShowConnectionError()) {
return (
<div className="mx_RoomStatusBar">
<div role="alert">
@ -287,7 +302,7 @@ export default class RoomStatusBar extends React.PureComponent {
}
if (this.state.unsentMessages.length > 0 || this.state.isResending) {
return this._getUnsentMessageContent();
return this.getUnsentMessageContent();
}
return null;

View file

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

View file

@ -57,12 +57,20 @@ import { Key } from "../../Keyboard";
import { IState, RovingTabIndexProvider, useRovingTabIndex } from "../../accessibility/RovingTabIndex";
import { getDisplayAliasForRoom } from "./RoomDirectory";
import MatrixClientContext from "../../contexts/MatrixClientContext";
import { useEventEmitterState } from "../../hooks/useEventEmitter";
import { IOOBData } from "../../stores/ThreepidInviteStore";
interface IProps {
space: Room;
initialText?: string;
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 {
@ -71,7 +79,7 @@ interface ITileProps {
selected?: boolean;
numChildRooms?: number;
hasPermissions?: boolean;
onViewRoomClick(autoJoin: boolean): void;
onViewRoomClick(autoJoin: boolean, roomType: RoomType): void;
onToggleClick?(): void;
}
@ -87,7 +95,8 @@ const Tile: React.FC<ITileProps> = ({
}) => {
const cli = useContext(MatrixClientContext);
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"));
const [showChildren, toggleShowChildren] = useStateToggle(true);
@ -96,12 +105,12 @@ const Tile: React.FC<ITileProps> = ({
const onPreviewClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
onViewRoomClick(false);
onViewRoomClick(false, room.room_type as RoomType);
};
const onJoinClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
onViewRoomClick(true);
onViewRoomClick(true, room.room_type as RoomType);
};
let button;
@ -278,7 +287,13 @@ const Tile: React.FC<ITileProps> = ({
</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);
// 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,
// XXX: This logic is duplicated from the JS SDK which would normally decide what the name is.
name: room.name || roomAlias || _t("Unnamed room"),
},
roomType,
} as IOOBData,
});
};
@ -313,7 +329,7 @@ interface IHierarchyLevelProps {
hierarchy: RoomHierarchy;
parents: 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;
}
@ -351,8 +367,8 @@ export const HierarchyLevel = ({
room={room}
suggested={hierarchy.isSuggested(root.room_id, room.room_id)}
selected={selectedMap?.get(root.room_id)?.has(room.room_id)}
onViewRoomClick={(autoJoin) => {
onViewRoomClick(room.room_id, autoJoin);
onViewRoomClick={(autoJoin, roomType) => {
onViewRoomClick(room.room_id, autoJoin, roomType);
}}
hasPermissions={hasPermissions}
onToggleClick={onToggleClick ? () => onToggleClick(root.room_id, room.room_id) : undefined}
@ -371,8 +387,8 @@ export const HierarchyLevel = ({
}).length}
suggested={hierarchy.isSuggested(root.room_id, space.room_id)}
selected={selectedMap?.get(root.room_id)?.has(space.room_id)}
onViewRoomClick={(autoJoin) => {
onViewRoomClick(space.room_id, autoJoin);
onViewRoomClick={(autoJoin, roomType) => {
onViewRoomClick(space.room_id, autoJoin, roomType);
}}
hasPermissions={hasPermissions}
onToggleClick={onToggleClick ? () => onToggleClick(root.room_id, space.room_id) : undefined}
@ -574,7 +590,7 @@ const SpaceHierarchy = ({
const { loading, rooms, hierarchy, loadMore } = useSpaceSummary(space);
const filteredRoomSet = useMemo<Set<IHierarchyRoom>>(() => {
if (!rooms.length) return new Set();
if (!rooms?.length) return new Set();
const lcQuery = query.toLowerCase().trim();
if (!lcQuery) return new Set(rooms);
@ -650,8 +666,8 @@ const SpaceHierarchy = ({
parents={new Set()}
selectedMap={selected}
onToggleClick={hasPermissions ? onToggleClick : undefined}
onViewRoomClick={(roomId, autoJoin) => {
showRoom(cli, hierarchy, roomId, autoJoin);
onViewRoomClick={(roomId, autoJoin, roomType) => {
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 Spinner from "../views/elements/Spinner";
import GroupAvatar from "../views/avatars/GroupAvatar";
import { useDispatcher } from "../../hooks/useDispatcher";
interface IProps {
space: Room;
@ -155,10 +156,10 @@ const SpaceInfo = ({ space }) => {
</div>;
};
const onBetaClick = () => {
const onPreferencesClick = () => {
defaultDispatcher.dispatch({
action: Action.ViewUserSettings,
initialTabId: UserTab.Labs,
initialTabId: UserTab.Preferences,
});
};
@ -191,6 +192,11 @@ interface ISpacePreviewProps {
const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }: ISpacePreviewProps) => {
const cli = useContext(MatrixClientContext);
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);
@ -280,15 +286,11 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }: ISp
if (!spacesEnabled) {
footer = <div className="mx_SpaceRoomView_preview_spaceBetaPrompt">
{ myMembership === "join"
? _t("To view %(spaceName)s, turn on the <a>Spaces beta</a>", {
spaceName: space.name,
}, {
a: sub => <AccessibleButton onClick={onBetaClick} kind="link">{ sub }</AccessibleButton>,
? _t("To view this Space, hide communities in your <a>preferences</a>", {}, {
a: sub => <AccessibleButton onClick={onPreferencesClick} kind="link">{ sub }</AccessibleButton>,
})
: _t("To join %(spaceName)s, turn on the <a>Spaces beta</a>", {
spaceName: space.name,
}, {
a: sub => <AccessibleButton onClick={onBetaClick} kind="link">{ sub }</AccessibleButton>,
: _t("To join this Space, hide communities in your <a>preferences</a>", {}, {
a: sub => <AccessibleButton onClick={onPreferencesClick} kind="link">{ sub }</AccessibleButton>,
})
}
</div>;
@ -725,7 +727,7 @@ const SpaceSetupPrivateInvite = ({ space, onFinished }) => {
</div>
<div className="mx_SpaceRoomView_inviteTeammates_betaDisclaimer">
<BetaPill onClick={onBetaClick} />
<BetaPill />
{ _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.", {}, {
b: sub => <b>{ sub }</b>,

View file

@ -16,7 +16,7 @@ limitations under the License.
import React from 'react';
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 { RightPanelPhases } from "../../stores/RightPanelStorePhases";
@ -46,13 +46,13 @@ export default class ThreadPanel extends React.Component<IProps, IState> {
}
public componentDidMount(): void {
this.room.on("Thread.update", this.onThreadEventReceived);
this.room.on("Thread.ready", this.onThreadEventReceived);
this.room.on(ThreadEvent.Update, this.onThreadEventReceived);
this.room.on(ThreadEvent.Ready, this.onThreadEventReceived);
}
public componentWillUnmount(): void {
this.room.removeListener("Thread.update", this.onThreadEventReceived);
this.room.removeListener("Thread.ready", this.onThreadEventReceived);
this.room.removeListener(ThreadEvent.Update, this.onThreadEventReceived);
this.room.removeListener(ThreadEvent.Ready, this.onThreadEventReceived);
}
private onThreadEventReceived = () => this.updateThreads();

View file

@ -16,7 +16,7 @@ limitations under the License.
import React from 'react';
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 { 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);
mxEv.setThread(thread);
}
thread.on("Thread.update", this.updateThread);
thread.once("Thread.ready", this.updateThread);
thread.on(ThreadEvent.Update, this.updateThread);
thread.once(ThreadEvent.Ready, this.updateThread);
this.updateThread(thread);
};
private teardownThread = () => {
if (this.state.thread) {
this.state.thread.removeListener("Thread.update", this.updateThread);
this.state.thread.removeListener("Thread.ready", this.updateThread);
this.state.thread.removeListener(ThreadEvent.Update, 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 PropTypes from "prop-types";
import { MatrixClientPeg } from "../../MatrixClientPeg";
import * as sdk from "../../index";
import Modal from '../../Modal';
import { _t } from '../../languageHandler';
import HomePage from "./HomePage";
import { replaceableComponent } from "../../utils/replaceableComponent";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
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")
export default class UserView extends React.Component {
static get propTypes() {
return {
userId: PropTypes.string,
export default class UserView extends React.Component<IProps, IState> {
constructor(props: IProps) {
super(props);
this.state = {
loading: true,
};
}
constructor(props) {
super(props);
this.state = {};
}
componentDidMount() {
public componentDidMount(): void {
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
// it as optional and MatrixChat sometimes fires in a way which results
// in an NPE when we try to update the profile info.
if (prevProps.userId !== this.props.userId && this.props.userId) {
this._loadProfileInfo();
this.loadProfileInfo();
}
}
async _loadProfileInfo() {
private async loadProfileInfo(): Promise<void> {
const cli = MatrixClientPeg.get();
this.setState({ loading: true });
let profileInfo;
try {
profileInfo = await cli.getProfileInfo(this.props.userId);
} catch (err) {
const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog');
Modal.createTrackedDialog(_t('Could not load user profile'), '', ErrorDialog, {
title: _t('Could not load user profile'),
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 });
}
render() {
public render(): JSX.Element {
if (this.state.loading) {
const Spinner = sdk.getComponent("elements.Spinner");
return <Spinner />;
} else if (this.state.member) {
const RightPanel = sdk.getComponent('structures.RightPanel');
const MainSplit = sdk.getComponent('structures.MainSplit');
const panel = <RightPanel user={this.state.member} />;
} else if (this.state.member?.user) {
const panel = <RightPanel user={this.state.member.user} resizeNotifier={this.props.resizeNotifier} />;
return (<MainSplit panel={panel} resizeNotifier={this.props.resizeNotifier}>
<HomePage />
</MainSplit>);

View file

@ -17,24 +17,28 @@ limitations under the License.
*/
import React from "react";
import PropTypes from "prop-types";
import SyntaxHighlight from "../views/elements/SyntaxHighlight";
import { _t } from "../../languageHandler";
import * as sdk from "../../index";
import MatrixClientContext from "../../contexts/MatrixClientContext";
import { SendCustomEvent } from "../views/dialogs/DevtoolsDialog";
import { canEditContent } from "../../utils/EventUtils";
import { MatrixClientPeg } from '../../MatrixClientPeg';
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")
export default class ViewSource extends React.Component {
static propTypes = {
onFinished: PropTypes.func.isRequired,
mxEvent: PropTypes.object.isRequired, // the MatrixEvent associated with the context menu
};
constructor(props) {
export default class ViewSource extends React.Component<IProps, IState> {
constructor(props: IProps) {
super(props);
this.state = {
@ -42,19 +46,20 @@ export default class ViewSource extends React.Component {
};
}
onBack() {
private onBack(): void {
// TODO: refresh the "Event ID:" modal header
this.setState({ isEditing: false });
}
onEdit() {
private onEdit(): void {
this.setState({ isEditing: true });
}
// 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 isEncrypted = mxEvent.isEncrypted();
// @ts-ignore
const decryptedEventSource = mxEvent.clearEvent; // FIXME: clearEvent is private
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
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 isEncrypted = mxEvent.isEncrypted();
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
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 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 room = cli.getRoom(mxEvent.getRoomId());
return room.currentState.mayClientSendStateEvent(mxEvent.getType(), cli);
}
render() {
const BaseDialog = sdk.getComponent("views.dialogs.BaseDialog");
public render(): JSX.Element {
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;

View file

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

View file

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

View file

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

View file

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

View file

@ -23,10 +23,9 @@ import Modal from '../../../Modal';
import BaseDialog from "./BaseDialog";
import DialogButtons from "../elements/DialogButtons";
import QuestionDialog from "./QuestionDialog";
import { IDialogProps } from "./IDialogProps";
interface IProps {
onFinished: (success: boolean) => void;
}
interface IProps extends IDialogProps {}
const CryptoStoreTooNewDialog: React.FC<IProps> = (props: IProps) => {
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"
placeholder={_t("Search for rooms or people")}
onSearch={setQuery}
autoComplete={true}
autoFocus={true}
/>
<AutoHideScrollbar className="mx_ForwardList_content">

View file

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

View file

@ -126,7 +126,6 @@ const ManageRestrictedJoinRuleDialog: React.FC<IProps> = ({ room, selected = [],
className="mx_textinput_icon mx_textinput_search"
placeholder={_t("Search spaces")}
onSearch={setQuery}
autoComplete={true}
autoFocus={true}
/>
<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 { MatrixClientPeg } from "../../../MatrixClientPeg";
import E2EIcon from "../rooms/E2EIcon";
import E2EIcon, { E2EState } from "../rooms/E2EIcon";
import AccessibleButton from "../elements/AccessibleButton";
import BaseDialog from "./BaseDialog";
import { IDialogProps } from "./IDialogProps";
@ -47,7 +47,7 @@ const UntrustedDeviceDialog: React.FC<IProps> = ({ device, user, onFinished }) =
onFinished={onFinished}
className="mx_UntrustedDeviceDialog"
title={<>
<E2EIcon status="warning" size={24} hideTooltip={true} />
<E2EIcon status={E2EState.Warning} size={24} hideTooltip={true} />
{ _t("Not Trusted") }
</>}
>

View file

@ -19,7 +19,6 @@ limitations under the License.
import url from 'url';
import React, { createRef } from 'react';
import PropTypes from 'prop-types';
import { MatrixClientPeg } from '../../../MatrixClientPeg';
import AccessibleButton from './AccessibleButton';
import { _t } from '../../../languageHandler';
@ -39,33 +38,95 @@ import { MatrixCapabilities } from "matrix-widget-api";
import RoomWidgetContextMenu from "../context_menus/WidgetContextMenu";
import WidgetAvatar from "../avatars/WidgetAvatar";
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")
export default class AppTile extends React.Component {
constructor(props) {
export default class AppTile extends React.Component<IProps, IState> {
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);
// The key used for PersistedElement
this._persistKey = getPersistKey(this.props.app.id);
this.persistKey = getPersistKey(this.props.app.id);
try {
this._sgWidget = new StopGapWidget(this.props);
this._sgWidget.on("preparing", this._onWidgetPrepared);
this._sgWidget.on("ready", this._onWidgetReady);
this.sgWidget = new StopGapWidget(this.props);
this.sgWidget.on("preparing", this.onWidgetPrepared);
this.sgWidget.on("ready", this.onWidgetReady);
} catch (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._contextMenuButton = createRef();
this.state = this.getNewState(props);
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
hasPermissionToLoad = (props) => {
if (this._usingLocalWidget()) return true;
private hasPermissionToLoad = (props: IProps): boolean => {
if (this.usingLocalWidget()) return true;
if (!props.room) return true; // user widgets always have permissions
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
* @return {Object} Updated component state to be set with setState
*/
_getNewState(newProps) {
private getNewState(newProps: IProps): IState {
return {
initialising: true, // True while we are mangling the widget URL
// 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
// added it to the room, or if explicitly granted by the user
hasPermissionToLoad: this.hasPermissionToLoad(newProps),
error: null,
widgetPageTitle: newProps.widgetPageTitle,
menuDisplayed: false,
widgetPageTitle: this.props.widgetPageTitle,
};
}
onAllowedWidgetsChange = () => {
private onAllowedWidgetsChange = (): void => {
const hasPermissionToLoad = this.hasPermissionToLoad(this.props);
if (this.state.hasPermissionToLoad && !hasPermissionToLoad) {
// Force the widget to be non-persistent (able to be deleted/forgotten)
ActiveWidgetStore.destroyPersistentWidget(this.props.app.id);
PersistedElement.destroyElement(this._persistKey);
if (this._sgWidget) this._sgWidget.stop();
PersistedElement.destroyElement(this.persistKey);
if (this.sgWidget) this.sgWidget.stop();
}
this.setState({ hasPermissionToLoad });
};
isMixedContent() {
private isMixedContent(): boolean {
const parentContentProtocol = window.location.protocol;
const u = url.parse(this.props.app.url);
const childContentProtocol = u.protocol;
@ -120,69 +181,70 @@ export default class AppTile extends React.Component {
return false;
}
componentDidMount() {
public componentDidMount(): void {
// Only fetch IM token on mount if we're showing and have permission to load
if (this._sgWidget && this.state.hasPermissionToLoad) {
this._startWidget();
if (this.sgWidget && this.state.hasPermissionToLoad) {
this.startWidget();
}
// Widget action listeners
this.dispatcherRef = dis.register(this._onAction);
this.dispatcherRef = dis.register(this.onAction);
}
componentWillUnmount() {
public componentWillUnmount(): void {
// Widget action listeners
if (this.dispatcherRef) dis.unregister(this.dispatcherRef);
// if it's not remaining on screen, get rid of the PersistedElement container
if (!ActiveWidgetStore.getWidgetPersistence(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();
}
SettingsStore.unwatchSetting(this._allowedWidgetsWatchRef);
SettingsStore.unwatchSetting(this.allowedWidgetsWatchRef);
}
_resetWidget(newProps) {
if (this._sgWidget) {
this._sgWidget.stop();
private resetWidget(newProps: IProps): void {
if (this.sgWidget) {
this.sgWidget.stop();
}
try {
this._sgWidget = new StopGapWidget(newProps);
this._sgWidget.on("preparing", this._onWidgetPrepared);
this._sgWidget.on("ready", this._onWidgetReady);
this._startWidget();
this.sgWidget = new StopGapWidget(newProps);
this.sgWidget.on("preparing", this.onWidgetPrepared);
this.sgWidget.on("ready", this.onWidgetReady);
this.startWidget();
} catch (e) {
console.log("Failed to construct widget", e);
this._sgWidget = null;
this.sgWidget = null;
}
}
_startWidget() {
this._sgWidget.prepare().then(() => {
private startWidget(): void {
this.sgWidget.prepare().then(() => {
this.setState({ initialising: false });
});
}
_iframeRefChange = (ref) => {
private iframeRefChange = (ref: HTMLIFrameElement): void => {
this.iframe = ref;
if (ref) {
if (this._sgWidget) this._sgWidget.start(ref);
if (this.sgWidget) this.sgWidget.start(ref);
} else {
this._resetWidget(this.props);
this.resetWidget(this.props);
}
};
// 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) {
this._getNewState(nextProps);
this.getNewState(nextProps);
if (this.state.hasPermissionToLoad) {
this._resetWidget(nextProps);
this.resetWidget(nextProps);
}
}
@ -198,7 +260,7 @@ export default class AppTile extends React.Component {
* @private
* @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
// 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
@ -217,27 +279,27 @@ export default class AppTile extends React.Component {
}
// Delete the widget from the persisted store for good measure.
PersistedElement.destroyElement(this._persistKey);
PersistedElement.destroyElement(this.persistKey);
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 });
};
_onWidgetReady = () => {
private onWidgetReady = (): void => {
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) {
switch (payload.action) {
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: 'stickerpicker_close' });
} else {
@ -248,7 +310,7 @@ export default class AppTile extends React.Component {
}
};
_grantWidgetPermission = () => {
private grantWidgetPermission = (): void => {
const roomId = this.props.room.roomId;
console.info("Granting permission for widget to load: " + this.props.app.eventId);
const current = SettingsStore.getValue("allowedWidgets", roomId);
@ -258,14 +320,14 @@ export default class AppTile extends React.Component {
this.setState({ hasPermissionToLoad: true });
// Fetch a token for the integration manager, now that we're allowed to
this._startWidget();
this.startWidget();
}).catch(err => {
console.error(err);
// 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";
if (this.props.app.name && 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
* @returns {bool} true If using a local version of the widget
*/
_usingLocalWidget() {
private usingLocalWidget(): boolean {
return WidgetType.JITSI.matches(this.props.app.type);
}
_getTileTitle() {
private getTileTitle(): JSX.Element {
const name = this.formatAppTileName();
const titleSpacer = <span>&nbsp;-&nbsp;</span>;
let title = '';
@ -300,32 +362,32 @@ export default class AppTile extends React.Component {
}
// 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
// twice from the same computer, which Jitsi can have problems with (audio echo/gain-loop).
if (WidgetType.JITSI.matches(this.props.app.type)) {
this._endWidgetActions().then(() => {
this.endWidgetActions().then(() => {
if (this.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.
// window.open(this._getPopoutUrl(), '_blank', 'noopener=yes');
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 });
};
_closeContextMenu = () => {
private closeContextMenu = (): void => {
this.setState({ menuDisplayed: false });
};
render() {
public render(): JSX.Element {
let appTileBody;
// 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...")} />
</div>
);
if (this._sgWidget === null) {
if (this.sgWidget === null) {
appTileBody = (
<div className={appTileBodyClass} style={appTileBodyStyles}>
<AppWarning errorMsg={_t("Error loading Widget")} />
@ -365,9 +427,9 @@ export default class AppTile extends React.Component {
<AppPermission
roomId={this.props.room.roomId}
creatorUserId={this.props.creatorUserId}
url={this._sgWidget.embedUrl}
url={this.sgWidget.embedUrl}
isRoomEncrypted={isEncrypted}
onPermissionGranted={this._grantWidgetPermission}
onPermissionGranted={this.grantWidgetPermission}
/>
</div>
);
@ -390,8 +452,8 @@ export default class AppTile extends React.Component {
{ this.state.loading && loadingElement }
<iframe
allow={iframeFeatures}
ref={this._iframeRefChange}
src={this._sgWidget.embedUrl}
ref={this.iframeRefChange}
src={this.sgWidget.embedUrl}
allowFullScreen={true}
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
// AppTile's border is in the wrong place
appTileBody = <div className="mx_AppTile_persistedWrapper">
<PersistedElement persistKey={this._persistKey}>
<PersistedElement persistKey={this.persistKey}>
{ appTileBody }
</PersistedElement>
</div>;
@ -429,9 +491,9 @@ export default class AppTile extends React.Component {
if (this.state.menuDisplayed) {
contextMenu = (
<RoomWidgetContextMenu
{...aboveLeftOf(this._contextMenuButton.current.getBoundingClientRect(), null)}
{...aboveLeftOf(this.contextMenuButton.current.getBoundingClientRect(), null)}
app={this.props.app}
onFinished={this._closeContextMenu}
onFinished={this.closeContextMenu}
showUnpin={!this.props.userWidget}
userWidget={this.props.userWidget}
onEditClick={this.props.onEditClick}
@ -444,21 +506,21 @@ export default class AppTile extends React.Component {
<div className={appTileClasses} id={this.props.app.id}>
{ this.props.showMenubar &&
<div className="mx_AppTileMenuBar">
<span className="mx_AppTileMenuBarTitle" style={{ pointerEvents: (this.props.handleMinimisePointerEvents ? 'all' : false) }}>
{ this.props.showTitle && this._getTileTitle() }
<span className="mx_AppTileMenuBarTitle" style={{ pointerEvents: (this.props.handleMinimisePointerEvents ? 'all' : "none") }}>
{ this.props.showTitle && this.getTileTitle() }
</span>
<span className="mx_AppTileMenuBarWidgets">
{ this.props.showPopout && <AccessibleButton
className="mx_AppTileMenuBar_iconButton mx_AppTileMenuBar_iconButton_popout"
title={_t('Popout widget')}
onClick={this._onPopoutWidgetClick}
onClick={this.onPopoutWidgetClick}
/> }
<ContextMenuButton
className="mx_AppTileMenuBar_iconButton mx_AppTileMenuBar_iconButton_menu"
label={_t("Options")}
isExpanded={this.state.menuDisplayed}
inputRef={this._contextMenuButton}
onClick={this._onContextMenuClick}
inputRef={this.contextMenuButton}
onClick={this.onContextMenuClick}
/>
</span>
</div> }
@ -469,49 +531,3 @@ export default class AppTile extends React.Component {
</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 PropTypes from 'prop-types';
import React from 'react';
const AppWarning = (props) => {
interface IProps {
errorMsg?: string;
}
const AppWarning: React.FC<IProps> = (props) => {
return (
<div className='mx_AppPermissionWarning'>
<div className='mx_AppPermissionWarningImage'>
<img src={require("../../../../res/img/warning.svg")} alt='' />
</div>
<div className='mx_AppPermissionWarningText'>
<span className='mx_AppPermissionWarningTextLabel'>{ props.errorMsg }</span>
<span className='mx_AppPermissionWarningTextLabel'>{ props.errorMsg || "Error" }</span>
</div>
</div>
);
};
AppWarning.propTypes = {
errorMsg: PropTypes.string,
};
AppWarning.defaultProps = {
errorMsg: 'Error',
};
export default AppWarning;

View file

@ -17,60 +17,61 @@ limitations under the License.
*/
import React from "react";
import PropTypes from "prop-types";
import { _t } from '../../../languageHandler';
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.
*/
@replaceableComponent("views.elements.DialogButtons")
export default class DialogButtons extends React.Component {
static propTypes = {
// 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 = {
export default class DialogButtons extends React.Component<IProps> {
public static defaultProps: Partial<IProps> = {
hasCancel: true,
disabled: false,
};
_onCancelClick = () => {
this.props.onCancel();
private onCancelClick = (event: React.MouseEvent): void => {
this.props.onCancel(event);
};
render() {
public render(): JSX.Element {
let primaryButtonClassName = "mx_Dialog_primary";
if (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
// primary in the DOM so will get form submissions unless we make it not a submit.
type="button"
onClick={this._onCancelClick}
onClick={this.onCancelClick}
className={this.props.cancelButtonClass}
disabled={this.props.disabled}
>

View file

@ -14,71 +14,73 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import * as sdk from '../../../index';
import React, { ChangeEvent, createRef } from 'react';
import { _t } from '../../../languageHandler';
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")
export default class DirectorySearchBox extends React.Component {
constructor(props) {
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);
export default class DirectorySearchBox extends React.Component<IProps, IState> {
private input = createRef<HTMLInputElement>();
this.input = null;
constructor(props: IProps) {
super(props);
this.state = {
value: this.props.initialText || '',
};
}
_collectInput(e) {
this.input = e;
}
_onClearClick() {
private onClearClick = (): void => {
this.setState({ value: '' });
if (this.input) {
this.input.focus();
if (this.input.current) {
this.input.current.focus();
if (this.props.onClear) {
this.props.onClear();
}
}
}
};
_onChange(ev) {
if (!this.input) return;
private onChange = (ev: ChangeEvent<HTMLInputElement>): void => {
if (!this.input.current) return;
this.setState({ value: ev.target.value });
if (this.props.onChange) {
this.props.onChange(ev.target.value);
}
}
};
_onKeyUp(ev) {
private onKeyUp = (ev: React.KeyboardEvent): void => {
if (ev.key == 'Enter' && this.props.showJoinButton) {
if (this.props.onJoinClick) {
this.props.onJoinClick(this.state.value);
}
}
}
};
_onJoinButtonClick() {
private onJoinButtonClick = (): void => {
if (this.props.onJoinClick) {
this.props.onJoinClick(this.state.value);
}
}
render() {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
};
public render(): JSX.Element {
const searchboxClasses = {
mx_DirectorySearchBox: true,
};
@ -87,7 +89,7 @@ export default class DirectorySearchBox extends React.Component {
let joinButton;
if (this.props.showJoinButton) {
joinButton = <AccessibleButton className="mx_DirectorySearchBox_joinButton"
onClick={this._onJoinButtonClick}
onClick={this.onJoinButtonClick}
>{ _t("Join") }</AccessibleButton>;
}
@ -97,24 +99,15 @@ export default class DirectorySearchBox extends React.Component {
name="dirsearch"
value={this.state.value}
className="mx_textinput_icon mx_textinput_search"
ref={this._collectInput}
onChange={this._onChange}
onKeyUp={this._onKeyUp}
ref={this.input}
onChange={this.onChange}
onKeyUp={this.onKeyUp}
placeholder={this.props.placeholder}
autoFocus
/>
{ joinButton }
<AccessibleButton className="mx_DirectorySearchBox_clear" onClick={this._onClearClick} />
<AccessibleButton className="mx_DirectorySearchBox_clear" onClick={this.onClearClick} />
</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 PropTypes from 'prop-types';
import { Key } from "../../../Keyboard";
import { replaceableComponent } from "../../../utils/replaceableComponent";
enum Phases {
Display = "display",
Edit = "edit",
}
interface IProps {
onValueChanged?: (value: string, shouldSubmit: boolean) => void;
initialValue?: string;
label?: string;
placeholder?: string;
className?: string;
labelClassName?: string;
placeholderClassName?: string;
// Overrides blurToSubmit if true
blurToCancel?: boolean;
// Will cause onValueChanged(value, true) to fire on blur
blurToSubmit?: boolean;
editable?: boolean;
}
interface IState {
phase: Phases;
}
@replaceableComponent("views.elements.EditableText")
export default class EditableText extends React.Component {
static propTypes = {
onValueChanged: PropTypes.func,
initialValue: PropTypes.string,
label: PropTypes.string,
placeholder: PropTypes.string,
className: PropTypes.string,
labelClassName: PropTypes.string,
placeholderClassName: PropTypes.string,
// Overrides blurToSubmit if true
blurToCancel: PropTypes.bool,
// Will cause onValueChanged(value, true) to fire on blur
blurToSubmit: PropTypes.bool,
editable: PropTypes.bool,
};
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>();
static Phases = {
Display: "display",
Edit: "edit",
};
static defaultProps = {
public static defaultProps: Partial<IProps> = {
onValueChanged() {},
initialValue: '',
label: '',
@ -53,81 +62,61 @@ export default class EditableText extends React.Component {
blurToSubmit: false,
};
constructor(props) {
constructor(props: IProps) {
super(props);
// we track value as an JS object field rather than in React state
// as React doesn't play nice with contentEditable.
this.value = '';
this.placeholder = false;
this._editable_div = createRef();
this.state = {
phase: Phases.Display,
};
}
state = {
phase: EditableText.Phases.Display,
};
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
// eslint-disable-next-line camelcase
UNSAFE_componentWillReceiveProps(nextProps) {
// eslint-disable-next-line @typescript-eslint/naming-convention, camelcase
public UNSAFE_componentWillReceiveProps(nextProps: IProps): void {
if (nextProps.initialValue !== this.props.initialValue) {
this.value = nextProps.initialValue;
if (this._editable_div.current) {
if (this.editableDiv.current) {
this.showPlaceholder(!this.value);
}
}
}
componentDidMount() {
public componentDidMount(): void {
this.value = this.props.initialValue;
if (this._editable_div.current) {
if (this.editableDiv.current) {
this.showPlaceholder(!this.value);
}
}
showPlaceholder = show => {
private showPlaceholder = (show: boolean): void => {
if (show) {
this._editable_div.current.textContent = this.props.placeholder;
this._editable_div.current.setAttribute("class", this.props.className
this.editableDiv.current.textContent = this.props.placeholder;
this.editableDiv.current.setAttribute("class", this.props.className
+ " " + this.props.placeholderClassName);
this.placeholder = true;
this.value = '';
} else {
this._editable_div.current.textContent = this.value;
this._editable_div.current.setAttribute("class", this.props.className);
this.editableDiv.current.textContent = this.value;
this.editableDiv.current.setAttribute("class", this.props.className);
this.placeholder = false;
}
};
getValue = () => this.value;
setValue = value => {
this.value = value;
this.showPlaceholder(!this.value);
};
edit = () => {
private cancelEdit = (): void => {
this.setState({
phase: EditableText.Phases.Edit,
});
};
cancelEdit = () => {
this.setState({
phase: EditableText.Phases.Display,
phase: Phases.Display,
});
this.value = this.props.initialValue;
this.showPlaceholder(!this.value);
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);
};
onKeyDown = ev => {
private onKeyDown = (ev: React.KeyboardEvent<HTMLDivElement>): void => {
// console.log("keyDown: textContent=" + ev.target.textContent + ", value=" + this.value + ", placeholder=" + 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);
};
onKeyUp = ev => {
private onKeyUp = (ev: React.KeyboardEvent<HTMLDivElement>): void => {
// 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);
} else if (!this.placeholder) {
this.value = ev.target.textContent;
this.value = (ev.target as HTMLDivElement).textContent;
}
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);
};
onClickDiv = ev => {
private onClickDiv = (): void => {
if (!this.props.editable) return;
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);
const node = ev.target.childNodes[0];
if (node) {
const range = document.createRange();
range.setStart(node, 0);
range.setEnd(node, node.length);
range.setEnd(node, ev.target.childNodes.length);
const sel = window.getSelection();
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 submit = (ev.key === Key.ENTER) || shouldSubmit;
const submit = ("key" in ev && ev.key === Key.ENTER) || shouldSubmit;
this.setState({
phase: EditableText.Phases.Display,
phase: Phases.Display,
}, () => {
if (this.value !== this.props.initialValue) {
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();
sel.removeAllRanges();
@ -208,11 +201,11 @@ export default class EditableText extends React.Component {
this.showPlaceholder(!this.value);
};
render() {
public render(): JSX.Element {
const { className, editable, initialValue, label, labelClassName } = this.props;
let editableEl;
if (!editable || (this.state.phase === EditableText.Phases.Display &&
if (!editable || (this.state.phase === Phases.Display &&
(label || labelClassName) && !this.value)
) {
// show the label
@ -222,7 +215,7 @@ export default class EditableText extends React.Component {
} else {
// show the content editable div, but manually manage its contents as react and contentEditable don't play nice together
editableEl = <div
ref={this._editable_div}
ref={this.editableDiv}
contentEditable={true}
className={className}
onKeyDown={this.onKeyDown}

View file

@ -15,9 +15,34 @@ limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import * as sdk from '../../../index';
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
@ -31,50 +56,51 @@ import { replaceableComponent } from "../../../utils/replaceableComponent";
* taken from the 'initialValue' property.
*/
@replaceableComponent("views.elements.EditableTextContainer")
export default class EditableTextContainer extends React.Component {
constructor(props) {
export default class EditableTextContainer extends React.Component<IProps, IState> {
private unmounted = false;
public static defaultProps: Partial<IProps> = {
initialValue: "",
placeholder: "",
blurToSubmit: false,
onSubmit: () => { return Promise.resolve(); },
};
constructor(props: IProps) {
super(props);
this._unmounted = false;
this.state = {
busy: false,
errorString: null,
value: props.initialValue,
};
this._onValueChanged = this._onValueChanged.bind(this);
}
componentDidMount() {
if (this.props.getInitialValue === undefined) {
// use whatever was given in the initialValue property.
return;
}
public async componentDidMount(): Promise<void> {
// use whatever was given in the initialValue property.
if (this.props.getInitialValue === undefined) return;
this.setState({ busy: true });
this.props.getInitialValue().then(
(result) => {
if (this._unmounted) { return; }
this.setState({
busy: false,
value: result,
});
},
(error) => {
if (this._unmounted) { return; }
this.setState({
errorString: error.toString(),
busy: false,
});
},
);
try {
const initialValue = await this.props.getInitialValue();
if (this.unmounted) return;
this.setState({
busy: false,
value: initialValue,
});
} catch (error) {
if (this.unmounted) return;
this.setState({
errorString: error.toString(),
busy: false,
});
}
}
componentWillUnmount() {
this._unmounted = true;
public componentWillUnmount(): void {
this.unmounted = true;
}
_onValueChanged(value, shouldSubmit) {
private onValueChanged = (value: string, shouldSubmit: boolean): void => {
if (!shouldSubmit) {
return;
}
@ -86,38 +112,36 @@ export default class EditableTextContainer extends React.Component {
this.props.onSubmit(value).then(
() => {
if (this._unmounted) { return; }
if (this.unmounted) { return; }
this.setState({
busy: false,
value: value,
});
},
(error) => {
if (this._unmounted) { return; }
if (this.unmounted) { return; }
this.setState({
errorString: error.toString(),
busy: false,
});
},
);
}
};
render() {
public render(): JSX.Element {
if (this.state.busy) {
const Loader = sdk.getComponent("elements.Spinner");
return (
<Loader />
<Spinner />
);
} else if (this.state.errorString) {
return (
<div className="error">{ this.state.errorString }</div>
);
} else {
const EditableText = sdk.getComponent('elements.EditableText');
return (
<EditableText initialValue={this.state.value}
placeholder={this.props.placeholder}
onValueChanged={this._onValueChanged}
onValueChanged={this.onValueChanged}
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 PropTypes from 'prop-types';
import * as sdk from '../../../index';
import * as languageHandler from '../../../languageHandler';
import SettingsStore from "../../../settings/SettingsStore";
import { _t } from "../../../languageHandler";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import Spinner from "./Spinner";
import Dropdown from "./Dropdown";
function languageMatchesSearchQuery(query, language) {
if (language.label.toUpperCase().includes(query.toUpperCase())) return true;
@ -30,11 +30,22 @@ function languageMatchesSearchQuery(query, language) {
return false;
}
interface IProps {
className?: string;
onOptionChange: (language: string) => void;
value?: string;
disabled?: boolean;
}
interface IState {
searchQuery: string;
langs: string[];
}
@replaceableComponent("views.elements.LanguageDropdown")
export default class LanguageDropdown extends React.Component {
constructor(props) {
export default class LanguageDropdown extends React.Component<IProps, IState> {
constructor(props: IProps) {
super(props);
this._onSearchChange = this._onSearchChange.bind(this);
this.state = {
searchQuery: '',
@ -42,7 +53,7 @@ export default class LanguageDropdown extends React.Component {
};
}
componentDidMount() {
public componentDidMount(): void {
languageHandler.getAllLanguagesFromJson().then((langs) => {
langs.sort(function(a, b) {
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({
searchQuery: search,
});
}
};
render() {
public render(): JSX.Element {
if (this.state.langs === null) {
const Spinner = sdk.getComponent('elements.Spinner');
return <Spinner />;
}
const Dropdown = sdk.getComponent('elements.Dropdown');
let displayedLanguages;
if (this.state.searchQuery) {
displayedLanguages = this.state.langs.filter((lang) => {
@ -107,7 +115,7 @@ export default class LanguageDropdown extends React.Component {
id="mx_LanguageDropdown"
className={this.props.className}
onOptionChange={this.props.onOptionChange}
onSearchChange={this._onSearchChange}
onSearchChange={this.onSearchChange}
searchEnabled={true}
value={value}
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 PropTypes from 'prop-types';
import { replaceableComponent } from "../../../utils/replaceableComponent";
class ItemRange {
constructor(topCount, renderCount, bottomCount) {
this.topCount = topCount;
this.renderCount = renderCount;
this.bottomCount = bottomCount;
}
constructor(
public topCount: number,
public renderCount: number,
public bottomCount: number,
) { }
contains(range) {
public contains(range: ItemRange): boolean {
// don't contain empty ranges
// as it will prevent clearing the list
// once it is scrolled far enough out of view
@ -36,7 +35,7 @@ class ItemRange {
(range.topCount + range.renderCount) <= (this.topCount + this.renderCount);
}
expand(amount) {
public expand(amount: number): ItemRange {
// don't expand ranges that won't render anything
if (this.renderCount === 0) {
return this;
@ -51,20 +50,55 @@ class ItemRange {
);
}
totalSize() {
public totalSize(): number {
return this.topCount + this.renderCount + this.bottomCount;
}
}
interface IProps<T> {
// height in pixels of the component returned by `renderItem`
itemHeight: number;
// 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;
element?: string;
className?: string;
}
interface IState {
renderRange: ItemRange;
}
@replaceableComponent("views.elements.LazyRenderList")
export default class LazyRenderList extends React.Component {
constructor(props) {
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 = {};
this.state = {
renderRange: null,
};
}
static getDerivedStateFromProps(props, state) {
public static getDerivedStateFromProps(props: IProps<unknown>, state: IState): Partial<IState> {
const range = LazyRenderList.getVisibleRangeFromProps(props);
const intersectRange = range.expand(props.overflowMargin);
const renderRange = range.expand(props.overflowItems);
@ -77,7 +111,7 @@ export default class LazyRenderList extends React.Component {
return null;
}
static getVisibleRangeFromProps(props) {
private static getVisibleRangeFromProps(props: IProps<unknown>): ItemRange {
const { items, itemHeight, scrollTop, height } = props;
const length = items ? items.length : 0;
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);
}
render() {
public render(): JSX.Element {
const { itemHeight, items, renderItem } = this.props;
const { renderRange } = this.state;
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);
return _t('%(nameList)s %(transitionList)s', { nameList: nameList, transitionList: desc });
return _t('%(nameList)s %(transitionList)s', { nameList, transitionList: desc });
});
if (!summaries) {

View file

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

View file

@ -19,57 +19,70 @@ import React from 'react';
import RoomViewStore from '../../../stores/RoomViewStore';
import ActiveWidgetStore from '../../../stores/ActiveWidgetStore';
import WidgetUtils from '../../../utils/WidgetUtils';
import * as sdk from '../../../index';
import { MatrixClientPeg } from '../../../MatrixClientPeg';
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")
export default class PersistentApp extends React.Component {
state = {
roomId: RoomViewStore.getRoomId(),
persistentWidgetId: ActiveWidgetStore.getPersistentWidgetId(),
};
export default class PersistentApp extends React.Component<{}, IState> {
private roomStoreToken: EventSubscription;
componentDidMount() {
this._roomStoreToken = RoomViewStore.addListener(this._onRoomViewStoreUpdate);
ActiveWidgetStore.on('update', this._onActiveWidgetStoreUpdate);
MatrixClientPeg.get().on("Room.myMembership", this._onMyMembership);
constructor() {
super({});
this.state = {
roomId: RoomViewStore.getRoomId(),
persistentWidgetId: ActiveWidgetStore.getPersistentWidgetId(),
};
}
componentWillUnmount() {
if (this._roomStoreToken) {
this._roomStoreToken.remove();
public componentDidMount(): void {
this.roomStoreToken = RoomViewStore.addListener(this.onRoomViewStoreUpdate);
ActiveWidgetStore.on('update', this.onActiveWidgetStoreUpdate);
MatrixClientPeg.get().on("Room.myMembership", this.onMyMembership);
}
public componentWillUnmount(): void {
if (this.roomStoreToken) {
this.roomStoreToken.remove();
}
ActiveWidgetStore.removeListener('update', this._onActiveWidgetStoreUpdate);
ActiveWidgetStore.removeListener('update', this.onActiveWidgetStoreUpdate);
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;
this.setState({
roomId: RoomViewStore.getRoomId(),
});
};
_onActiveWidgetStoreUpdate = () => {
private onActiveWidgetStoreUpdate = (): void => {
this.setState({
persistentWidgetId: ActiveWidgetStore.getPersistentWidgetId(),
});
};
_onMyMembership = async (room, membership) => {
private onMyMembership = async (room: Room, membership: string): Promise<void> => {
const persistentWidgetInRoomId = ActiveWidgetStore.getRoomId(this.state.persistentWidgetId);
if (membership !== "join") {
// we're not in the room anymore - delete
if (room.roomId === persistentWidgetInRoomId) {
if (room .roomId === persistentWidgetInRoomId) {
ActiveWidgetStore.destroyPersistentWidget(this.state.persistentWidgetId);
}
}
};
render() {
public render(): JSX.Element {
if (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(),
persistentWidgetInRoomId, appEvent.getId(),
);
const AppTile = sdk.getComponent('elements.AppTile');
return <AppTile
key={app.id}
app={app}

View file

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

View file

@ -17,25 +17,34 @@
import React from 'react';
import { replaceableComponent } from "../../../utils/replaceableComponent";
interface IProps {
reason?: string;
contentHtml: string;
}
interface IState {
visible: boolean;
}
@replaceableComponent("views.elements.Spoiler")
export default class Spoiler extends React.Component {
constructor(props) {
export default class Spoiler extends React.Component<IProps, IState> {
constructor(props: IProps) {
super(props);
this.state = {
visible: false,
};
}
toggleVisible(e) {
private toggleVisible = (e: React.MouseEvent): void => {
if (!this.state.visible) {
// we are un-blurring, we don't want this click to propagate to potential child pills
e.preventDefault();
e.stopPropagation();
}
this.setState({ visible: !this.state.visible });
}
};
render() {
public render(): JSX.Element {
const reason = this.props.reason ? (
<span className="mx_EventTile_spoiler_reason">{ "(" + this.props.reason + ")" }</span>
) : 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
// HTML content. This is secure as the contents have already been parsed previously
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 }
&nbsp;
<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 PropTypes from 'prop-types';
import { highlightBlock } from 'highlight.js';
import { replaceableComponent } from "../../../utils/replaceableComponent";
interface IProps {
className?: string;
children?: React.ReactNode;
}
@replaceableComponent("views.elements.SyntaxHighlight")
export default class SyntaxHighlight extends React.Component {
static propTypes = {
className: PropTypes.string,
children: PropTypes.node,
};
export default class SyntaxHighlight extends React.Component<IProps> {
private el: HTMLPreElement = null;
constructor(props) {
constructor(props: IProps) {
super(props);
this._ref = this._ref.bind(this);
}
// componentDidUpdate used here for reusability
componentDidUpdate() {
if (this._el) highlightBlock(this._el);
public componentDidUpdate(): void {
if (this.el) highlightBlock(this.el);
}
// call componentDidUpdate because _ref is fired on initial render
// which does not fire componentDidUpdate
_ref(el) {
this._el = el;
private ref = (el: HTMLPreElement): void => {
this.el = el;
this.componentDidUpdate();
}
};
render() {
public render(): JSX.Element {
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>
</pre>;
}
}

View file

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

View file

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

View file

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

View file

@ -429,7 +429,7 @@ const UserOptionsSection: React.FC<{
if (!isMe) {
directMessageButton = (
<AccessibleButton onClick={() => { openDMForUser(cli, member.userId); }} className="mx_UserInfo_field">
{ _t('Direct message') }
{ _t("Message") }
</AccessibleButton>
);
}
@ -826,7 +826,7 @@ const RoomAdminToolsContainer: React.FC<IBaseRoomProps> = ({
if (canAffectUser && me.powerLevel >= banPowerLevel) {
banButton = <BanToggleButton member={member} startUpdating={startUpdating} stopUpdating={stopUpdating} />;
}
if (canAffectUser && me.powerLevel >= editPowerLevel) {
if (canAffectUser && me.powerLevel >= editPowerLevel && !room.isSpaceRoom()) {
muteButton = (
<MuteToggleButton
member={member}
@ -1052,8 +1052,7 @@ const PowerLevelEditor: React.FC<{
const cli = useContext(MatrixClientContext);
const [selectedPowerLevel, setSelectedPowerLevel] = useState(user.powerLevel);
const onPowerChange = useCallback(async (powerLevelStr: string) => {
const powerLevel = parseInt(powerLevelStr, 10);
const onPowerChange = useCallback(async (powerLevel: number) => {
setSelectedPowerLevel(powerLevel);
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 { _t } from "../../../languageHandler";
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 Spinner from "../elements/Spinner";
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.
body = <React.Fragment>
<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">
<AccessibleButton
kind="danger"
@ -252,7 +252,7 @@ export default class VerificationPanel extends React.PureComponent<IProps, IStat
<div className="mx_UserInfo_container mx_VerificationPanel_verified_section">
<h3>{ _t("Verified") }</h3>
<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 }
<AccessibleButton kind="primary" className="mx_UserInfo_wideButton" onClick={this.props.onClose}>
{ _t("Got it") }

View file

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

View file

@ -15,27 +15,43 @@ limitations under the License.
*/
import React, { createRef } from 'react';
import PropTypes from 'prop-types';
import { _t } from "../../../languageHandler";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import Field from "../elements/Field";
import * as sdk from "../../../index";
import { replaceableComponent } from "../../../utils/replaceableComponent";
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?
@replaceableComponent("views.room_settings.RoomProfileSettings")
export default class RoomProfileSettings extends React.Component {
static propTypes = {
roomId: PropTypes.string.isRequired,
};
export default class RoomProfileSettings extends React.Component<IProps, IState> {
private avatarUpload = createRef<HTMLInputElement>();
constructor(props) {
constructor(props: IProps) {
super(props);
const client = MatrixClientPeg.get();
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", "");
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()),
canSetAvatar: room.currentState.maySendStateEvent('m.room.avatar', client.getUserId()),
};
this._avatarUpload = createRef();
}
_uploadAvatar = () => {
this._avatarUpload.current.click();
private uploadAvatar = (): void => {
this.avatarUpload.current.click();
};
_removeAvatar = () => {
private removeAvatar = (): void => {
// clear file upload field so same file can be selected
this._avatarUpload.current.value = "";
this.avatarUpload.current.value = "";
this.setState({
avatarUrl: 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.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.preventDefault();
@ -100,35 +114,46 @@ export default class RoomProfileSettings extends React.Component {
this.setState({ enableProfileSave: false });
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?
const displayName = this.state.displayName.trim();
if (this.state.originalDisplayName !== this.state.displayName) {
await client.setRoomName(this.props.roomId, displayName);
newState.originalDisplayName = displayName;
newState.displayName = displayName;
originalDisplayName = displayName;
}
if (this.state.avatarFile) {
const uri = await client.uploadContent(this.state.avatarFile);
await client.sendStateEvent(this.props.roomId, 'm.room.avatar', { url: uri }, '');
newState.avatarUrl = mediaFromMxc(uri).getSquareThumbnailHttp(96);
newState.originalAvatarUrl = newState.avatarUrl;
newState.avatarFile = null;
avatarUrl = mediaFromMxc(uri).getSquareThumbnailHttp(96);
originalAvatarUrl = avatarUrl;
avatarFile = null;
} else if (this.state.originalAvatarUrl !== this.state.avatarUrl) {
await client.sendStateEvent(this.props.roomId, 'm.room.avatar', {}, '');
}
if (this.state.originalTopic !== 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 });
if (this.state.originalDisplayName === e.target.value) {
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 });
if (this.state.originalTopic === e.target.value) {
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) {
this.setState({
avatarUrl: this.state.originalAvatarUrl,
@ -160,7 +185,7 @@ export default class RoomProfileSettings extends React.Component {
const reader = new FileReader();
reader.onload = (ev) => {
this.setState({
avatarUrl: ev.target.result,
avatarUrl: String(ev.target.result),
avatarFile: file,
enableProfileSave: true,
});
@ -168,10 +193,7 @@ export default class RoomProfileSettings extends React.Component {
reader.readAsDataURL(file);
};
render() {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
const AvatarSetting = sdk.getComponent('settings.AvatarSetting');
public render(): JSX.Element {
let profileSettingsButtons;
if (
this.state.canSetName ||
@ -181,14 +203,14 @@ export default class RoomProfileSettings extends React.Component {
profileSettingsButtons = (
<div className="mx_ProfileSettings_buttons">
<AccessibleButton
onClick={this._cancelProfileChanges}
onClick={this.cancelProfileChanges}
kind="link"
disabled={!this.state.enableProfileSave}
>
{ _t("Cancel") }
</AccessibleButton>
<AccessibleButton
onClick={this._saveProfile}
onClick={this.saveProfile}
kind="primary"
disabled={!this.state.enableProfileSave}
>
@ -200,16 +222,16 @@ export default class RoomProfileSettings extends React.Component {
return (
<form
onSubmit={this._saveProfile}
onSubmit={this.saveProfile}
autoComplete="off"
noValidate={true}
className="mx_ProfileSettings_profileForm"
>
<input
type="file"
ref={this._avatarUpload}
ref={this.avatarUpload}
className="mx_ProfileSettings_avatarUpload"
onChange={this._onAvatarChanged}
onChange={this.onAvatarChanged}
accept="image/*"
/>
<div className="mx_ProfileSettings_profile">
@ -219,7 +241,7 @@ export default class RoomProfileSettings extends React.Component {
type="text"
value={this.state.displayName}
autoComplete="off"
onChange={this._onDisplayNameChanged}
onChange={this.onDisplayNameChanged}
disabled={!this.state.canSetName}
/>
<Field
@ -230,7 +252,7 @@ export default class RoomProfileSettings extends React.Component {
type="text"
value={this.state.topic}
autoComplete="off"
onChange={this._onTopicChanged}
onChange={this.onTopicChanged}
element="textarea"
/>
</div>
@ -238,8 +260,8 @@ export default class RoomProfileSettings extends React.Component {
avatarUrl={this.state.avatarUrl}
avatarName={this.state.displayName || this.props.roomId}
avatarAltText={_t("Room avatar")}
uploadAvatar={this.state.canSetAvatar ? this._uploadAvatar : undefined}
removeAvatar={this.state.canSetAvatar ? this._removeAvatar : undefined} />
uploadAvatar={this.state.canSetAvatar ? this.uploadAvatar : undefined}
removeAvatar={this.state.canSetAvatar ? this.removeAvatar : undefined} />
</div>
{ profileSettingsButtons }
</form>

View file

@ -18,8 +18,6 @@ limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import * as sdk from "../../../index";
import { _t, _td } from '../../../languageHandler';
import SettingsStore from "../../../settings/SettingsStore";
import dis from "../../../dispatcher/dispatcher";
@ -27,21 +25,22 @@ import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { Action } from "../../../dispatcher/actions";
import { SettingLevel } from "../../../settings/SettingLevel";
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")
export default class UrlPreviewSettings extends React.Component {
static propTypes = {
room: PropTypes.object,
};
_onClickUserSettings = (e) => {
export default class UrlPreviewSettings extends React.Component<IProps> {
private onClickUserSettings = (e: React.MouseEvent): void => {
e.preventDefault();
e.stopPropagation();
dis.fire(Action.ViewUserSettings);
};
render() {
const SettingsFlag = sdk.getComponent("elements.SettingsFlag");
public render(): JSX.Element {
const roomId = this.props.room.roomId;
const isEncrypted = MatrixClientPeg.get().isRoomEncrypted(roomId);
@ -54,18 +53,18 @@ export default class UrlPreviewSettings extends React.Component {
if (accountEnabled) {
previewsForAccount = (
_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 {
previewsForAccount = (
_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 = (
<label>
<SettingsFlag

View file

@ -16,7 +16,6 @@ limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { Resizable } from "re-resizable";
@ -26,8 +25,6 @@ import * as sdk from '../../../index';
import * as ScalarMessaging from '../../../ScalarMessaging';
import WidgetUtils from '../../../utils/WidgetUtils';
import WidgetEchoStore from "../../../stores/WidgetEchoStore";
import { IntegrationManagers } from "../../../integrations/IntegrationManagers";
import SettingsStore from "../../../settings/SettingsStore";
import ResizeNotifier from "../../../utils/ResizeNotifier";
import ResizeHandle from "../elements/ResizeHandle";
import Resizer from "../../../resizer/resizer";
@ -37,60 +34,74 @@ import { clamp, percentageOf, percentageWithin } from "../../../utils/numbers";
import { useStateCallback } from "../../../hooks/useStateCallback";
import { replaceableComponent } from "../../../utils/replaceableComponent";
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")
export default class AppsDrawer extends React.Component {
static propTypes = {
userId: PropTypes.string.isRequired,
room: PropTypes.object.isRequired,
resizeNotifier: PropTypes.instanceOf(ResizeNotifier).isRequired,
showApps: PropTypes.bool, // Should apps be rendered
};
static defaultProps = {
export default class AppsDrawer extends React.Component<IProps, IState> {
private resizeContainer: HTMLDivElement;
private resizer: Resizer;
private dispatcherRef: string;
public static defaultProps: Partial<IProps> = {
showApps: true,
};
constructor(props) {
constructor(props: IProps) {
super(props);
this.state = {
apps: this._getApps(),
resizingVertical: false, // true when changing the height of the apps drawer
resizingHorizontal: false, // true when chagning the distribution of the width between widgets
apps: this.getApps(),
resizingVertical: false,
resizingHorizontal: false,
resizing: false,
};
this._resizeContainer = null;
this.resizer = this._createResizer();
this.resizer = this.createResizer();
this.props.resizeNotifier.on("isResizing", this.onIsResizing);
}
componentDidMount() {
public componentDidMount(): void {
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);
}
componentWillUnmount() {
public componentWillUnmount(): void {
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._resizeContainer) {
if (this.resizeContainer) {
this.resizer.detach();
}
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.setState({ resizingVertical: 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
// (ie. a vertical resize handle because, the handle itself is vertical...)
const classNames = {
@ -100,11 +111,11 @@ export default class AppsDrawer extends React.Component {
};
const collapseConfig = {
onResizeStart: () => {
this._resizeContainer.classList.add("mx_AppsDrawer_resizing");
this.resizeContainer.classList.add("mx_AppsDrawer_resizing");
this.setState({ resizingHorizontal: true });
},
onResizeStop: () => {
this._resizeContainer.classList.remove("mx_AppsDrawer_resizing");
this.resizeContainer.classList.remove("mx_AppsDrawer_resizing");
WidgetLayoutStore.instance.setResizerDistributions(
this.props.room, Container.Top,
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
const resizer = new Resizer({}, PercentageDistributor, collapseConfig);
const resizer = new Resizer(null, PercentageDistributor, collapseConfig);
resizer.setClassNames(classNames);
return resizer;
}
_collectResizer = (ref) => {
if (this._resizeContainer) {
private collectResizer = (ref: HTMLDivElement): void => {
if (this.resizeContainer) {
this.resizer.detach();
}
@ -127,22 +138,22 @@ export default class AppsDrawer extends React.Component {
this.resizer.container = ref;
this.resizer.attach();
}
this._resizeContainer = ref;
this._loadResizerPreferences();
this.resizeContainer = ref;
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) {
// Room has changed, update apps
this._updateApps();
} else if (this._getAppsHash(this.state.apps) !== this._getAppsHash(prevState.apps)) {
this._loadResizerPreferences();
this.updateApps();
} else if (this.getAppsHash(this.state.apps) !== this.getAppsHash(prevState.apps)) {
this.loadResizerPreferences();
}
}
_relaxResizer = () => {
private relaxResizer = (): void => {
const distributors = this.resizer.getDistributors();
// 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());
};
_loadResizerPreferences = () => {
private loadResizerPreferences = (): void => {
const distributions = WidgetLayoutStore.instance.getResizerDistributions(this.props.room, Container.Top);
if (this.state.apps && (this.state.apps.length - 1) === distributions.length) {
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;
}
onAction = (action) => {
private onAction = (action: ActionPayload): void => {
const hideWidgetKey = this.props.room.roomId + '_hide_widget_drawer';
switch (action.action) {
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({
apps: this._getApps(),
apps: this.getApps(),
});
};
_launchManageIntegrations() {
if (SettingsStore.getValue("feature_many_integration_managers")) {
IntegrationManagers.sharedInstance().openAll();
} else {
IntegrationManagers.sharedInstance().getPrimaryManager().open(this.props.room, 'add_integ');
}
}
render() {
public render(): JSX.Element {
if (!this.props.showApps) return <div />;
const apps = this.state.apps.map((app, index, arr) => {
@ -257,7 +260,7 @@ export default class AppsDrawer extends React.Component {
className="mx_AppsContainer_resizer"
resizeNotifier={this.props.resizeNotifier}
>
<div className="mx_AppsContainer" ref={this._collectResizer}>
<div className="mx_AppsContainer" ref={this.collectResizer}>
{ apps.map((app, i) => {
if (i < 1) return app;
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,
minHeight,
maxHeight,
@ -303,7 +317,7 @@ const PersistentVResizer = ({
});
return <Resizable
size={{ height: Math.min(height, maxHeight) }}
size={{ height: Math.min(height, maxHeight), width: null }}
minHeight={minHeight}
maxHeight={maxHeight}
onResizeStart={() => {

View file

@ -50,7 +50,8 @@ import { AutocompleteAction, getKeyBindingsManager, MessageComposerAction } from
import { replaceableComponent } from "../../../utils/replaceableComponent";
// 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;
@ -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 range = model.startRange(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) => {
const part = model.parts[index];
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) {
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
@ -180,18 +181,25 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
if (data) {
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
// because we'll replace the whole range with an emoji,
// so move the start forward to the start of the emoticon.
// 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
// 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 => {
renderModel(this.editorRef.current, this.props.model);
@ -607,8 +615,7 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
};
private configureEmoticonAutoReplace = (): void => {
const shouldReplace = SettingsStore.getValue('MessageComposerInput.autoReplaceEmoji');
this.props.model.setTransformCallback(shouldReplace ? this.replaceEmoticon : null);
this.props.model.setTransformCallback(this.transform);
};
private configureShouldShowPillAvatar = (): void => {
@ -621,6 +628,11 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
this.setState({ surroundWith });
};
private transform = (documentPosition: DocumentPosition): void => {
const shouldReplace = SettingsStore.getValue('MessageComposerInput.autoReplaceEmoji');
if (shouldReplace) this.replaceEmoticon(documentPosition, REGEX_EMOTICON_WHITESPACE);
};
componentWillUnmount() {
document.removeEventListener("selectionchange", this.onSelectionChange);
this.editorRef.current.removeEventListener("input", this.onInput, true);

View file

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

View file

@ -27,7 +27,7 @@ import { findEditableEvent } from '../../../utils/EventUtils';
import { parseEvent } from '../../../editor/deserialize';
import { CommandPartCreator, Part, PartCreator, Type } from '../../../editor/parts';
import EditorStateTransfer from '../../../utils/EditorStateTransfer';
import BasicMessageComposer from "./BasicMessageComposer";
import BasicMessageComposer, { REGEX_EMOTICON } from "./BasicMessageComposer";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { Command, CommandCategories, getCommand } from '../../../SlashCommands';
import { Action } from "../../../dispatcher/actions";
@ -42,6 +42,7 @@ import ErrorDialog from "../dialogs/ErrorDialog";
import QuestionDialog from "../dialogs/QuestionDialog";
import { ActionPayload } from "../../../dispatcher/payloads";
import AccessibleButton from '../elements/AccessibleButton';
import SettingsStore from "../../../settings/SettingsStore";
function getHtmlReplyFallback(mxEvent: MatrixEvent): string {
const html = mxEvent.getContent().formatted_body;
@ -315,6 +316,14 @@ export default class EditMessageComposer extends React.Component<IProps, IState>
private sendEdit = async (): Promise<void> => {
const startTime = CountlyAnalytics.getTimestamp();
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 newContent = editContent["m.new_content"];

View file

@ -20,7 +20,7 @@ import React from 'react';
import AccessibleButton from '../elements/AccessibleButton';
import { _td } from '../../../languageHandler';
import classNames from "classnames";
import E2EIcon from './E2EIcon';
import E2EIcon, { E2EState } from './E2EIcon';
import { replaceableComponent } from "../../../utils/replaceableComponent";
import BaseAvatar from '../avatars/BaseAvatar';
import PresenceLabel from "./PresenceLabel";
@ -75,7 +75,7 @@ interface IProps {
suppressOnHover?: boolean;
showPresence?: boolean;
subtextLabel?: string;
e2eStatus?: string;
e2eStatus?: E2EState;
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 { Relations } from "matrix-js-sdk/src/models/relations";
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 { _t } from '../../../languageHandler';
@ -33,7 +33,7 @@ import { formatTime } from "../../../DateUtils";
import { MatrixClientPeg } from '../../../MatrixClientPeg';
import { ALL_RULE_TYPES } from "../../../mjolnir/BanList";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { E2E_STATE } from "./E2EIcon";
import { E2EState } from "./E2EIcon";
import { toRem } from "../../../utils/units";
import { WidgetType } from "../../../widgets/WidgetType";
import RoomAvatar from "../avatars/RoomAvatar";
@ -464,8 +464,8 @@ export default class EventTile extends React.Component<IProps, IState> {
}
if (SettingsStore.getValue("feature_thread")) {
this.props.mxEvent.once("Thread.ready", this.updateThread);
this.props.mxEvent.on("Thread.update", this.updateThread);
this.props.mxEvent.once(ThreadEvent.Ready, 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 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;
}
@ -605,7 +605,7 @@ export default class EventTile extends React.Component<IProps, IState> {
if (encryptionInfo.mismatchedSender) {
// something definitely wrong is going on here
this.setState({
verified: E2E_STATE.WARNING,
verified: E2EState.Warning,
}, this.props.onHeightChanged); // Decryption may have caused a change in size
return;
}
@ -613,7 +613,7 @@ export default class EventTile extends React.Component<IProps, IState> {
if (!userTrust.isCrossSigningVerified()) {
// user is not verified, so default to everything is normal
this.setState({
verified: E2E_STATE.NORMAL,
verified: E2EState.Normal,
}, this.props.onHeightChanged); // Decryption may have caused a change in size
return;
}
@ -623,27 +623,27 @@ export default class EventTile extends React.Component<IProps, IState> {
);
if (!eventSenderTrust) {
this.setState({
verified: E2E_STATE.UNKNOWN,
verified: E2EState.Unknown,
}, this.props.onHeightChanged); // Decryption may have caused a change in size
return;
}
if (!eventSenderTrust.isVerified()) {
this.setState({
verified: E2E_STATE.WARNING,
verified: E2EState.Warning,
}, this.props.onHeightChanged); // Decryption may have caused a change in size
return;
}
if (!encryptionInfo.authenticated) {
this.setState({
verified: E2E_STATE.UNAUTHENTICATED,
verified: E2EState.Unauthenticated,
}, this.props.onHeightChanged); // Decryption may have caused a change in size
return;
}
this.setState({
verified: E2E_STATE.VERIFIED,
verified: E2EState.Verified,
}, 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
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
} else if (this.state.verified === E2E_STATE.VERIFIED) {
} else if (this.state.verified === E2EState.Verified) {
return; // no icon for verified
} else if (this.state.verified === E2E_STATE.UNAUTHENTICATED) {
} else if (this.state.verified === E2EState.Unauthenticated) {
return (<E2ePadlockUnauthenticated />);
} else if (this.state.verified === E2E_STATE.UNKNOWN) {
} else if (this.state.verified === E2EState.Unknown) {
return (<E2ePadlockUnknown />);
} else {
return (<E2ePadlockUnverified />);
@ -961,9 +961,9 @@ export default class EventTile extends React.Component<IProps, IState> {
mx_EventTile_lastInSection: this.props.lastInSection,
mx_EventTile_contextual: this.props.contextual,
mx_EventTile_actionBarFocused: this.state.actionBarFocused,
mx_EventTile_verified: !isBubbleMessage && this.state.verified === E2E_STATE.VERIFIED,
mx_EventTile_unverified: !isBubbleMessage && this.state.verified === E2E_STATE.WARNING,
mx_EventTile_unknown: !isBubbleMessage && this.state.verified === E2E_STATE.UNKNOWN,
mx_EventTile_verified: !isBubbleMessage && this.state.verified === E2EState.Verified,
mx_EventTile_unverified: !isBubbleMessage && this.state.verified === E2EState.Warning,
mx_EventTile_unknown: !isBubbleMessage && this.state.verified === E2EState.Unknown,
mx_EventTile_bad: isEncryptionFailure,
mx_EventTile_emote: msgtype === 'm.emote',
mx_EventTile_noSender: this.props.hideSender,
@ -1192,14 +1192,19 @@ export default class EventTile extends React.Component<IProps, IState> {
}
default: {
const thread = ReplyThread.makeThread(
this.props.mxEvent,
this.props.onHeightChanged,
this.props.permalinkCreator,
this.replyThread,
this.props.layout,
this.props.alwaysShowTimestamps || this.state.hover,
);
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.onHeightChanged,
this.props.permalinkCreator,
this.replyThread,
this.props.layout,
this.props.alwaysShowTimestamps || this.state.hover,
);
}
const isOwnEvent = this.props.mxEvent?.sender?.userId === MatrixClientPeg.get().getUserId();

View file

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

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import React, { MouseEvent } from "react";
import classNames from "classnames";
import { formatCount } from "../../../utils/FormattingUtils";
import SettingsStore from "../../../settings/SettingsStore";
@ -22,6 +22,9 @@ import AccessibleButton from "../elements/AccessibleButton";
import { XOR } from "../../../@types/common";
import { NOTIFICATION_STATE_UPDATE, NotificationState } from "../../../stores/notifications/NotificationState";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import Tooltip from "../elements/Tooltip";
import { _t } from "../../../languageHandler";
import { NotificationColor } from "../../../stores/notifications/NotificationColor";
interface IProps {
notification: NotificationState;
@ -39,6 +42,7 @@ interface IProps {
}
interface IClickableProps extends IProps, React.InputHTMLAttributes<Element> {
showUnsentTooltip?: boolean;
/**
* If specified will return an AccessibleButton instead of a div.
*/
@ -47,6 +51,7 @@ interface IClickableProps extends IProps, React.InputHTMLAttributes<Element> {
interface IState {
showCounts: boolean; // whether or not to show counts. Independent of props.forceCount
showTooltip: boolean;
}
@replaceableComponent("views.rooms.NotificationBadge")
@ -59,6 +64,7 @@ export default class NotificationBadge extends React.PureComponent<XOR<IProps, I
this.state = {
showCounts: SettingsStore.getValue("Notifications.alwaysShowBadgeCounts", this.roomId),
showTooltip: false,
};
this.countWatcherRef = SettingsStore.watchSetting(
@ -93,9 +99,22 @@ export default class NotificationBadge extends React.PureComponent<XOR<IProps, I
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 {
/* 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
if (notification.isIdle) return null;
@ -124,9 +143,24 @@ export default class NotificationBadge extends React.PureComponent<XOR<IProps, I
});
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 (
<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>
{ tooltip }
</AccessibleButton>
);
}

View file

@ -547,7 +547,7 @@ export default class RoomList extends React.PureComponent<IProps, IState> {
const unfilteredHistorical = unfilteredLists[DefaultTagID.Archived] || [];
const unfilteredFavourite = unfilteredLists[DefaultTagID.Favourite] || [];
// 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">
<div>{ _t("Use the + to make a new room or explore existing ones below") }</div>
<AccessibleButton

View file

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

View file

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

View file

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

View file

@ -31,8 +31,8 @@ import {
textSerialize,
unescapeMessage,
} from '../../../editor/serialize';
import BasicMessageComposer, { REGEX_EMOTICON } from "./BasicMessageComposer";
import { CommandPartCreator, Part, PartCreator, SerializedPart, Type } from '../../../editor/parts';
import BasicMessageComposer from "./BasicMessageComposer";
import ReplyThread from "../elements/ReplyThread";
import { findEditableEvent } from '../../../utils/EventUtils';
import SendHistoryManager from "../../../SendHistoryManager";
@ -347,15 +347,24 @@ export default class SendMessageComposer extends React.Component<IProps> {
}
public async sendMessage(): Promise<void> {
if (this.model.isEmpty) {
const model = this.model;
if (model.isEmpty) {
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;
let shouldSend = true;
let content;
if (!containsEmote(this.model) && this.isSlashCommand()) {
if (!containsEmote(model) && this.isSlashCommand()) {
const [cmd, args, commandText] = this.getSlashCommand();
if (cmd) {
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;
this.sendQuickReaction();
}
@ -410,7 +419,7 @@ export default class SendMessageComposer extends React.Component<IProps> {
const { roomId } = this.props.room;
if (!content) {
content = createMessageContent(
this.model,
model,
replyToEvent,
this.props.replyInThread,
this.props.permalinkCreator,
@ -446,9 +455,9 @@ export default class SendMessageComposer extends React.Component<IProps> {
CountlyAnalytics.instance.trackSendMessage(startTime, prom, roomId, false, !!replyToEvent, content);
}
this.sendHistoryManager.save(this.model, replyToEvent);
this.sendHistoryManager.save(model, replyToEvent);
// clear composer
this.model.reset([]);
model.reset([]);
this.editorRef.current?.clearUndoHistory();
this.editorRef.current?.focus();
this.clearStoredEditorState();

View file

@ -32,6 +32,7 @@ import { replaceableComponent } from "../../../utils/replaceableComponent";
import { ActionPayload } from '../../../dispatcher/payloads';
import ScalarAuthClient from '../../../ScalarAuthClient';
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).
// 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");
// FIXME: could this use the same code as other apps?
const stickerApp = {
const stickerApp: IApp = {
id: stickerpickerWidget.id,
url: stickerpickerWidget.content.url,
name: stickerpickerWidget.content.name,
type: stickerpickerWidget.content.type,
data: stickerpickerWidget.content.data,
roomId: stickerpickerWidget.content.roomId,
eventId: stickerpickerWidget.content.eventId,
avatar_url: stickerpickerWidget.content.avatar_url,
creatorUserId: stickerpickerWidget.content.creatorUserId,
};
stickersContent = (
@ -287,9 +292,7 @@ export default class Stickerpicker extends React.PureComponent<IProps, IState> {
onEditClick={this.launchManageIntegrations}
onDeleteClick={this.removeStickerpickerWidgets}
showTitle={false}
showCancel={false}
showPopout={false}
onMinimiseClick={this.onHideStickersClick}
handleMinimisePointerEvents={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
*/

View file

@ -28,6 +28,7 @@ import { MatrixClientPeg } from "../../../MatrixClientPeg";
import Modal from "../../../Modal";
import ManageRestrictedJoinRuleDialog from "../dialogs/ManageRestrictedJoinRuleDialog";
import RoomUpgradeWarningDialog from "../dialogs/RoomUpgradeWarningDialog";
import QuestionDialog from "../dialogs/QuestionDialog";
import { upgradeRoom } from "../../../utils/RoomUpgrade";
import { arrayHasDiff } from "../../../utils/arrays";
import { useLocalEcho } from "../../../hooks/useLocalEcho";
@ -207,27 +208,50 @@ const JoinRuleSettings = ({ room, promptUpgrade, onError, beforeChange, closeSet
} else if (preferredRestrictionVersion) {
// Block this action on a room upgrade otherwise it'd make their room unjoinable
const targetVersion = preferredRestrictionVersion;
Modal.createTrackedDialog('Restricted join rule upgrade', '', RoomUpgradeWarningDialog, {
const modal = Modal.createTrackedDialog('Restricted join rule upgrade', '', RoomUpgradeWarningDialog, {
roomId: room.roomId,
targetVersion,
description: _t("This upgrade will allow members of selected spaces " +
"access to this room without an invite."),
onFinished: async (resp) => {
if (!resp?.continue) return;
const roomId = await upgradeRoom(room, targetVersion, resp.invite, true, true, true);
closeSettingsFn();
// switch to the new room in the background
dis.dispatch({
action: "view_room",
room_id: roomId,
});
// open new settings on this tab
dis.dispatch({
action: "open_room_settings",
initial_tab_id: ROOM_SECURITY_TAB,
});
},
});
const [resp] = await modal.finished;
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);
closeSettingsFn();
// switch to the new room in the background
dis.dispatch({
action: "view_room",
room_id: roomId,
});
// open new settings on this tab
dis.dispatch({
action: "open_room_settings",
initial_tab_id: ROOM_SECURITY_TAB,
});
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 room = client.getRoom(this.props.roomId);
const plEvent = room.currentState.getStateEvents(EventType.RoomPowerLevels, '');
@ -148,8 +148,6 @@ export default class RolesRoomSettingsTab extends React.Component<IProps> {
const eventsLevelPrefix = "event_levels_";
const value = parseInt(inputValue);
if (powerLevelKey.startsWith(eventsLevelPrefix)) {
// deep copy "events" object, Object.assign itself won't deep copy
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 room = client.getRoom(this.props.roomId);
const plEvent = room.currentState.getStateEvents(EventType.RoomPowerLevels, '');

View file

@ -28,7 +28,6 @@ import { replaceableComponent } from "../../../../../utils/replaceableComponent"
import SettingsFlag from '../../../elements/SettingsFlag';
import * as KeyboardShortcuts from "../../../../../accessibility/KeyboardShortcuts";
import AccessibleButton from "../../../elements/AccessibleButton";
import SpaceStore from "../../../../../stores/SpaceStore";
import GroupAvatar from "../../../avatars/GroupAvatar";
import dis from "../../../../../dispatcher/dispatcher";
import GroupActions from "../../../../../actions/GroupActions";
@ -145,7 +144,7 @@ export default class PreferencesUserSettingsTab extends React.Component<IProps,
];
static COMMUNITIES_SETTINGS = [
// TODO: part of delabsing move the toggle here - https://github.com/vector-im/element-web/issues/18088
"showCommunitiesInsteadOfSpaces",
];
static KEYBINDINGS_SETTINGS = [
@ -286,9 +285,17 @@ export default class PreferencesUserSettingsTab extends React.Component<IProps,
SettingsStore.setValue("readMarkerOutOfViewThresholdMs", null, SettingLevel.DEVICE, e.target.value);
};
private renderGroup(settingIds: string[]): React.ReactNodeArray {
return settingIds.filter(SettingsStore.isEnabled).map(i => {
return <SettingsFlag key={i} name={i} level={SettingLevel.ACCOUNT} />;
private renderGroup(
settingIds: string[],
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) }
</div>
{ SpaceStore.spacesEnabled && <div className="mx_SettingsTab_section">
<div className="mx_SettingsTab_section">
<span className="mx_SettingsTab_subheading">{ _t("Spaces") }</span>
{ this.renderGroup(PreferencesUserSettingsTab.SPACES_SETTINGS) }
</div> }
{ this.renderGroup(PreferencesUserSettingsTab.SPACES_SETTINGS, SettingLevel.ACCOUNT, true) }
</div>
<div className="mx_SettingsTab_section">
<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>
<CommunityMigrator onFinished={this.props.closeSettingsFn} />
</details>
{ this.renderGroup(PreferencesUserSettingsTab.COMMUNITIES_SETTINGS) }
{ this.renderGroup(PreferencesUserSettingsTab.COMMUNITIES_SETTINGS, SettingLevel.DEVICE) }
</div>
<div className="mx_SettingsTab_section">

View file

@ -97,9 +97,8 @@ const spaceNameValidator = withValidation({
],
});
const nameToAlias = (name: string, domain: string): string => {
const localpart = name.trim().toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9_-]+/gi, "");
return `#${localpart}:${domain}`;
const nameToLocalpart = (name: string): string => {
return name.trim().toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9_-]+/gi, "");
};
// 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."),
rageshakeLabel: "spaces-feedback",
rageshakeData: Object.fromEntries([
"feature_spaces.all_rooms",
"feature_spaces.space_member_dms",
"feature_spaces.space_dm_badges",
"Spaces.allRoomsInHome",
].map(k => [k, SettingsStore.getValue(k)])),
});
}}
@ -176,8 +173,9 @@ export const SpaceCreateForm: React.FC<ISpaceCreateFormProps> = ({
value={name}
onChange={ev => {
const newName = ev.target.value;
if (!alias || alias === nameToAlias(name, domain)) {
setAlias(nameToAlias(newName, domain));
if (!alias || alias === `#${nameToLocalpart(name)}:${domain}`) {
setAlias(`#${nameToLocalpart(newName)}:${domain}`);
aliasFieldRef.current?.validate({ allowEmpty: true });
}
setName(newName);
}}
@ -194,7 +192,7 @@ export const SpaceCreateForm: React.FC<ISpaceCreateFormProps> = ({
onChange={setAlias}
domain={domain}
value={alias}
placeholder={name ? nameToAlias(name, domain) : _t("e.g. my-space")}
placeholder={name ? nameToLocalpart(name) : _t("e.g. my-space")}
label={_t("Address")}
disabled={busy}
onKeyDown={onKeyDown}
@ -217,6 +215,7 @@ export const SpaceCreateForm: React.FC<ISpaceCreateFormProps> = ({
};
const SpaceCreateMenu = ({ onFinished }) => {
const cli = useContext(MatrixClientContext);
const [visibility, setVisibility] = useState<Visibility>(null);
const [busy, setBusy] = useState<boolean>(false);
@ -233,14 +232,18 @@ const SpaceCreateMenu = ({ onFinished }) => {
setBusy(true);
// 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.validate({ allowEmpty: false, focused: true });
setBusy(false);
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.validate({ allowEmpty: true, focused: true });
setBusy(false);
@ -248,7 +251,13 @@ const SpaceCreateMenu = ({ onFinished }) => {
}
try {
await createSpace(name, visibility === Visibility.Public, alias, topic, avatar);
await createSpace(
name,
visibility === Visibility.Public,
aliasLocalpart ? alias : undefined,
topic,
avatar,
);
onFinished();
} catch (e) {
@ -290,13 +299,13 @@ const SpaceCreateMenu = ({ onFinished }) => {
/>
<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}>
{ sub }
</AccessibleButton>,
}) }
<br />
{ _t("To join an existing space you'll need an invite.") }
{ _t("To join a space you'll need an invite.") }
</p>
<SpaceFeedbackPrompt onClick={onFinished} />

View file

@ -151,12 +151,19 @@ const CreateSpaceButton = ({
}
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);
openMenu();
};
let betaDot: JSX.Element;
if (!localStorage.getItem("mx_seenSpaces") && !SpaceStore.instance.spacePanelSpaces.length) {
betaDot = <div className="mx_BetaDot" />;
}
return <li
className={classNames("mx_SpaceItem", {
className={classNames("mx_SpaceItem mx_SpaceItem_new", {
"collapsed": isPanelCollapsed,
})}
role="treeitem"
@ -169,6 +176,7 @@ const CreateSpaceButton = ({
onClick={onNewClick}
isNarrow={isPanelCollapsed}
/>
{ betaDot }
{ contextMenu }
</li>;

View file

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

View file

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

View file

@ -32,13 +32,20 @@ export default class Range {
this._end = bIsLarger ? positionB : positionA;
}
public moveStart(delta: number): void {
public moveStartForwards(delta: number): void {
this._start = this._start.forwardsWhile(this.model, () => {
delta -= 1;
return delta >= 0;
});
}
public moveEndBackwards(delta: number): void {
this._end = this._end.backwardsWhile(this.model, () => {
delta -= 1;
return delta >= 0;
});
}
public trim(): void {
this._start = this._start.forwardsWhile(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;
// 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
const savedHandler = useRef(handler);
@ -51,7 +55,11 @@ export const useEventEmitter = (emitter: EventEmitter, eventName: string | symbo
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 handler = useCallback((...args: any[]) => {
setValue(fn(...args));

View file

@ -25,7 +25,7 @@ const defaultMapper: Mapper<RoomState> = (roomState: RoomState) => roomState;
// Hook to simplify watching Matrix Room state
export const useRoomState = <T extends any = RoomState>(
room: Room,
room?: Room,
mapper: Mapper<T> = defaultMapper as Mapper<T>,
): T => {
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