Merge remote-tracking branch 'upstream/develop' into fix/12652/screen-share

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
This commit is contained in:
Šimon Brandner 2021-07-25 08:10:01 +02:00
commit b762303102
No known key found for this signature in database
GPG key ID: 55C211A1226CB17D
181 changed files with 4794 additions and 3252 deletions

View file

@ -86,6 +86,7 @@
@import "./views/dialogs/_IncomingSasDialog.scss"; @import "./views/dialogs/_IncomingSasDialog.scss";
@import "./views/dialogs/_InviteDialog.scss"; @import "./views/dialogs/_InviteDialog.scss";
@import "./views/dialogs/_KeyboardShortcutsDialog.scss"; @import "./views/dialogs/_KeyboardShortcutsDialog.scss";
@import "./views/dialogs/_ManageRestrictedJoinRuleDialog.scss";
@import "./views/dialogs/_MessageEditHistoryDialog.scss"; @import "./views/dialogs/_MessageEditHistoryDialog.scss";
@import "./views/dialogs/_ModalWidgetDialog.scss"; @import "./views/dialogs/_ModalWidgetDialog.scss";
@import "./views/dialogs/_NewSessionReviewDialog.scss"; @import "./views/dialogs/_NewSessionReviewDialog.scss";
@ -172,7 +173,6 @@
@import "./views/messages/_MStickerBody.scss"; @import "./views/messages/_MStickerBody.scss";
@import "./views/messages/_MTextBody.scss"; @import "./views/messages/_MTextBody.scss";
@import "./views/messages/_MVideoBody.scss"; @import "./views/messages/_MVideoBody.scss";
@import "./views/messages/_MVoiceMessageBody.scss";
@import "./views/messages/_MediaBody.scss"; @import "./views/messages/_MediaBody.scss";
@import "./views/messages/_MessageActionBar.scss"; @import "./views/messages/_MessageActionBar.scss";
@import "./views/messages/_MessageTimestamp.scss"; @import "./views/messages/_MessageTimestamp.scss";

View file

@ -45,9 +45,14 @@ limitations under the License.
/* Overrides for the attachment body tiles */ /* Overrides for the attachment body tiles */
.mx_FilePanel .mx_EventTile { .mx_FilePanel .mx_EventTile:not([data-layout=bubble]) {
word-break: break-word; word-break: break-word;
margin-top: 32px; margin-top: 10px;
padding-top: 0;
.mx_EventTile_line {
padding-left: 0;
}
} }
.mx_FilePanel .mx_EventTile .mx_MImageBody { .mx_FilePanel .mx_EventTile .mx_MImageBody {

View file

@ -84,7 +84,7 @@ limitations under the License.
display: inline; display: inline;
} }
.mx_NotificationPanel .mx_EventTile_senderDetails { .mx_NotificationPanel .mx_EventTile:not([data-layout=bubble]) .mx_EventTile_senderDetails {
padding-left: 36px; // align with the room name padding-left: 36px; // align with the room name
position: relative; position: relative;
@ -105,7 +105,7 @@ limitations under the License.
padding-left: 5px; padding-left: 5px;
} }
.mx_NotificationPanel .mx_EventTile_line { .mx_NotificationPanel .mx_EventTile:not([data-layout=bubble]) .mx_EventTile_line {
margin-right: 0px; margin-right: 0px;
padding-left: 36px; // align with the room name padding-left: 36px; // align with the room name
padding-top: 0px; padding-top: 0px;

View file

@ -234,6 +234,9 @@ $SpaceRoomViewInnerWidth: 428px;
} }
.mx_SpaceRoomView_landing { .mx_SpaceRoomView_landing {
display: flex;
flex-direction: column;
> .mx_BaseAvatar_image, > .mx_BaseAvatar_image,
> .mx_BaseAvatar > .mx_BaseAvatar_image { > .mx_BaseAvatar > .mx_BaseAvatar_image {
border-radius: 12px; border-radius: 12px;
@ -340,6 +343,7 @@ $SpaceRoomViewInnerWidth: 428px;
.mx_SearchBox { .mx_SearchBox {
margin: 0 0 20px; margin: 0 0 20px;
flex: 0;
} }
.mx_SpaceFeedbackPrompt { .mx_SpaceFeedbackPrompt {
@ -350,6 +354,11 @@ $SpaceRoomViewInnerWidth: 428px;
display: none; display: none;
} }
} }
.mx_SpaceRoomDirectory_list {
// we don't want this container to get forced into the flexbox layout
display: contents;
}
} }
.mx_SpaceRoomView_privateScope { .mx_SpaceRoomView_privateScope {

View file

@ -14,9 +14,8 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
.mx_AudioPlayer_container { .mx_MediaBody.mx_AudioPlayer_container {
padding: 16px 12px 12px 12px; padding: 16px 12px 12px 12px;
max-width: 267px; // use max to make the control fit in the files/pinned panels
.mx_AudioPlayer_primaryContainer { .mx_AudioPlayer_primaryContainer {
display: flex; display: flex;

View file

@ -18,10 +18,10 @@ limitations under the License.
// are shared amongst multiple voice message components. // are shared amongst multiple voice message components.
// Container for live recording and playback controls // Container for live recording and playback controls
.mx_VoiceMessagePrimaryContainer { .mx_MediaBody.mx_VoiceMessagePrimaryContainer {
// 7px top and bottom for visual design. 12px left & right, but the waveform (right) // The waveform (right) has a 1px padding on it that we want to account for, otherwise
// has a 1px padding on it that we want to account for. // inherit from mx_MediaBody
padding: 7px 12px 7px 11px; padding-right: 11px;
// Cheat at alignment a bit // Cheat at alignment a bit
display: flex; display: flex;

View file

@ -27,7 +27,6 @@ limitations under the License.
// https://bugzilla.mozilla.org/show_bug.cgi?id=255139 // https://bugzilla.mozilla.org/show_bug.cgi?id=255139
display: inline-block; display: inline-block;
user-select: none; user-select: none;
line-height: 1;
} }
.mx_BaseAvatar_initial { .mx_BaseAvatar_initial {

View file

@ -65,7 +65,7 @@ limitations under the License.
.mx_CreateRoomDialog_aliasContainer { .mx_CreateRoomDialog_aliasContainer {
display: flex; display: flex;
// put margin on container so it can collapse with siblings // put margin on container so it can collapse with siblings
margin: 10px 0; margin: 24px 0 10px;
.mx_RoomAliasField { .mx_RoomAliasField {
margin: 0; margin: 0;
@ -101,10 +101,6 @@ limitations under the License.
margin-left: 30px; margin-left: 30px;
} }
.mx_CreateRoomDialog_topic {
margin-bottom: 36px;
}
.mx_Dialog_content > .mx_SettingsFlag { .mx_Dialog_content > .mx_SettingsFlag {
margin-top: 24px; margin-top: 24px;
} }
@ -113,5 +109,56 @@ limitations under the License.
margin: 0 85px 0 0; margin: 0 85px 0 0;
font-size: $font-12px; font-size: $font-12px;
} }
.mx_Dropdown {
margin-bottom: 8px;
font-weight: normal;
font-family: $font-family;
font-size: $font-14px;
color: $primary-fg-color;
.mx_Dropdown_input {
border: 1px solid $input-border-color;
}
.mx_Dropdown_option {
font-size: $font-14px;
line-height: $font-32px;
height: 32px;
min-height: 32px;
> div {
padding-left: 30px;
position: relative;
&::before {
content: "";
position: absolute;
height: 16px;
width: 16px;
left: 6px;
top: 8px;
mask-repeat: no-repeat;
mask-position: center;
background-color: $secondary-fg-color;
}
}
}
.mx_CreateRoomDialog_dropdown_invite::before {
mask-image: url('$(res)/img/element-icons/lock.svg');
mask-size: contain;
}
.mx_CreateRoomDialog_dropdown_public::before {
mask-image: url('$(res)/img/globe.svg');
mask-size: 12px;
}
.mx_CreateRoomDialog_dropdown_restricted::before {
mask-image: url('$(res)/img/element-icons/community-members.svg');
mask-size: contain;
}
}
} }

View file

@ -36,6 +36,10 @@ limitations under the License.
flex-shrink: 0; flex-shrink: 0;
overflow-y: auto; overflow-y: auto;
.mx_EventTile[data-layout=bubble] {
margin-top: 20px;
}
div { div {
pointer-events: none; pointer-events: none;
} }

View file

@ -0,0 +1,150 @@
/*
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.
*/
.mx_ManageRestrictedJoinRuleDialog_wrapper {
.mx_Dialog {
display: flex;
flex-direction: column;
}
}
.mx_ManageRestrictedJoinRuleDialog {
width: 480px;
color: $primary-fg-color;
display: flex;
flex-direction: column;
flex-wrap: nowrap;
min-height: 0;
height: 60vh;
.mx_SearchBox {
// To match the space around the title
margin: 0 0 15px 0;
flex-grow: 0;
}
.mx_ManageRestrictedJoinRuleDialog_content {
flex-grow: 1;
}
.mx_ManageRestrictedJoinRuleDialog_noResults {
display: block;
margin-top: 24px;
}
.mx_ManageRestrictedJoinRuleDialog_section {
&:not(:first-child) {
margin-top: 24px;
}
> h3 {
margin: 0;
color: $secondary-fg-color;
font-size: $font-12px;
font-weight: $font-semi-bold;
line-height: $font-15px;
}
.mx_ManageRestrictedJoinRuleDialog_entry {
display: flex;
margin-top: 12px;
> div {
flex-grow: 1;
}
img.mx_RoomAvatar_isSpaceRoom,
.mx_RoomAvatar_isSpaceRoom img {
border-radius: 4px;
}
.mx_ManageRestrictedJoinRuleDialog_entry_name {
margin: 0 8px;
font-size: $font-15px;
line-height: 30px;
flex-grow: 1;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.mx_ManageRestrictedJoinRuleDialog_entry_description {
margin-top: 8px;
font-size: $font-12px;
line-height: $font-15px;
color: $tertiary-fg-color;
}
.mx_Checkbox {
align-items: center;
}
}
}
.mx_ManageRestrictedJoinRuleDialog_section_spaces {
.mx_BaseAvatar {
margin-right: 12px;
}
.mx_BaseAvatar_image {
border-radius: 8px;
}
}
.mx_ManageRestrictedJoinRuleDialog_section_info {
position: relative;
border-radius: 8px;
margin: 12px 0;
padding: 8px 8px 8px 42px;
background-color: $header-panel-bg-color;
font-size: $font-12px;
line-height: $font-15px;
color: $secondary-fg-color;
&::before {
content: '';
position: absolute;
left: 10px;
top: calc(50% - 8px); // vertical centering
height: 16px;
width: 16px;
background-color: $secondary-fg-color;
mask-repeat: no-repeat;
mask-size: contain;
mask-image: url('$(res)/img/element-icons/room/room-summary.svg');
mask-position: center;
}
}
.mx_ManageRestrictedJoinRuleDialog_footer {
margin-top: 20px;
.mx_ManageRestrictedJoinRuleDialog_footer_buttons {
display: flex;
width: max-content;
margin-left: auto;
.mx_AccessibleButton {
display: inline-block;
& + .mx_AccessibleButton {
margin-left: 24px;
}
}
}
}
}

View file

@ -27,7 +27,7 @@ limitations under the License.
display: flex; display: flex;
align-items: center; align-items: center;
position: relative; position: relative;
border-radius: 3px; border-radius: 4px;
border: 1px solid $strong-input-border-color; border: 1px solid $strong-input-border-color;
font-size: $font-12px; font-size: $font-12px;
user-select: none; user-select: none;
@ -109,7 +109,7 @@ input.mx_Dropdown_option:focus {
z-index: 2; z-index: 2;
margin: 0; margin: 0;
padding: 0px; padding: 0px;
border-radius: 3px; border-radius: 4px;
border: 1px solid $input-focused-border-color; border: 1px solid $input-focused-border-color;
background-color: $primary-bg-color; background-color: $primary-bg-color;
max-height: 200px; max-height: 200px;

View file

@ -19,8 +19,9 @@ limitations under the License.
margin-left: 0; margin-left: 0;
margin-right: 0; margin-right: 0;
margin-bottom: 8px; margin-bottom: 8px;
padding-left: 10px; padding: 0 10px;
border-left: 4px solid $button-bg-color; border-left: 2px solid $button-bg-color;
border-radius: 2px;
.mx_ReplyThread_show { .mx_ReplyThread_show {
cursor: pointer; cursor: pointer;

View file

@ -60,12 +60,6 @@ limitations under the License.
} }
.mx_MFileBody_info { .mx_MFileBody_info {
background-color: $message-body-panel-bg-color;
border-radius: 12px;
width: 243px; // same width as a playable voice message, accounting for padding
padding: 6px 12px;
color: $message-body-panel-fg-color;
.mx_MFileBody_info_icon { .mx_MFileBody_info_icon {
background-color: $message-body-panel-icon-bg-color; background-color: $message-body-panel-icon-bg-color;
border-radius: 20px; border-radius: 20px;

View file

@ -16,10 +16,6 @@ limitations under the License.
$timelineImageBorderRadius: 4px; $timelineImageBorderRadius: 4px;
.mx_MImageBody {
display: block;
}
.mx_MImageBody_thumbnail { .mx_MImageBody_thumbnail {
object-fit: contain; object-fit: contain;
border-radius: $timelineImageBorderRadius; border-radius: $timelineImageBorderRadius;
@ -28,7 +24,7 @@ $timelineImageBorderRadius: 4px;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
> canvas { > div > canvas {
border-radius: $timelineImageBorderRadius; border-radius: $timelineImageBorderRadius;
} }
} }

View file

@ -20,9 +20,11 @@ limitations under the License.
.mx_MediaBody { .mx_MediaBody {
background-color: $message-body-panel-bg-color; background-color: $message-body-panel-bg-color;
border-radius: 12px; border-radius: 12px;
max-width: 243px; // use max-width instead of width so it fits within right panels
color: $message-body-panel-fg-color; color: $message-body-panel-fg-color;
font-size: $font-14px; font-size: $font-14px;
line-height: $font-24px; line-height: $font-24px;
}
padding: 6px 12px;
}

View file

@ -38,7 +38,8 @@ limitations under the License.
padding-top: 0; padding-top: 0;
} }
&:hover { &:hover,
&.mx_EventTile_selected {
&::before { &::before {
content: ''; content: '';
position: absolute; position: absolute;
@ -80,7 +81,7 @@ limitations under the License.
.mx_MessageActionBar { .mx_MessageActionBar {
right: 0; right: 0;
transform: translate3d(50%, 50%, 0); transform: translate3d(90%, 50%, 0);
} }
--backgroundColor: $eventbubble-others-bg; --backgroundColor: $eventbubble-others-bg;
@ -91,12 +92,17 @@ limitations under the License.
float: right; float: right;
> a { > a {
left: auto; left: auto;
right: -48px; right: -68px;
} }
} }
.mx_SenderProfile { .mx_SenderProfile {
display: none; display: none;
} }
.mx_ReplyTile .mx_SenderProfile {
display: block;
}
.mx_ReactionsRow { .mx_ReactionsRow {
float: right; float: right;
clear: right; clear: right;
@ -126,7 +132,9 @@ limitations under the License.
margin: 0 -12px 0 -9px; margin: 0 -12px 0 -9px;
> a { > a {
position: absolute; position: absolute;
left: -48px; padding: 10px 20px;
top: 0;
left: -68px;
} }
} }
@ -147,13 +155,17 @@ limitations under the License.
.mx_EventTile_avatar { .mx_EventTile_avatar {
position: absolute; position: absolute;
top: 0; top: 0;
line-height: 1;
img { img {
box-shadow: 0 0 0 3px $eventbubble-avatar-outline; box-shadow: 0 0 0 3px $eventbubble-avatar-outline;
border-radius: 50%; border-radius: 50%;
} }
} }
.mx_BaseAvatar,
.mx_EventTile_avatar {
line-height: 1;
}
&[data-has-reply=true] { &[data-has-reply=true] {
> .mx_EventTile_line { > .mx_EventTile_line {
flex-direction: column; flex-direction: column;
@ -213,6 +225,7 @@ limitations under the License.
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 5px 0;
.mx_EventTile_avatar { .mx_EventTile_avatar {
position: static; position: static;
@ -254,7 +267,7 @@ limitations under the License.
} }
.mx_MessageActionBar { .mx_MessageActionBar {
transform: translate3d(50%, 0, 0); transform: translate3d(90%, 0, 0);
} }
} }
@ -279,7 +292,7 @@ limitations under the License.
& + .mx_EventListSummary { & + .mx_EventListSummary {
.mx_EventTile { .mx_EventTile {
margin-top: 0; margin-top: 0;
padding: 0; padding: 2px 0;
} }
} }

View file

@ -132,7 +132,8 @@ $hover-select-border: 4px;
} }
} }
&.mx_EventTile_info .mx_EventTile_line { &.mx_EventTile_info .mx_EventTile_line,
& ~ .mx_EventListSummary .mx_EventTile_avatar ~ .mx_EventTile_line {
padding-left: calc($left-gutter + 18px); padding-left: calc($left-gutter + 18px);
} }
@ -471,6 +472,10 @@ $hover-select-border: 4px;
background-color: $header-panel-bg-color; background-color: $header-panel-bg-color;
} }
pre code > * {
display: inline-block;
}
pre { pre {
// have to use overlay rather than auto otherwise Linux and Windows // have to use overlay rather than auto otherwise Linux and Windows
// Chrome gets very confused about vertical spacing: // Chrome gets very confused about vertical spacing:

View file

@ -26,6 +26,7 @@ $left-gutter: 64px;
> .mx_EventTile_avatar { > .mx_EventTile_avatar {
position: absolute; position: absolute;
z-index: 9;
} }
.mx_MessageTimestamp { .mx_MessageTimestamp {

View file

@ -19,7 +19,8 @@ limitations under the License.
margin-right: 15px; margin-right: 15px;
margin-bottom: 15px; margin-bottom: 15px;
display: flex; display: flex;
border-left: 4px solid $preview-widget-bar-color; border-left: 2px solid $preview-widget-bar-color;
border-radius: 2px;
color: $preview-widget-fg-color; color: $preview-widget-fg-color;
} }
@ -33,7 +34,7 @@ limitations under the License.
.mx_LinkPreviewWidget_caption { .mx_LinkPreviewWidget_caption {
margin-left: 15px; margin-left: 15px;
flex: 1 1 auto; flex: 1 1 auto;
overflow-x: hidden; // cause it to wrap rather than clip overflow: hidden; // cause it to wrap rather than clip
} }
.mx_LinkPreviewWidget_title { .mx_LinkPreviewWidget_title {

View file

@ -29,8 +29,10 @@ limitations under the License.
display: flex; display: flex;
flex-direction: column; flex-direction: column;
// min-height at this level so the mx_BasicMessageComposer_input // min-height at this level so the mx_BasicMessageComposer_input
// still stays vertically centered when less than 50px // still stays vertically centered when less than 55px.
min-height: 50px; // We also set this to ensure the voice message recording widget
// doesn't cause a jump.
min-height: 55px;
.mx_BasicMessageComposer_input { .mx_BasicMessageComposer_input {
padding: 3px 0; padding: 3px 0;

View file

@ -47,14 +47,14 @@ limitations under the License.
color: $settings-subsection-fg-color; color: $settings-subsection-fg-color;
font-size: $font-14px; font-size: $font-14px;
display: block; display: block;
margin: 10px 100px 10px 0; // Align with the rest of the view margin: 10px 80px 10px 0; // Align with the rest of the view
} }
.mx_SettingsTab_section { .mx_SettingsTab_section {
margin-bottom: 24px; margin-bottom: 24px;
.mx_SettingsFlag { .mx_SettingsFlag {
margin-right: 100px; margin-right: 80px;
margin-bottom: 10px; margin-bottom: 10px;
} }

View file

@ -14,6 +14,44 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
.mx_SecurityRoomSettingsTab {
.mx_SettingsTab_showAdvanced {
padding: 0;
margin-bottom: 16px;
}
.mx_SecurityRoomSettingsTab_spacesWithAccess {
> h4 {
color: $secondary-fg-color;
font-weight: $font-semi-bold;
font-size: $font-12px;
line-height: $font-15px;
text-transform: uppercase;
}
> span {
font-weight: 500;
font-size: $font-14px;
line-height: 32px; // matches height of avatar for v-align
color: $secondary-fg-color;
display: inline-block;
img.mx_RoomAvatar_isSpaceRoom,
.mx_RoomAvatar_isSpaceRoom img {
border-radius: 8px;
}
.mx_BaseAvatar {
margin-right: 8px;
}
& + span {
margin-left: 16px;
}
}
}
}
.mx_SecurityRoomSettingsTab_warning { .mx_SecurityRoomSettingsTab_warning {
display: block; display: block;
@ -26,5 +64,51 @@ limitations under the License.
} }
.mx_SecurityRoomSettingsTab_encryptionSection { .mx_SecurityRoomSettingsTab_encryptionSection {
margin-bottom: 25px; padding-bottom: 24px;
border-bottom: 1px solid $menu-border-color;
margin-bottom: 32px;
}
.mx_SecurityRoomSettingsTab_upgradeRequired {
margin-left: 16px;
padding: 4px 16px;
border: 1px solid $accent-color;
border-radius: 8px;
color: $accent-color;
font-size: $font-12px;
line-height: $font-15px;
}
.mx_SecurityRoomSettingsTab_joinRule {
.mx_RadioButton {
padding-top: 16px;
margin-bottom: 8px;
.mx_RadioButton_content {
margin-left: 14px;
font-weight: $font-semi-bold;
font-size: $font-15px;
line-height: $font-24px;
color: $primary-fg-color;
display: block;
}
}
> span {
display: inline-block;
margin-left: 34px;
margin-bottom: 16px;
font-size: $font-15px;
line-height: $font-24px;
color: $secondary-fg-color;
& + .mx_RadioButton {
border-top: 1px solid $menu-border-color;
}
}
.mx_AccessibleButton_kind_link {
padding: 0;
font-size: inherit;
}
} }

View file

@ -15,8 +15,7 @@ limitations under the License.
*/ */
.mx_AppearanceUserSettingsTab_fontSlider, .mx_AppearanceUserSettingsTab_fontSlider,
.mx_AppearanceUserSettingsTab_fontSlider_preview, .mx_AppearanceUserSettingsTab_fontSlider_preview {
.mx_AppearanceUserSettingsTab_Layout {
@mixin mx_Settings_fullWidthField; @mixin mx_Settings_fullWidthField;
} }
@ -159,13 +158,10 @@ limitations under the License.
.mx_AppearanceUserSettingsTab_Layout_RadioButtons { .mx_AppearanceUserSettingsTab_Layout_RadioButtons {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
gap: 24px;
color: $primary-fg-color; color: $primary-fg-color;
.mx_AppearanceUserSettingsTab_spacer {
width: 24px;
}
> .mx_AppearanceUserSettingsTab_Layout_RadioButton { > .mx_AppearanceUserSettingsTab_Layout_RadioButton {
flex-grow: 0; flex-grow: 0;
flex-shrink: 1; flex-shrink: 1;
@ -215,6 +211,21 @@ limitations under the License.
.mx_RadioButton_checked { .mx_RadioButton_checked {
background-color: rgba($accent-color, 0.08); background-color: rgba($accent-color, 0.08);
} }
.mx_EventTile {
margin: 0;
&[data-layout=bubble] {
margin-right: 40px;
}
&[data-layout=irc] {
> a {
display: none;
}
}
.mx_EventTile_line {
max-width: 90%;
}
}
} }
.mx_AppearanceUserSettingsTab_Advanced { .mx_AppearanceUserSettingsTab_Advanced {

View file

@ -1,7 +0,0 @@
<svg height="12" viewBox="0 0 12 12" width="12" xmlns="http://www.w3.org/2000/svg">
<g style="stroke:#454545;stroke-width:.8;fill:none;fill-rule:evenodd;stroke-linecap:round;stroke-linejoin:round" transform="translate(1 1)">
<circle cx="5" cy="5" r="5"/>
<path d="m0 5h10"/>
<path d="m5 0c1.25064019 1.36917645 1.96137638 3.14601693 2 5-.03862362 1.85398307-.74935981 3.63082355-2 5-1.25064019-1.36917645-1.96137638-3.14601693-2-5 .03862362-1.85398307.74935981-3.63082355 2-5z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 524 B

View file

@ -209,8 +209,8 @@ $user-tile-hover-bg-color: $header-panel-bg-color;
$message-body-panel-fg-color: $secondary-fg-color; $message-body-panel-fg-color: $secondary-fg-color;
$message-body-panel-bg-color: #394049; // "Dark Tile" $message-body-panel-bg-color: #394049; // "Dark Tile"
$message-body-panel-icon-fg-color: #21262C; // "Separator" $message-body-panel-icon-fg-color: $secondary-fg-color;
$message-body-panel-icon-bg-color: $tertiary-fg-color; $message-body-panel-icon-bg-color: #21262C; // "System Dark"
$voice-record-stop-border-color: $quaternary-fg-color; $voice-record-stop-border-color: $quaternary-fg-color;
$voice-record-waveform-incomplete-fg-color: $quaternary-fg-color; $voice-record-waveform-incomplete-fg-color: $quaternary-fg-color;
@ -295,3 +295,11 @@ $eventbubble-reply-color: #C1C6CD;
.hljs-tag { .hljs-tag {
color: inherit; // Without this they'd be weirdly blue which doesn't match the theme color: inherit; // Without this they'd be weirdly blue which doesn't match the theme
} }
.hljs-addition {
background: #1a4b59;
}
.hljs-deletion {
background: #53232a;
}

View file

@ -207,8 +207,8 @@ $user-tile-hover-bg-color: $header-panel-bg-color;
$message-body-panel-fg-color: $secondary-fg-color; $message-body-panel-fg-color: $secondary-fg-color;
$message-body-panel-bg-color: #394049; $message-body-panel-bg-color: #394049;
$message-body-panel-icon-fg-color: $primary-bg-color; $message-body-panel-icon-fg-color: $secondary-fg-color;
$message-body-panel-icon-bg-color: $secondary-fg-color; $message-body-panel-icon-bg-color: #21262C;
// See non-legacy dark for variable information // See non-legacy dark for variable information
$voice-record-stop-border-color: #6F7882; $voice-record-stop-border-color: #6F7882;

View file

@ -331,7 +331,7 @@ $user-tile-hover-bg-color: $header-panel-bg-color;
$message-body-panel-fg-color: $secondary-fg-color; $message-body-panel-fg-color: $secondary-fg-color;
$message-body-panel-bg-color: #E3E8F0; $message-body-panel-bg-color: #E3E8F0;
$message-body-panel-icon-fg-color: $secondary-fg-color; $message-body-panel-icon-fg-color: $secondary-fg-color;
$message-body-panel-icon-bg-color: $primary-bg-color; $message-body-panel-icon-bg-color: #F4F6FA;
// See non-legacy _light for variable information // See non-legacy _light for variable information
$voice-record-stop-symbol-color: #ff4b55; $voice-record-stop-symbol-color: #ff4b55;

View file

@ -327,7 +327,7 @@ $user-tile-hover-bg-color: $header-panel-bg-color;
$message-body-panel-fg-color: $secondary-fg-color; $message-body-panel-fg-color: $secondary-fg-color;
$message-body-panel-bg-color: #E3E8F0; // "Separator" $message-body-panel-bg-color: #E3E8F0; // "Separator"
$message-body-panel-icon-fg-color: $secondary-fg-color; $message-body-panel-icon-fg-color: $secondary-fg-color;
$message-body-panel-icon-bg-color: $primary-bg-color; $message-body-panel-icon-bg-color: #F4F6FA;
// These two don't change between themes. They are the $warning-color, but we don't // These two don't change between themes. They are the $warning-color, but we don't
// want custom themes to affect them by accident. // want custom themes to affect them by accident.

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2021 The Matrix.org Foundation C.I.C. Copyright 2021 Šimon Brandner <simon.bra.ag@gmail.com>
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -14,6 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
.mx_MVoiceMessageBody { declare module "*.svg" {
display: inline-block; // makes the playback controls magically line up const path: string;
export default path;
} }

View file

@ -57,7 +57,7 @@ const BIGEMOJI_REGEX = new RegExp(`^(${EMOJIBASE_REGEX.source})+$`, 'i');
const COLOR_REGEX = /^#[0-9a-fA-F]{6}$/; const COLOR_REGEX = /^#[0-9a-fA-F]{6}$/;
export const PERMITTED_URL_SCHEMES = ['http', 'https', 'ftp', 'mailto', 'magnet']; export const PERMITTED_URL_SCHEMES = ['http', 'https', 'ftp', 'mailto', 'magnet', 'matrix'];
const MEDIA_API_MXC_REGEX = /\/_matrix\/media\/r0\/(?:download|thumbnail)\/(.+?)\/(.+?)(?:[?/]|$)/; const MEDIA_API_MXC_REGEX = /\/_matrix\/media\/r0\/(?:download|thumbnail)\/(.+?)\/(.+?)(?:[?/]|$)/;

View file

@ -51,10 +51,15 @@ export async function startAnyRegistrationFlow(options) {
description: _t("Use your account or create a new one to continue."), description: _t("Use your account or create a new one to continue."),
button: _t("Create Account"), button: _t("Create Account"),
extraButtons: [ extraButtons: [
<button key="start_login" onClick={() => { <button
modal.close(); key="start_login"
dis.dispatch({ action: 'start_login', screenAfterLogin: options.screen_after }); onClick={() => {
}}>{ _t('Sign In') }</button>, modal.close();
dis.dispatch({ action: 'start_login', screenAfterLogin: options.screen_after });
}}
>
{ _t('Sign In') }
</button>,
], ],
onFinished: (proceed) => { onFinished: (proceed) => {
if (proceed) { if (proceed) {

View file

@ -34,7 +34,6 @@ import { getAddressType } from './UserAddress';
import { abbreviateUrl } from './utils/UrlUtils'; import { abbreviateUrl } from './utils/UrlUtils';
import { getDefaultIdentityServerUrl, useDefaultIdentityServer } from './utils/IdentityServerUtils'; import { getDefaultIdentityServerUrl, useDefaultIdentityServer } from './utils/IdentityServerUtils';
import { isPermalinkHost, parsePermalink } from "./utils/permalinks/Permalinks"; import { isPermalinkHost, parsePermalink } from "./utils/permalinks/Permalinks";
import { inviteUsersToRoom } from "./RoomInvite";
import { WidgetType } from "./widgets/WidgetType"; import { WidgetType } from "./widgets/WidgetType";
import { Jitsi } from "./widgets/Jitsi"; import { Jitsi } from "./widgets/Jitsi";
import { parseFragment as parseHtml, Element as ChildElement } from "parse5"; import { parseFragment as parseHtml, Element as ChildElement } from "parse5";
@ -49,6 +48,7 @@ import { UIFeature } from "./settings/UIFeature";
import { CHAT_EFFECTS } from "./effects"; import { CHAT_EFFECTS } from "./effects";
import CallHandler from "./CallHandler"; import CallHandler from "./CallHandler";
import { guessAndSetDMRoom } from "./Rooms"; import { guessAndSetDMRoom } from "./Rooms";
import { upgradeRoom } from './utils/RoomUpgrade';
import UploadConfirmDialog from './components/views/dialogs/UploadConfirmDialog'; import UploadConfirmDialog from './components/views/dialogs/UploadConfirmDialog';
import ErrorDialog from './components/views/dialogs/ErrorDialog'; import ErrorDialog from './components/views/dialogs/ErrorDialog';
import DevtoolsDialog from './components/views/dialogs/DevtoolsDialog'; import DevtoolsDialog from './components/views/dialogs/DevtoolsDialog';
@ -277,50 +277,8 @@ export const Commands = [
/*isPriority=*/false, /*isStatic=*/true); /*isPriority=*/false, /*isStatic=*/true);
return success(finished.then(async ([resp]) => { return success(finished.then(async ([resp]) => {
if (!resp.continue) return; if (!resp?.continue) return;
await upgradeRoom(room, args, resp.invite);
let checkForUpgradeFn;
try {
const upgradePromise = cli.upgradeRoom(roomId, args);
// We have to wait for the js-sdk to give us the room back so
// we can more effectively abuse the MultiInviter behaviour
// which heavily relies on the Room object being available.
if (resp.invite) {
checkForUpgradeFn = async (newRoom) => {
// The upgradePromise should be done by the time we await it here.
const { replacement_room: newRoomId } = await upgradePromise;
if (newRoom.roomId !== newRoomId) return;
const toInvite = [
...room.getMembersWithMembership("join"),
...room.getMembersWithMembership("invite"),
].map(m => m.userId).filter(m => m !== cli.getUserId());
if (toInvite.length > 0) {
// Errors are handled internally to this function
await inviteUsersToRoom(newRoomId, toInvite);
}
cli.removeListener('Room', checkForUpgradeFn);
};
cli.on('Room', checkForUpgradeFn);
}
// We have to await after so that the checkForUpgradesFn has a proper reference
// to the new room's ID.
await upgradePromise;
} catch (e) {
console.error(e);
if (checkForUpgradeFn) cli.removeListener('Room', checkForUpgradeFn);
Modal.createTrackedDialog('Slash Commands', 'room upgrade error', ErrorDialog, {
title: _t('Error upgrading room'),
description: _t(
'Double check that your server supports the room version chosen and try again.'),
});
}
})); }));
} }
return reject(this.getUsage()); return reject(this.getUsage());

View file

@ -14,35 +14,33 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import PropTypes from "prop-types";
const emailRegex = /^\S+@\S+\.\S+$/; const emailRegex = /^\S+@\S+\.\S+$/;
const mxUserIdRegex = /^@\S+:\S+$/; const mxUserIdRegex = /^@\S+:\S+$/;
const mxRoomIdRegex = /^!\S+:\S+$/; const mxRoomIdRegex = /^!\S+:\S+$/;
export const addressTypes = ['mx-user-id', 'mx-room-id', 'email'];
export enum AddressType { export enum AddressType {
Email = "email", Email = "email",
MatrixUserId = "mx-user-id", MatrixUserId = "mx-user-id",
MatrixRoomId = "mx-room-id", MatrixRoomId = "mx-room-id",
} }
export const addressTypes = [AddressType.Email, AddressType.MatrixRoomId, AddressType.MatrixUserId];
// PropType definition for an object describing // PropType definition for an object describing
// an address that can be invited to a room (which // an address that can be invited to a room (which
// could be a third party identifier or a matrix ID) // could be a third party identifier or a matrix ID)
// along with some additional information about the // along with some additional information about the
// address / target. // address / target.
export const UserAddressType = PropTypes.shape({ export interface IUserAddress {
addressType: PropTypes.oneOf(addressTypes).isRequired, addressType: AddressType;
address: PropTypes.string.isRequired, address: string;
displayName: PropTypes.string, displayName?: string;
avatarMxc: PropTypes.string, avatarMxc?: string;
// true if the address is known to be a valid address (eg. is a real // true if the address is known to be a valid address (eg. is a real
// user we've seen) or false otherwise (eg. is just an address the // user we've seen) or false otherwise (eg. is just an address the
// user has entered) // user has entered)
isKnown: PropTypes.bool, isKnown?: boolean;
}); }
export function getAddressType(inputText: string): AddressType | null { export function getAddressType(inputText: string): AddressType | null {
if (emailRegex.test(inputText)) { if (emailRegex.test(inputText)) {

View file

@ -269,7 +269,7 @@ export default class CreateKeyBackupDialog extends React.PureComponent {
<details> <details>
<summary>{ _t("Advanced") }</summary> <summary>{ _t("Advanced") }</summary>
<AccessibleButton kind='primary' onClick={this._onSkipPassPhraseClick} > <AccessibleButton kind='primary' onClick={this._onSkipPassPhraseClick}>
{ _t("Set up with a Security Key") } { _t("Set up with a Security Key") }
</AccessibleButton> </AccessibleButton>
</details> </details>

View file

@ -474,7 +474,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
outlined outlined
> >
<div className="mx_CreateSecretStorageDialog_optionTitle"> <div className="mx_CreateSecretStorageDialog_optionTitle">
<span className="mx_CreateSecretStorageDialog_optionIcon mx_CreateSecretStorageDialog_optionIcon_secureBackup"></span> <span className="mx_CreateSecretStorageDialog_optionIcon mx_CreateSecretStorageDialog_optionIcon_secureBackup" />
{ _t("Generate a Security Key") } { _t("Generate a Security Key") }
</div> </div>
<div>{ _t("Well generate a Security Key for you to store somewhere safe, like a password manager or a safe.") }</div> <div>{ _t("Well generate a Security Key for you to store somewhere safe, like a password manager or a safe.") }</div>
@ -493,7 +493,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
outlined outlined
> >
<div className="mx_CreateSecretStorageDialog_optionTitle"> <div className="mx_CreateSecretStorageDialog_optionTitle">
<span className="mx_CreateSecretStorageDialog_optionIcon mx_CreateSecretStorageDialog_optionIcon_securePhrase"></span> <span className="mx_CreateSecretStorageDialog_optionIcon mx_CreateSecretStorageDialog_optionIcon_securePhrase" />
{ _t("Enter a Security Phrase") } { _t("Enter a Security Phrase") }
</div> </div>
<div>{ _t("Use a secret phrase only you know, and optionally save a Security Key to use for backup.") }</div> <div>{ _t("Use a secret phrase only you know, and optionally save a Security Key to use for backup.") }</div>
@ -701,7 +701,8 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
<code ref={this._collectRecoveryKeyNode}>{ this._recoveryKey.encodedPrivateKey }</code> <code ref={this._collectRecoveryKeyNode}>{ this._recoveryKey.encodedPrivateKey }</code>
</div> </div>
<div className="mx_CreateSecretStorageDialog_recoveryKeyButtons"> <div className="mx_CreateSecretStorageDialog_recoveryKeyButtons">
<AccessibleButton kind='primary' className="mx_Dialog_primary" <AccessibleButton kind='primary'
className="mx_Dialog_primary"
onClick={this._onDownloadClick} onClick={this._onDownloadClick}
disabled={this.state.phase === PHASE_STORING} disabled={this.state.phase === PHASE_STORING}
> >

View file

@ -148,8 +148,12 @@ export default class ExportE2eKeysDialog extends React.Component {
</label> </label>
</div> </div>
<div className='mx_E2eKeysDialog_inputCell'> <div className='mx_E2eKeysDialog_inputCell'>
<input ref={this._passphrase1} id='passphrase1' <input
autoFocus={true} size='64' type='password' ref={this._passphrase1}
id='passphrase1'
autoFocus={true}
size='64'
type='password'
disabled={disableForm} disabled={disableForm}
/> />
</div> </div>
@ -161,8 +165,10 @@ export default class ExportE2eKeysDialog extends React.Component {
</label> </label>
</div> </div>
<div className='mx_E2eKeysDialog_inputCell'> <div className='mx_E2eKeysDialog_inputCell'>
<input ref={this._passphrase2} id='passphrase2' <input ref={this._passphrase2}
size='64' type='password' id='passphrase2'
size='64'
type='password'
disabled={disableForm} disabled={disableForm}
/> />
</div> </div>

View file

@ -174,7 +174,10 @@ export default class ImportE2eKeysDialog extends React.Component {
</div> </div>
</div> </div>
<div className='mx_Dialog_buttons'> <div className='mx_Dialog_buttons'>
<input className='mx_Dialog_primary' type='submit' value={_t('Import')} <input
className='mx_Dialog_primary'
type='submit'
value={_t('Import')}
disabled={!this.state.enableSubmit || disableForm} disabled={!this.state.enableSubmit || disableForm}
/> />
<button onClick={this._onCancelClick} disabled={disableForm}> <button onClick={this._onCancelClick} disabled={disableForm}>

View file

@ -0,0 +1,37 @@
/*
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 { DEFAULT_WAVEFORM, Playback } from "./Playback";
import { PlaybackManager } from "./PlaybackManager";
/**
* A managed playback is a Playback instance that is guided by a PlaybackManager.
*/
export class ManagedPlayback extends Playback {
public constructor(private manager: PlaybackManager, buf: ArrayBuffer, seedWaveform = DEFAULT_WAVEFORM) {
super(buf, seedWaveform);
}
public async play(): Promise<void> {
this.manager.playOnly(this);
return super.play();
}
public destroy() {
this.manager.destroyPlaybackInstance(this);
super.destroy();
}
}

View file

@ -32,7 +32,7 @@ export enum PlaybackState {
export const PLAYBACK_WAVEFORM_SAMPLES = 39; export const PLAYBACK_WAVEFORM_SAMPLES = 39;
const THUMBNAIL_WAVEFORM_SAMPLES = 100; // arbitrary: [30,120] const THUMBNAIL_WAVEFORM_SAMPLES = 100; // arbitrary: [30,120]
const DEFAULT_WAVEFORM = arraySeed(0, PLAYBACK_WAVEFORM_SAMPLES); export const DEFAULT_WAVEFORM = arraySeed(0, PLAYBACK_WAVEFORM_SAMPLES);
function makePlaybackWaveform(input: number[]): number[] { function makePlaybackWaveform(input: number[]): number[] {
// First, convert negative amplitudes to positive so we don't detect zero as "noisy". // First, convert negative amplitudes to positive so we don't detect zero as "noisy".
@ -59,9 +59,10 @@ export class Playback extends EventEmitter implements IDestroyable {
public readonly thumbnailWaveform: number[]; public readonly thumbnailWaveform: number[];
private readonly context: AudioContext; private readonly context: AudioContext;
private source: AudioBufferSourceNode; private source: AudioBufferSourceNode | MediaElementAudioSourceNode;
private state = PlaybackState.Decoding; private state = PlaybackState.Decoding;
private audioBuf: AudioBuffer; private audioBuf: AudioBuffer;
private element: HTMLAudioElement;
private resampledWaveform: number[]; private resampledWaveform: number[];
private waveformObservable = new SimpleObservable<number[]>(); private waveformObservable = new SimpleObservable<number[]>();
private readonly clock: PlaybackClock; private readonly clock: PlaybackClock;
@ -129,36 +130,64 @@ export class Playback extends EventEmitter implements IDestroyable {
this.removeAllListeners(); this.removeAllListeners();
this.clock.destroy(); this.clock.destroy();
this.waveformObservable.close(); this.waveformObservable.close();
if (this.element) {
URL.revokeObjectURL(this.element.src);
this.element.remove();
}
} }
public async prepare() { public async prepare() {
// Safari compat: promise API not supported on this function // The point where we use an audio element is fairly arbitrary, though we don't want
this.audioBuf = await new Promise((resolve, reject) => { // it to be too low. As of writing, voice messages want to show a waveform but audio
this.context.decodeAudioData(this.buf, b => resolve(b), async e => { // messages do not. Using an audio element means we can't show a waveform preview, so
// This error handler is largely for Safari as well, which doesn't support Opus/Ogg // we try to target the difference between a voice message file and large audio file.
// very well. // Overall, the point of this is to avoid memory-related issues due to storing a massive
console.error("Error decoding recording: ", e); // audio buffer in memory, as that can balloon to far greater than the input buffer's
console.warn("Trying to re-encode to WAV instead..."); // byte length.
if (this.buf.byteLength > 5 * 1024 * 1024) { // 5mb
console.log("Audio file too large: processing through <audio /> element");
this.element = document.createElement("AUDIO") as HTMLAudioElement;
const prom = new Promise((resolve, reject) => {
this.element.onloadeddata = () => resolve(null);
this.element.onerror = (e) => reject(e);
});
this.element.src = URL.createObjectURL(new Blob([this.buf]));
await prom; // make sure the audio element is ready for us
} else {
// Safari compat: promise API not supported on this function
this.audioBuf = await new Promise((resolve, reject) => {
this.context.decodeAudioData(this.buf, b => resolve(b), async e => {
try {
// This error handler is largely for Safari as well, which doesn't support Opus/Ogg
// very well.
console.error("Error decoding recording: ", e);
console.warn("Trying to re-encode to WAV instead...");
const wav = await decodeOgg(this.buf); const wav = await decodeOgg(this.buf);
// noinspection ES6MissingAwait - not needed when using callbacks // noinspection ES6MissingAwait - not needed when using callbacks
this.context.decodeAudioData(wav, b => resolve(b), e => { this.context.decodeAudioData(wav, b => resolve(b), e => {
console.error("Still failed to decode recording: ", e); console.error("Still failed to decode recording: ", e);
reject(e); reject(e);
});
} catch (e) {
console.error("Caught decoding error:", e);
reject(e);
}
}); });
}); });
});
// Update the waveform to the real waveform once we have channel data to use. We don't // Update the waveform to the real waveform once we have channel data to use. We don't
// exactly trust the user-provided waveform to be accurate... // exactly trust the user-provided waveform to be accurate...
const waveform = Array.from(this.audioBuf.getChannelData(0)); const waveform = Array.from(this.audioBuf.getChannelData(0));
this.resampledWaveform = makePlaybackWaveform(waveform); this.resampledWaveform = makePlaybackWaveform(waveform);
}
this.waveformObservable.update(this.resampledWaveform); this.waveformObservable.update(this.resampledWaveform);
this.emit(PlaybackState.Stopped); // signal that we're not decoding anymore this.emit(PlaybackState.Stopped); // signal that we're not decoding anymore
this.clock.flagLoadTime(); // must happen first because setting the duration fires a clock update this.clock.flagLoadTime(); // must happen first because setting the duration fires a clock update
this.clock.durationSeconds = this.audioBuf.duration; this.clock.durationSeconds = this.element ? this.element.duration : this.audioBuf.duration;
} }
private onPlaybackEnd = async () => { private onPlaybackEnd = async () => {
@ -171,7 +200,11 @@ export class Playback extends EventEmitter implements IDestroyable {
if (this.state === PlaybackState.Stopped) { if (this.state === PlaybackState.Stopped) {
this.disconnectSource(); this.disconnectSource();
this.makeNewSourceBuffer(); this.makeNewSourceBuffer();
this.source.start(); if (this.element) {
await this.element.play();
} else {
(this.source as AudioBufferSourceNode).start();
}
} }
// We use the context suspend/resume functions because it allows us to pause a source // We use the context suspend/resume functions because it allows us to pause a source
@ -182,13 +215,21 @@ export class Playback extends EventEmitter implements IDestroyable {
} }
private disconnectSource() { private disconnectSource() {
if (this.element) return; // leave connected, we can (and must) re-use it
this.source?.disconnect(); this.source?.disconnect();
this.source?.removeEventListener("ended", this.onPlaybackEnd); this.source?.removeEventListener("ended", this.onPlaybackEnd);
} }
private makeNewSourceBuffer() { private makeNewSourceBuffer() {
this.source = this.context.createBufferSource(); if (this.element && this.source) return; // leave connected, we can (and must) re-use it
this.source.buffer = this.audioBuf;
if (this.element) {
this.source = this.context.createMediaElementSource(this.element);
} else {
this.source = this.context.createBufferSource();
this.source.buffer = this.audioBuf;
}
this.source.addEventListener("ended", this.onPlaybackEnd); this.source.addEventListener("ended", this.onPlaybackEnd);
this.source.connect(this.context.destination); this.source.connect(this.context.destination);
} }
@ -241,7 +282,11 @@ export class Playback extends EventEmitter implements IDestroyable {
// when it comes time to the user hitting play. After a couple jumps, the user // when it comes time to the user hitting play. After a couple jumps, the user
// will have desynced the clock enough to be about 10-15 seconds off, while this // will have desynced the clock enough to be about 10-15 seconds off, while this
// keeps it as close to perfect as humans can perceive. // keeps it as close to perfect as humans can perceive.
this.source.start(now, timeSeconds); if (this.element) {
this.element.currentTime = timeSeconds;
} else {
(this.source as AudioBufferSourceNode).start(now, timeSeconds);
}
// Dev note: it's critical that the code gap between `this.source.start()` and // Dev note: it's critical that the code gap between `this.source.start()` and
// `this.pause()` is as small as possible: we do not want to delay *anything* // `this.pause()` is as small as possible: we do not want to delay *anything*

View file

@ -103,8 +103,8 @@ export class PlaybackClock implements IDestroyable {
* @param {MatrixEvent} event The event to use for placeholders. * @param {MatrixEvent} event The event to use for placeholders.
*/ */
public populatePlaceholdersFrom(event: MatrixEvent) { public populatePlaceholdersFrom(event: MatrixEvent) {
const durationSeconds = Number(event.getContent()['info']?.['duration']); const durationMs = Number(event.getContent()['info']?.['duration']);
if (Number.isFinite(durationSeconds)) this.placeholderDuration = durationSeconds; if (Number.isFinite(durationMs)) this.placeholderDuration = durationMs / 1000;
} }
/** /**
@ -132,6 +132,10 @@ export class PlaybackClock implements IDestroyable {
public flagStop() { public flagStop() {
this.stopped = true; this.stopped = true;
// Reset the clock time now so that the update going out will trigger components
// to check their seek/position information (alongside the clock).
this.clipStart = this.context.currentTime;
} }
public syncTo(contextTime: number, clipTime: number) { public syncTo(contextTime: number, clipTime: number) {

View file

@ -0,0 +1,54 @@
/*
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 { DEFAULT_WAVEFORM, Playback } from "./Playback";
import { ManagedPlayback } from "./ManagedPlayback";
/**
* Handles management of playback instances to ensure certain functionality, like
* one playback operating at any one time.
*/
export class PlaybackManager {
private static internalInstance: PlaybackManager;
private instances: ManagedPlayback[] = [];
public static get instance(): PlaybackManager {
if (!PlaybackManager.internalInstance) {
PlaybackManager.internalInstance = new PlaybackManager();
}
return PlaybackManager.internalInstance;
}
/**
* Stops all other playback instances. If no playback is provided, all instances
* are stopped.
* @param playback Optional. The playback to leave untouched.
*/
public playOnly(playback?: Playback) {
this.instances.filter(p => p !== playback).forEach(p => p.stop());
}
public destroyPlaybackInstance(playback: ManagedPlayback) {
this.instances = this.instances.filter(p => p !== playback);
}
public createPlaybackInstance(buf: ArrayBuffer, waveform = DEFAULT_WAVEFORM): Playback {
const instance = new ManagedPlayback(this, buf, waveform);
this.instances.push(instance);
return instance;
}
}

View file

@ -333,12 +333,17 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
if (this.lastUpload) return this.lastUpload; if (this.lastUpload) return this.lastUpload;
this.emit(RecordingState.Uploading); try {
const { url: mxc, file: encrypted } = await uploadFile(this.client, inRoomId, new Blob([this.audioBuffer], { this.emit(RecordingState.Uploading);
type: this.contentType, const { url: mxc, file: encrypted } = await uploadFile(this.client, inRoomId, new Blob([this.audioBuffer], {
})); type: this.contentType,
this.lastUpload = { mxc, encrypted }; }));
this.emit(RecordingState.Uploaded); this.lastUpload = { mxc, encrypted };
this.emit(RecordingState.Uploaded);
} catch (e) {
this.emit(RecordingState.Ended);
throw e;
}
return this.lastUpload; return this.lastUpload;
} }
} }

View file

@ -120,8 +120,7 @@ export default class EmbeddedPage extends React.PureComponent {
const content = <div className={`${className}_body`} const content = <div className={`${className}_body`}
dangerouslySetInnerHTML={{ __html: this.state.page }} dangerouslySetInnerHTML={{ __html: this.state.page }}
> />;
</div>;
if (this.props.scrollbar) { if (this.props.scrollbar) {
return <AutoHideScrollbar className={classes}> return <AutoHideScrollbar className={classes}>

View file

@ -36,6 +36,7 @@ import ResizeNotifier from '../../utils/ResizeNotifier';
import TimelinePanel from "./TimelinePanel"; import TimelinePanel from "./TimelinePanel";
import Spinner from "../views/elements/Spinner"; import Spinner from "../views/elements/Spinner";
import { TileShape } from '../views/rooms/EventTile'; import { TileShape } from '../views/rooms/EventTile';
import { Layout } from "../../settings/Layout";
interface IProps { interface IProps {
roomId: string; roomId: string;
@ -267,6 +268,7 @@ class FilePanel extends React.Component<IProps, IState> {
tileShape={TileShape.FileGrid} tileShape={TileShape.FileGrid}
resizeNotifier={this.props.resizeNotifier} resizeNotifier={this.props.resizeNotifier}
empty={emptyState} empty={emptyState}
layout={Layout.Group}
/> />
</BaseCard> </BaseCard>
); );

View file

@ -222,7 +222,7 @@ class FeaturedRoom extends React.Component {
let roomNameNode = null; let roomNameNode = null;
if (permalink) { if (permalink) {
roomNameNode = <a href={permalink} onClick={this.onClick} >{ roomName }</a>; roomNameNode = <a href={permalink} onClick={this.onClick}>{ roomName }</a>;
} else { } else {
roomNameNode = <span>{ roomName }</span>; roomNameNode = <span>{ roomName }</span>;
} }
@ -1185,10 +1185,13 @@ export default class GroupView extends React.Component {
avatarImage = <Spinner />; avatarImage = <Spinner />;
} else { } else {
const GroupAvatar = sdk.getComponent('avatars.GroupAvatar'); const GroupAvatar = sdk.getComponent('avatars.GroupAvatar');
avatarImage = <GroupAvatar groupId={this.props.groupId} avatarImage = <GroupAvatar
groupId={this.props.groupId}
groupName={this.state.profileForm.name} groupName={this.state.profileForm.name}
groupAvatarUrl={this.state.profileForm.avatar_url} groupAvatarUrl={this.state.profileForm.avatar_url}
width={28} height={28} resizeMethod='crop' width={28}
height={28}
resizeMethod='crop'
/>; />;
} }
@ -1199,9 +1202,12 @@ export default class GroupView extends React.Component {
</label> </label>
<div className="mx_GroupView_avatarPicker_edit"> <div className="mx_GroupView_avatarPicker_edit">
<label htmlFor="avatarInput" className="mx_GroupView_avatarPicker_label"> <label htmlFor="avatarInput" className="mx_GroupView_avatarPicker_label">
<img src={require("../../../res/img/camera.svg")} <img
alt={_t("Upload avatar")} title={_t("Upload avatar")} src={require("../../../res/img/camera.svg")}
width="17" height="15" /> alt={_t("Upload avatar")}
title={_t("Upload avatar")}
width="17"
height="15" />
</label> </label>
<input id="avatarInput" className="mx_GroupView_uploadInput" type="file" onChange={this._onAvatarSelected} /> <input id="avatarInput" className="mx_GroupView_uploadInput" type="file" onChange={this._onAvatarSelected} />
</div> </div>
@ -1238,7 +1244,8 @@ export default class GroupView extends React.Component {
groupAvatarUrl={groupAvatarUrl} groupAvatarUrl={groupAvatarUrl}
groupName={groupName} groupName={groupName}
onClick={onGroupHeaderItemClick} onClick={onGroupHeaderItemClick}
width={28} height={28} width={28}
height={28}
/>; />;
if (summary.profile && summary.profile.name) { if (summary.profile && summary.profile.name) {
nameNode = <div onClick={onGroupHeaderItemClick}> nameNode = <div onClick={onGroupHeaderItemClick}>
@ -1269,28 +1276,32 @@ export default class GroupView extends React.Component {
key="_cancelButton" key="_cancelButton"
onClick={this._onCancelClick} onClick={this._onCancelClick}
> >
<img src={require("../../../res/img/cancel.svg")} className="mx_filterFlipColor" <img
width="18" height="18" alt={_t("Cancel")} /> src={require("../../../res/img/cancel.svg")}
className="mx_filterFlipColor"
width="18"
height="18"
alt={_t("Cancel")} />
</AccessibleButton>, </AccessibleButton>,
); );
} else { } else {
if (summary.user && summary.user.membership === 'join') { if (summary.user && summary.user.membership === 'join') {
rightButtons.push( rightButtons.push(
<AccessibleButton className="mx_GroupHeader_button mx_GroupHeader_editButton" <AccessibleButton
className="mx_GroupHeader_button mx_GroupHeader_editButton"
key="_editButton" key="_editButton"
onClick={this._onEditClick} onClick={this._onEditClick}
title={_t("Community Settings")} title={_t("Community Settings")}
> />,
</AccessibleButton>,
); );
} }
rightButtons.push( rightButtons.push(
<AccessibleButton className="mx_GroupHeader_button mx_GroupHeader_shareButton" <AccessibleButton
className="mx_GroupHeader_button mx_GroupHeader_shareButton"
key="_shareButton" key="_shareButton"
onClick={this._onShareClick} onClick={this._onShareClick}
title={_t('Share Community')} title={_t('Share Community')}
> />,
</AccessibleButton>,
); );
} }

View file

@ -236,6 +236,8 @@ export default class MessagePanel extends React.Component<IProps, IState> {
// A map of <callId, CallEventGrouper> // A map of <callId, CallEventGrouper>
private callEventGroupers = new Map<string, CallEventGrouper>(); private callEventGroupers = new Map<string, CallEventGrouper>();
private membersCount = 0;
constructor(props, context) { constructor(props, context) {
super(props, context); super(props, context);
@ -256,11 +258,14 @@ export default class MessagePanel extends React.Component<IProps, IState> {
} }
componentDidMount() { componentDidMount() {
this.calculateRoomMembersCount();
this.props.room?.on("RoomState.members", this.calculateRoomMembersCount);
this.isMounted = true; this.isMounted = true;
} }
componentWillUnmount() { componentWillUnmount() {
this.isMounted = false; this.isMounted = false;
this.props.room?.off("RoomState.members", this.calculateRoomMembersCount);
SettingsStore.unwatchSetting(this.showTypingNotificationsWatcherRef); SettingsStore.unwatchSetting(this.showTypingNotificationsWatcherRef);
} }
@ -274,6 +279,10 @@ export default class MessagePanel extends React.Component<IProps, IState> {
} }
} }
private calculateRoomMembersCount = (): void => {
this.membersCount = this.props.room?.getMembers().length || 0;
};
private onShowTypingNotificationsChange = (): void => { private onShowTypingNotificationsChange = (): void => {
this.setState({ this.setState({
showTypingNotifications: SettingsStore.getValue("showTypingNotifications"), showTypingNotifications: SettingsStore.getValue("showTypingNotifications"),
@ -711,7 +720,6 @@ export default class MessagePanel extends React.Component<IProps, IState> {
isLastSuccessful = isLastSuccessful && mxEv.getSender() === MatrixClientPeg.get().getUserId(); isLastSuccessful = isLastSuccessful && mxEv.getSender() === MatrixClientPeg.get().getUserId();
const callEventGrouper = this.callEventGroupers.get(mxEv.getContent().call_id); const callEventGrouper = this.callEventGroupers.get(mxEv.getContent().call_id);
// use txnId as key if available so that we don't remount during sending // use txnId as key if available so that we don't remount during sending
ret.push( ret.push(
<TileErrorBoundary key={mxEv.getTxnId() || eventId} mxEvent={mxEv}> <TileErrorBoundary key={mxEv.getTxnId() || eventId} mxEvent={mxEv}>
@ -743,7 +751,7 @@ export default class MessagePanel extends React.Component<IProps, IState> {
enableFlair={this.props.enableFlair} enableFlair={this.props.enableFlair}
showReadReceipts={this.props.showReadReceipts} showReadReceipts={this.props.showReadReceipts}
callEventGrouper={callEventGrouper} callEventGrouper={callEventGrouper}
hideSender={this.props.room?.getMembers().length <= 2 && this.props.layout === Layout.Bubble} hideSender={this.membersCount <= 2 && this.props.layout === Layout.Bubble}
/> />
</TileErrorBoundary>, </TileErrorBoundary>,
); );

View file

@ -109,8 +109,7 @@ export default class MyGroups extends React.Component {
<SimpleRoomHeader title={_t("Communities")} icon={require("../../../res/img/icons-groups.svg")} /> <SimpleRoomHeader title={_t("Communities")} icon={require("../../../res/img/icons-groups.svg")} />
<div className='mx_MyGroups_header'> <div className='mx_MyGroups_header'>
<div className="mx_MyGroups_headerCard"> <div className="mx_MyGroups_headerCard">
<AccessibleButton className='mx_MyGroups_headerCard_button' onClick={this._onCreateGroupClick}> <AccessibleButton className='mx_MyGroups_headerCard_button' onClick={this._onCreateGroupClick} />
</AccessibleButton>
<div className="mx_MyGroups_headerCard_content"> <div className="mx_MyGroups_headerCard_content">
<div className="mx_MyGroups_headerCard_header"> <div className="mx_MyGroups_headerCard_header">
{ _t('Create a new community') } { _t('Create a new community') }

View file

@ -23,6 +23,7 @@ import { replaceableComponent } from "../../utils/replaceableComponent";
import TimelinePanel from "./TimelinePanel"; import TimelinePanel from "./TimelinePanel";
import Spinner from "../views/elements/Spinner"; import Spinner from "../views/elements/Spinner";
import { TileShape } from "../views/rooms/EventTile"; import { TileShape } from "../views/rooms/EventTile";
import { Layout } from "../../settings/Layout";
interface IProps { interface IProps {
onClose(): void; onClose(): void;
@ -52,6 +53,7 @@ export default class NotificationPanel extends React.PureComponent<IProps> {
tileShape={TileShape.Notif} tileShape={TileShape.Notif}
empty={emptyState} empty={emptyState}
alwaysShowTimestamps={true} alwaysShowTimestamps={true}
layout={Layout.Group}
/> />
); );
} else { } else {

View file

@ -266,8 +266,12 @@ export default class RoomStatusBar extends React.PureComponent {
<div className="mx_RoomStatusBar"> <div className="mx_RoomStatusBar">
<div role="alert"> <div role="alert">
<div className="mx_RoomStatusBar_connectionLostBar"> <div className="mx_RoomStatusBar_connectionLostBar">
<img src={require("../../../res/img/feather-customised/warning-triangle.svg")} width="24" <img
height="24" title="/!\ " alt="/!\ " /> src={require("../../../res/img/feather-customised/warning-triangle.svg")}
width="24"
height="24"
title="/!\ "
alt="/!\ " />
<div> <div>
<div className="mx_RoomStatusBar_connectionLostBar_title"> <div className="mx_RoomStatusBar_connectionLostBar_title">
{ _t('Connectivity to the server has been lost.') } { _t('Connectivity to the server has been lost.') }

View file

@ -166,6 +166,10 @@ export interface IState {
canReply: boolean; canReply: boolean;
layout: Layout; layout: Layout;
lowBandwidth: boolean; lowBandwidth: boolean;
alwaysShowTimestamps: boolean;
showTwelveHourTimestamps: boolean;
readMarkerInViewThresholdMs: number;
readMarkerOutOfViewThresholdMs: number;
showHiddenEventsInTimeline: boolean; showHiddenEventsInTimeline: boolean;
showReadReceipts: boolean; showReadReceipts: boolean;
showRedactions: boolean; showRedactions: boolean;
@ -231,6 +235,10 @@ export default class RoomView extends React.Component<IProps, IState> {
canReply: false, canReply: false,
layout: SettingsStore.getValue("layout"), layout: SettingsStore.getValue("layout"),
lowBandwidth: SettingsStore.getValue("lowBandwidth"), lowBandwidth: SettingsStore.getValue("lowBandwidth"),
alwaysShowTimestamps: SettingsStore.getValue("alwaysShowTimestamps"),
showTwelveHourTimestamps: SettingsStore.getValue("showTwelveHourTimestamps"),
readMarkerInViewThresholdMs: SettingsStore.getValue("readMarkerInViewThresholdMs"),
readMarkerOutOfViewThresholdMs: SettingsStore.getValue("readMarkerOutOfViewThresholdMs"),
showHiddenEventsInTimeline: SettingsStore.getValue("showHiddenEventsInTimeline"), showHiddenEventsInTimeline: SettingsStore.getValue("showHiddenEventsInTimeline"),
showReadReceipts: true, showReadReceipts: true,
showRedactions: true, showRedactions: true,
@ -263,14 +271,26 @@ export default class RoomView extends React.Component<IProps, IState> {
WidgetStore.instance.on(UPDATE_EVENT, this.onWidgetStoreUpdate); WidgetStore.instance.on(UPDATE_EVENT, this.onWidgetStoreUpdate);
this.settingWatchers = [ this.settingWatchers = [
SettingsStore.watchSetting("layout", null, () => SettingsStore.watchSetting("layout", null, (...[,,, value]) =>
this.setState({ layout: SettingsStore.getValue("layout") }), this.setState({ layout: value as Layout }),
), ),
SettingsStore.watchSetting("lowBandwidth", null, () => SettingsStore.watchSetting("lowBandwidth", null, (...[,,, value]) =>
this.setState({ lowBandwidth: SettingsStore.getValue("lowBandwidth") }), this.setState({ lowBandwidth: value as boolean }),
), ),
SettingsStore.watchSetting("showHiddenEventsInTimeline", null, () => SettingsStore.watchSetting("alwaysShowTimestamps", null, (...[,,, value]) =>
this.setState({ showHiddenEventsInTimeline: SettingsStore.getValue("showHiddenEventsInTimeline") }), this.setState({ alwaysShowTimestamps: value as boolean }),
),
SettingsStore.watchSetting("showTwelveHourTimestamps", null, (...[,,, value]) =>
this.setState({ showTwelveHourTimestamps: value as boolean }),
),
SettingsStore.watchSetting("readMarkerInViewThresholdMs", null, (...[,,, value]) =>
this.setState({ readMarkerInViewThresholdMs: value as number }),
),
SettingsStore.watchSetting("readMarkerOutOfViewThresholdMs", null, (...[,,, value]) =>
this.setState({ readMarkerOutOfViewThresholdMs: value as number }),
),
SettingsStore.watchSetting("showHiddenEventsInTimeline", null, (...[,,, value]) =>
this.setState({ showHiddenEventsInTimeline: value as boolean }),
), ),
]; ];
} }
@ -337,30 +357,20 @@ export default class RoomView extends React.Component<IProps, IState> {
// Add watchers for each of the settings we just looked up // Add watchers for each of the settings we just looked up
this.settingWatchers = this.settingWatchers.concat([ this.settingWatchers = this.settingWatchers.concat([
SettingsStore.watchSetting("showReadReceipts", null, () => SettingsStore.watchSetting("showReadReceipts", roomId, (...[,,, value]) =>
this.setState({ this.setState({ showReadReceipts: value as boolean }),
showReadReceipts: SettingsStore.getValue("showReadReceipts", roomId),
}),
), ),
SettingsStore.watchSetting("showRedactions", null, () => SettingsStore.watchSetting("showRedactions", roomId, (...[,,, value]) =>
this.setState({ this.setState({ showRedactions: value as boolean }),
showRedactions: SettingsStore.getValue("showRedactions", roomId),
}),
), ),
SettingsStore.watchSetting("showJoinLeaves", null, () => SettingsStore.watchSetting("showJoinLeaves", roomId, (...[,,, value]) =>
this.setState({ this.setState({ showJoinLeaves: value as boolean }),
showJoinLeaves: SettingsStore.getValue("showJoinLeaves", roomId),
}),
), ),
SettingsStore.watchSetting("showAvatarChanges", null, () => SettingsStore.watchSetting("showAvatarChanges", roomId, (...[,,, value]) =>
this.setState({ this.setState({ showAvatarChanges: value as boolean }),
showAvatarChanges: SettingsStore.getValue("showAvatarChanges", roomId),
}),
), ),
SettingsStore.watchSetting("showDisplaynameChanges", null, () => SettingsStore.watchSetting("showDisplaynameChanges", roomId, (...[,,, value]) =>
this.setState({ this.setState({ showDisplaynameChanges: value as boolean }),
showDisplaynameChanges: SettingsStore.getValue("showDisplaynameChanges", roomId),
}),
), ),
]); ]);
@ -1730,7 +1740,8 @@ export default class RoomView extends React.Component<IProps, IState> {
onJoinClick={this.onJoinButtonClicked} onJoinClick={this.onJoinButtonClicked}
onForgetClick={this.onForgetClick} onForgetClick={this.onForgetClick}
onRejectClick={this.onRejectThreepidInviteButtonClicked} onRejectClick={this.onRejectThreepidInviteButtonClicked}
canPreview={false} error={this.state.roomLoadError} canPreview={false}
error={this.state.roomLoadError}
roomAlias={roomAlias} roomAlias={roomAlias}
joining={this.state.joining} joining={this.state.joining}
inviterName={inviterName} inviterName={inviterName}

View file

@ -136,8 +136,8 @@ export default class SearchBox extends React.Component {
key="button" key="button"
tabIndex={-1} tabIndex={-1}
className="mx_SearchBox_closeButton" className="mx_SearchBox_closeButton"
onClick={() => {this._clearSearch("button"); }}> onClick={() => {this._clearSearch("button"); }}
</AccessibleButton>) : undefined; />) : undefined;
// show a shorter placeholder when blurred, if requested // show a shorter placeholder when blurred, if requested
// this is used for the room filter field that has // this is used for the room filter field that has

View file

@ -16,7 +16,7 @@ limitations under the License.
import React, { RefObject, useContext, useRef, useState } from "react"; import React, { RefObject, useContext, useRef, useState } from "react";
import { EventType } from "matrix-js-sdk/src/@types/event"; import { EventType } from "matrix-js-sdk/src/@types/event";
import { Preset } from "matrix-js-sdk/src/@types/partials"; import { Preset, JoinRule } from "matrix-js-sdk/src/@types/partials";
import { Room } from "matrix-js-sdk/src/models/room"; import { Room } from "matrix-js-sdk/src/models/room";
import { EventSubscription } from "fbemitter"; import { EventSubscription } from "fbemitter";
@ -66,7 +66,6 @@ import Modal from "../../Modal";
import BetaFeedbackDialog from "../views/dialogs/BetaFeedbackDialog"; import BetaFeedbackDialog from "../views/dialogs/BetaFeedbackDialog";
import SdkConfig from "../../SdkConfig"; import SdkConfig from "../../SdkConfig";
import { EffectiveMembership, getEffectiveMembership } from "../../utils/membership"; import { EffectiveMembership, getEffectiveMembership } from "../../utils/membership";
import { JoinRule } from "../views/settings/tabs/room/SecurityRoomSettingsTab";
interface IProps { interface IProps {
space: Room; space: Room;
@ -101,12 +100,14 @@ export const SpaceFeedbackPrompt = ({ onClick }: { onClick?: () => void }) => {
<hr /> <hr />
<div> <div>
<span className="mx_SpaceFeedbackPrompt_text">{ _t("Spaces are a beta feature.") }</span> <span className="mx_SpaceFeedbackPrompt_text">{ _t("Spaces are a beta feature.") }</span>
<AccessibleButton kind="link" onClick={() => { <AccessibleButton
if (onClick) onClick(); kind="link"
Modal.createTrackedDialog("Beta Feedback", "feature_spaces", BetaFeedbackDialog, { onClick={() => {
if (onClick) onClick();
Modal.createTrackedDialog("Beta Feedback", "feature_spaces", BetaFeedbackDialog, {
featureId: "feature_spaces", featureId: "feature_spaces",
}); });
}}> }}>
{ _t("Feedback") } { _t("Feedback") }
</AccessibleButton> </AccessibleButton>
</div> </div>
@ -307,7 +308,6 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) =>
}; };
const SpaceLandingAddButton = ({ space, onNewRoomAdded }) => { const SpaceLandingAddButton = ({ space, onNewRoomAdded }) => {
const cli = useContext(MatrixClientContext);
const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu(); const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu();
let contextMenu; let contextMenu;
@ -330,7 +330,7 @@ const SpaceLandingAddButton = ({ space, onNewRoomAdded }) => {
e.stopPropagation(); e.stopPropagation();
closeMenu(); closeMenu();
if (await showCreateNewRoom(cli, space)) { if (await showCreateNewRoom(space)) {
onNewRoomAdded(); onNewRoomAdded();
} }
}} }}
@ -343,7 +343,7 @@ const SpaceLandingAddButton = ({ space, onNewRoomAdded }) => {
e.stopPropagation(); e.stopPropagation();
closeMenu(); closeMenu();
const [added] = await showAddExistingRooms(cli, space); const [added] = await showAddExistingRooms(space);
if (added) { if (added) {
onNewRoomAdded(); onNewRoomAdded();
} }
@ -397,11 +397,11 @@ const SpaceLanding = ({ space }) => {
} }
let settingsButton; let settingsButton;
if (shouldShowSpaceSettings(cli, space)) { if (shouldShowSpaceSettings(space)) {
settingsButton = <AccessibleTooltipButton settingsButton = <AccessibleTooltipButton
className="mx_SpaceRoomView_landing_settingsButton" className="mx_SpaceRoomView_landing_settingsButton"
onClick={() => { onClick={() => {
showSpaceSettings(cli, space); showSpaceSettings(space);
}} }}
title={_t("Settings")} title={_t("Settings")}
/>; />;
@ -553,9 +553,7 @@ const SpaceAddExistingRooms = ({ space, onFinished }) => {
onFinished={onFinished} onFinished={onFinished}
/> />
<div className="mx_SpaceRoomView_buttons"> <div className="mx_SpaceRoomView_buttons" />
</div>
<SpaceFeedbackPrompt /> <SpaceFeedbackPrompt />
</div>; </div>;
}; };

View file

@ -665,8 +665,8 @@ class TimelinePanel extends React.Component<IProps, IState> {
private readMarkerTimeout(readMarkerPosition: number): number { private readMarkerTimeout(readMarkerPosition: number): number {
return readMarkerPosition === 0 ? return readMarkerPosition === 0 ?
this.state.readMarkerInViewThresholdMs : this.context?.readMarkerInViewThresholdMs ?? this.state.readMarkerInViewThresholdMs :
this.state.readMarkerOutOfViewThresholdMs; this.context?.readMarkerOutOfViewThresholdMs ?? this.state.readMarkerOutOfViewThresholdMs;
} }
private async updateReadMarkerOnUserActivity(): Promise<void> { private async updateReadMarkerOnUserActivity(): Promise<void> {
@ -1493,8 +1493,12 @@ class TimelinePanel extends React.Component<IProps, IState> {
onUserScroll={this.props.onUserScroll} onUserScroll={this.props.onUserScroll}
onFillRequest={this.onMessageListFillRequest} onFillRequest={this.onMessageListFillRequest}
onUnfillRequest={this.onMessageListUnfillRequest} onUnfillRequest={this.onMessageListUnfillRequest}
isTwelveHour={this.state.isTwelveHour} isTwelveHour={this.context?.showTwelveHourTimestamps ?? this.state.isTwelveHour}
alwaysShowTimestamps={this.props.alwaysShowTimestamps || this.state.alwaysShowTimestamps} alwaysShowTimestamps={
this.props.alwaysShowTimestamps ??
this.context?.alwaysShowTimestamps ??
this.state.alwaysShowTimestamps
}
className={this.props.className} className={this.props.className}
tileShape={this.props.tileShape} tileShape={this.props.tileShape}
resizeNotifier={this.props.resizeNotifier} resizeNotifier={this.props.resizeNotifier}

View file

@ -315,7 +315,10 @@ export default class ForgotPassword extends React.Component<IProps, IState> {
{ _t("An email has been sent to %(emailAddress)s. Once you've followed the " + { _t("An email has been sent to %(emailAddress)s. Once you've followed the " +
"link it contains, click below.", { emailAddress: this.state.email }) } "link it contains, click below.", { emailAddress: this.state.email }) }
<br /> <br />
<input className="mx_Login_submit" type="button" onClick={this.onVerify} <input
className="mx_Login_submit"
type="button"
onClick={this.onVerify}
value={_t('I have verified my email address')} /> value={_t('I have verified my email address')} />
</div>; </div>;
} }
@ -328,7 +331,10 @@ export default class ForgotPassword extends React.Component<IProps, IState> {
"push notifications. To re-enable notifications, sign in again on each " + "push notifications. To re-enable notifications, sign in again on each " +
"device.", "device.",
) }</p> ) }</p>
<input className="mx_Login_submit" type="button" onClick={this.props.onComplete} <input
className="mx_Login_submit"
type="button"
onClick={this.props.onComplete}
value={_t('Return to login screen')} /> value={_t('Return to login screen')} />
</div>; </div>;
} }

View file

@ -463,7 +463,9 @@ export default class LoginComponent extends React.PureComponent<IProps, IState>
"Either use HTTPS or <a>enable unsafe scripts</a>.", {}, "Either use HTTPS or <a>enable unsafe scripts</a>.", {},
{ {
'a': (sub) => { 'a': (sub) => {
return <a target="_blank" rel="noreferrer noopener" return <a
target="_blank"
rel="noreferrer noopener"
href="https://www.google.com/search?&q=enable%20unsafe%20scripts" href="https://www.google.com/search?&q=enable%20unsafe%20scripts"
> >
{ sub } { sub }

View file

@ -557,12 +557,16 @@ export default class Registration extends React.Component<IProps, IState> {
loggedInUserId: this.state.differentLoggedInUserId, loggedInUserId: this.state.differentLoggedInUserId,
}, },
) }</p> ) }</p>
<p><AccessibleButton element="span" className="mx_linkButton" onClick={async event => { <p><AccessibleButton
const sessionLoaded = await this.onLoginClickWithCheck(event); element="span"
if (sessionLoaded) { className="mx_linkButton"
dis.dispatch({ action: "view_welcome_page" }); onClick={async event => {
} const sessionLoaded = await this.onLoginClickWithCheck(event);
}}> if (sessionLoaded) {
dis.dispatch({ action: "view_welcome_page" });
}
}}
>
{ _t("Continue with previous account") } { _t("Continue with previous account") }
</AccessibleButton></p> </AccessibleButton></p>
</div>; </div>;

View file

@ -14,9 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { Playback, PlaybackState } from "../../../voice/Playback";
import React, { createRef, ReactNode, RefObject } from "react"; import React, { createRef, ReactNode, RefObject } from "react";
import { UPDATE_EVENT } from "../../../stores/AsyncStore";
import PlayPauseButton from "./PlayPauseButton"; import PlayPauseButton from "./PlayPauseButton";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import { formatBytes } from "../../../utils/FormattingUtils"; import { formatBytes } from "../../../utils/FormattingUtils";
@ -25,44 +23,13 @@ import { Key } from "../../../Keyboard";
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import SeekBar from "./SeekBar"; import SeekBar from "./SeekBar";
import PlaybackClock from "./PlaybackClock"; import PlaybackClock from "./PlaybackClock";
import AudioPlayerBase from "./AudioPlayerBase";
interface IProps {
// Playback instance to render. Cannot change during component lifecycle: create
// an all-new component instead.
playback: Playback;
mediaName: string;
}
interface IState {
playbackPhase: PlaybackState;
}
@replaceableComponent("views.audio_messages.AudioPlayer") @replaceableComponent("views.audio_messages.AudioPlayer")
export default class AudioPlayer extends React.PureComponent<IProps, IState> { export default class AudioPlayer extends AudioPlayerBase {
private playPauseRef: RefObject<PlayPauseButton> = createRef(); private playPauseRef: RefObject<PlayPauseButton> = createRef();
private seekRef: RefObject<SeekBar> = createRef(); private seekRef: RefObject<SeekBar> = createRef();
constructor(props: IProps) {
super(props);
this.state = {
playbackPhase: PlaybackState.Decoding, // default assumption
};
// We don't need to de-register: the class handles this for us internally
this.props.playback.on(UPDATE_EVENT, this.onPlaybackUpdate);
// Don't wait for the promise to complete - it will emit a progress update when it
// is done, and it's not meant to take long anyhow.
// noinspection JSIgnoredPromiseFromCall
this.props.playback.prepare();
}
private onPlaybackUpdate = (ev: PlaybackState) => {
this.setState({ playbackPhase: ev });
};
private onKeyDown = (ev: React.KeyboardEvent) => { private onKeyDown = (ev: React.KeyboardEvent) => {
// stopPropagation() prevents the FocusComposer catch-all from triggering, // stopPropagation() prevents the FocusComposer catch-all from triggering,
// but we need to do it on key down instead of press (even though the user // but we need to do it on key down instead of press (even though the user
@ -88,37 +55,39 @@ export default class AudioPlayer extends React.PureComponent<IProps, IState> {
return `(${formatBytes(bytes)})`; return `(${formatBytes(bytes)})`;
} }
public render(): ReactNode { protected renderComponent(): ReactNode {
// tabIndex=0 to ensure that the whole component becomes a tab stop, where we handle keyboard // tabIndex=0 to ensure that the whole component becomes a tab stop, where we handle keyboard
// events for accessibility // events for accessibility
return <div className='mx_MediaBody mx_AudioPlayer_container' tabIndex={0} onKeyDown={this.onKeyDown}> return (
<div className='mx_AudioPlayer_primaryContainer'> <div className='mx_MediaBody mx_AudioPlayer_container' tabIndex={0} onKeyDown={this.onKeyDown}>
<PlayPauseButton <div className='mx_AudioPlayer_primaryContainer'>
playback={this.props.playback} <PlayPauseButton
playbackPhase={this.state.playbackPhase} playback={this.props.playback}
tabIndex={-1} // prevent tabbing into the button playbackPhase={this.state.playbackPhase}
ref={this.playPauseRef} tabIndex={-1} // prevent tabbing into the button
/> ref={this.playPauseRef}
<div className='mx_AudioPlayer_mediaInfo'> />
<span className='mx_AudioPlayer_mediaName'> <div className='mx_AudioPlayer_mediaInfo'>
{ this.props.mediaName || _t("Unnamed audio") } <span className='mx_AudioPlayer_mediaName'>
</span> { this.props.mediaName || _t("Unnamed audio") }
<div className='mx_AudioPlayer_byline'> </span>
<DurationClock playback={this.props.playback} /> <div className='mx_AudioPlayer_byline'>
&nbsp; { /* easiest way to introduce a gap between the components */ } <DurationClock playback={this.props.playback} />
{ this.renderFileSize() } &nbsp; { /* easiest way to introduce a gap between the components */ }
{ this.renderFileSize() }
</div>
</div> </div>
</div> </div>
<div className='mx_AudioPlayer_seek'>
<SeekBar
playback={this.props.playback}
tabIndex={-1} // prevent tabbing into the bar
playbackPhase={this.state.playbackPhase}
ref={this.seekRef}
/>
<PlaybackClock playback={this.props.playback} defaultDisplaySeconds={0} />
</div>
</div> </div>
<div className='mx_AudioPlayer_seek'> );
<SeekBar
playback={this.props.playback}
tabIndex={-1} // prevent tabbing into the bar
playbackPhase={this.state.playbackPhase}
ref={this.seekRef}
/>
<PlaybackClock playback={this.props.playback} defaultDisplaySeconds={0} />
</div>
</div>;
} }
} }

View file

@ -0,0 +1,70 @@
/*
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 { Playback, PlaybackState } from "../../../audio/Playback";
import { TileShape } from "../rooms/EventTile";
import React, { ReactNode } from "react";
import { UPDATE_EVENT } from "../../../stores/AsyncStore";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { _t } from "../../../languageHandler";
interface IProps {
// Playback instance to render. Cannot change during component lifecycle: create
// an all-new component instead.
playback: Playback;
mediaName?: string;
tileShape?: TileShape;
}
interface IState {
playbackPhase: PlaybackState;
error?: boolean;
}
@replaceableComponent("views.audio_messages.AudioPlayerBase")
export default abstract class AudioPlayerBase extends React.PureComponent<IProps, IState> {
constructor(props: IProps) {
super(props);
this.state = {
playbackPhase: PlaybackState.Decoding, // default assumption
};
// We don't need to de-register: the class handles this for us internally
this.props.playback.on(UPDATE_EVENT, this.onPlaybackUpdate);
// Don't wait for the promise to complete - it will emit a progress update when it
// is done, and it's not meant to take long anyhow.
this.props.playback.prepare().catch(e => {
console.error("Error processing audio file:", e);
this.setState({ error: true });
});
}
private onPlaybackUpdate = (ev: PlaybackState) => {
this.setState({ playbackPhase: ev });
};
protected abstract renderComponent(): ReactNode;
public render(): ReactNode {
return <>
{ this.renderComponent() }
{ this.state.error && <div className="text-warning">{ _t("Error downloading audio") }</div> }
</>;
}
}

View file

@ -17,7 +17,7 @@ limitations under the License.
import React from "react"; import React from "react";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import Clock from "./Clock"; import Clock from "./Clock";
import { Playback } from "../../../voice/Playback"; import { Playback } from "../../../audio/Playback";
interface IProps { interface IProps {
playback: Playback; playback: Playback;

View file

@ -15,7 +15,7 @@ limitations under the License.
*/ */
import React from "react"; import React from "react";
import { IRecordingUpdate, VoiceRecording } from "../../../voice/VoiceRecording"; import { IRecordingUpdate, VoiceRecording } from "../../../audio/VoiceRecording";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import Clock from "./Clock"; import Clock from "./Clock";
import { MarkedExecution } from "../../../utils/MarkedExecution"; import { MarkedExecution } from "../../../utils/MarkedExecution";

View file

@ -15,7 +15,7 @@ limitations under the License.
*/ */
import React from "react"; import React from "react";
import { IRecordingUpdate, RECORDING_PLAYBACK_SAMPLES, VoiceRecording } from "../../../voice/VoiceRecording"; import { IRecordingUpdate, RECORDING_PLAYBACK_SAMPLES, VoiceRecording } from "../../../audio/VoiceRecording";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import { arrayFastResample } from "../../../utils/arrays"; import { arrayFastResample } from "../../../utils/arrays";
import { percentageOf } from "../../../utils/numbers"; import { percentageOf } from "../../../utils/numbers";

View file

@ -18,7 +18,7 @@ import React, { ReactNode } from "react";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import { Playback, PlaybackState } from "../../../voice/Playback"; import { Playback, PlaybackState } from "../../../audio/Playback";
import classNames from "classnames"; import classNames from "classnames";
// omitted props are handled by render function // omitted props are handled by render function

View file

@ -17,7 +17,7 @@ limitations under the License.
import React from "react"; import React from "react";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import Clock from "./Clock"; import Clock from "./Clock";
import { Playback, PlaybackState } from "../../../voice/Playback"; import { Playback, PlaybackState } from "../../../audio/Playback";
import { UPDATE_EVENT } from "../../../stores/AsyncStore"; import { UPDATE_EVENT } from "../../../stores/AsyncStore";
interface IProps { interface IProps {

View file

@ -18,7 +18,7 @@ import React from "react";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import { arraySeed, arrayTrimFill } from "../../../utils/arrays"; import { arraySeed, arrayTrimFill } from "../../../utils/arrays";
import Waveform from "./Waveform"; import Waveform from "./Waveform";
import { Playback, PLAYBACK_WAVEFORM_SAMPLES } from "../../../voice/Playback"; import { Playback, PLAYBACK_WAVEFORM_SAMPLES } from "../../../audio/Playback";
import { percentageOf } from "../../../utils/numbers"; import { percentageOf } from "../../../utils/numbers";
interface IProps { interface IProps {

View file

@ -14,61 +14,30 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { Playback, PlaybackState } from "../../../voice/Playback";
import React, { ReactNode } from "react"; import React, { ReactNode } from "react";
import { UPDATE_EVENT } from "../../../stores/AsyncStore";
import PlayPauseButton from "./PlayPauseButton"; import PlayPauseButton from "./PlayPauseButton";
import PlaybackClock from "./PlaybackClock"; import PlaybackClock from "./PlaybackClock";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import { TileShape } from "../rooms/EventTile"; import { TileShape } from "../rooms/EventTile";
import PlaybackWaveform from "./PlaybackWaveform"; import PlaybackWaveform from "./PlaybackWaveform";
import AudioPlayerBase from "./AudioPlayerBase";
interface IProps {
// Playback instance to render. Cannot change during component lifecycle: create
// an all-new component instead.
playback: Playback;
tileShape?: TileShape;
}
interface IState {
playbackPhase: PlaybackState;
}
@replaceableComponent("views.audio_messages.RecordingPlayback") @replaceableComponent("views.audio_messages.RecordingPlayback")
export default class RecordingPlayback extends React.PureComponent<IProps, IState> { export default class RecordingPlayback extends AudioPlayerBase {
constructor(props: IProps) {
super(props);
this.state = {
playbackPhase: PlaybackState.Decoding, // default assumption
};
// We don't need to de-register: the class handles this for us internally
this.props.playback.on(UPDATE_EVENT, this.onPlaybackUpdate);
// Don't wait for the promise to complete - it will emit a progress update when it
// is done, and it's not meant to take long anyhow.
// noinspection JSIgnoredPromiseFromCall
this.props.playback.prepare();
}
private get isWaveformable(): boolean { private get isWaveformable(): boolean {
return this.props.tileShape !== TileShape.Notif return this.props.tileShape !== TileShape.Notif
&& this.props.tileShape !== TileShape.FileGrid && this.props.tileShape !== TileShape.FileGrid
&& this.props.tileShape !== TileShape.Pinned; && this.props.tileShape !== TileShape.Pinned;
} }
private onPlaybackUpdate = (ev: PlaybackState) => { protected renderComponent(): ReactNode {
this.setState({ playbackPhase: ev });
};
public render(): ReactNode {
const shapeClass = !this.isWaveformable ? 'mx_VoiceMessagePrimaryContainer_noWaveform' : ''; const shapeClass = !this.isWaveformable ? 'mx_VoiceMessagePrimaryContainer_noWaveform' : '';
return <div className={'mx_MediaBody mx_VoiceMessagePrimaryContainer ' + shapeClass}> return (
<PlayPauseButton playback={this.props.playback} playbackPhase={this.state.playbackPhase} /> <div className={'mx_MediaBody mx_VoiceMessagePrimaryContainer ' + shapeClass}>
<PlaybackClock playback={this.props.playback} /> <PlayPauseButton playback={this.props.playback} playbackPhase={this.state.playbackPhase} />
{ this.isWaveformable && <PlaybackWaveform playback={this.props.playback} /> } <PlaybackClock playback={this.props.playback} />
</div>; { this.isWaveformable && <PlaybackWaveform playback={this.props.playback} /> }
</div>
);
} }
} }

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { Playback, PlaybackState } from "../../../voice/Playback"; import { Playback, PlaybackState } from "../../../audio/Playback";
import React, { ChangeEvent, CSSProperties, ReactNode } from "react"; import React, { ChangeEvent, CSSProperties, ReactNode } from "react";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import { MarkedExecution } from "../../../utils/MarkedExecution"; import { MarkedExecution } from "../../../utils/MarkedExecution";

View file

@ -54,9 +54,13 @@ export default class Waveform extends React.PureComponent<IProps, IState> {
'mx_Waveform_bar': true, 'mx_Waveform_bar': true,
'mx_Waveform_bar_100pct': isCompleteBar, 'mx_Waveform_bar_100pct': isCompleteBar,
}); });
return <span key={i} style={{ return <span
"--barHeight": h, key={i}
} as WaveformCSSProperties} className={classes} />; style={{
"--barHeight": h,
} as WaveformCSSProperties}
className={classes}
/>;
}) } }) }
</div>; </div>;
} }

View file

@ -416,8 +416,10 @@ export class TermsAuthEntry extends React.Component<ITermsAuthEntryProps, ITerms
let submitButton; let submitButton;
if (this.props.showContinue !== false) { if (this.props.showContinue !== false) {
// XXX: button classes // XXX: button classes
submitButton = <button className="mx_InteractiveAuthEntryComponents_termsSubmit mx_GeneralButton" submitButton = <button
onClick={this.trySubmit} disabled={!allChecked}>{ _t("Accept") }</button>; className="mx_InteractiveAuthEntryComponents_termsSubmit mx_GeneralButton"
onClick={this.trySubmit}
disabled={!allChecked}>{ _t("Accept") }</button>;
} }
return ( return (
@ -616,7 +618,9 @@ export class MsisdnAuthEntry extends React.Component<IMsisdnAuthEntryProps, IMsi
aria-label={_t("Code")} aria-label={_t("Code")}
/> />
<br /> <br />
<input type="submit" value={_t("Submit")} <input
type="submit"
value={_t("Submit")}
className={submitClasses} className={submitClasses}
disabled={!enableSubmit} disabled={!enableSubmit}
/> />

View file

@ -187,7 +187,8 @@ const BaseAvatar = (props: IProps) => {
width: toPx(width), width: toPx(width),
height: toPx(height), height: toPx(height),
}} }}
title={title} alt={_t("Avatar")} title={title}
alt={_t("Avatar")}
inputRef={inputRef} inputRef={inputRef}
{...otherProps} /> {...otherProps} />
); );
@ -201,7 +202,8 @@ const BaseAvatar = (props: IProps) => {
width: toPx(width), width: toPx(width),
height: toPx(height), height: toPx(height),
}} }}
title={title} alt="" title={title}
alt=""
ref={inputRef} ref={inputRef}
{...otherProps} /> {...otherProps} />
); );

View file

@ -102,8 +102,12 @@ export default class MemberAvatar extends React.Component<IProps, IState> {
} }
return ( return (
<BaseAvatar {...otherProps} name={this.state.name} title={this.state.title} <BaseAvatar {...otherProps}
idName={userId} url={this.state.imageUrl} onClick={onClick} /> name={this.state.name}
title={this.state.title}
idName={userId}
url={this.state.imageUrl}
onClick={onClick} />
); );
} }
} }

View file

@ -13,9 +13,11 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React, { ComponentProps } from 'react'; import React, { ComponentProps } from 'react';
import { Room } from 'matrix-js-sdk/src/models/room'; import { Room } from 'matrix-js-sdk/src/models/room';
import { ResizeMethod } from 'matrix-js-sdk/src/@types/partials'; import { ResizeMethod } from 'matrix-js-sdk/src/@types/partials';
import classNames from "classnames";
import BaseAvatar from './BaseAvatar'; import BaseAvatar from './BaseAvatar';
import ImageView from '../elements/ImageView'; import ImageView from '../elements/ImageView';
@ -32,11 +34,14 @@ interface IProps extends Omit<ComponentProps<typeof BaseAvatar>, "name" | "idNam
// oobData.avatarUrl should be set (else there // oobData.avatarUrl should be set (else there
// would be nowhere to get the avatar from) // would be nowhere to get the avatar from)
room?: Room; room?: Room;
oobData?: IOOBData; oobData?: IOOBData & {
roomId?: string;
};
width?: number; width?: number;
height?: number; height?: number;
resizeMethod?: ResizeMethod; resizeMethod?: ResizeMethod;
viewAvatarOnClick?: boolean; viewAvatarOnClick?: boolean;
className?: string;
onClick?(): void; onClick?(): void;
} }
@ -129,15 +134,19 @@ export default class RoomAvatar extends React.Component<IProps, IState> {
}; };
public render() { public render() {
const { room, oobData, viewAvatarOnClick, onClick, ...otherProps } = this.props; const { room, oobData, viewAvatarOnClick, onClick, className, ...otherProps } = this.props;
const roomName = room ? room.name : oobData.name; const roomName = room ? room.name : oobData.name;
// If the room is a DM, we use the other user's ID for the color hash // If the room is a DM, we use the other user's ID for the color hash
// in order to match the room avatar with their avatar // in order to match the room avatar with their avatar
const idName = room ? (DMRoomMap.shared().getUserIdForRoomId(room.roomId) ?? room.roomId) : null; const idName = room ? (DMRoomMap.shared().getUserIdForRoomId(room.roomId) ?? room.roomId) : oobData.roomId;
return ( return (
<BaseAvatar {...otherProps} <BaseAvatar
{...otherProps}
className={classNames(className, {
mx_RoomAvatar_isSpaceRoom: room?.isSpaceRoom(),
})}
name={roomName} name={roomName}
idName={idName} idName={idName}
urls={this.state.urls} urls={this.state.urls}

View file

@ -60,8 +60,10 @@ export default class DialpadContextMenu extends React.Component<IProps, IState>
<AccessibleButton className="mx_DialPadContextMenu_cancel" onClick={this.onCancelClick} /> <AccessibleButton className="mx_DialPadContextMenu_cancel" onClick={this.onCancelClick} />
</div> </div>
<div className="mx_DialPadContextMenu_header"> <div className="mx_DialPadContextMenu_header">
<Field className="mx_DialPadContextMenu_dialled" <Field
value={this.state.value} autoFocus={true} className="mx_DialPadContextMenu_dialled"
value={this.state.value}
autoFocus={true}
onChange={this.onChange} onChange={this.onChange}
/> />
</div> </div>

View file

@ -109,8 +109,10 @@ export default class StatusMessageContextMenu extends React.Component {
</AccessibleButton>; </AccessibleButton>;
} }
} else { } else {
actionButton = <AccessibleButton className="mx_StatusMessageContextMenu_submit" actionButton = <AccessibleButton
disabled={!this.state.message} onClick={this._onSubmit} className="mx_StatusMessageContextMenu_submit"
disabled={!this.state.message}
onClick={this._onSubmit}
> >
<span>{ _t("Set status") }</span> <span>{ _t("Set status") }</span>
</AccessibleButton>; </AccessibleButton>;
@ -121,12 +123,19 @@ export default class StatusMessageContextMenu extends React.Component {
spinner = <Spinner w="24" h="24" />; spinner = <Spinner w="24" h="24" />;
} }
const form = <form className="mx_StatusMessageContextMenu_form" const form = <form
autoComplete="off" onSubmit={this._onSubmit} className="mx_StatusMessageContextMenu_form"
autoComplete="off"
onSubmit={this._onSubmit}
> >
<input type="text" className="mx_StatusMessageContextMenu_message" <input
key="message" placeholder={_t("Set a new status...")} type="text"
autoFocus={true} maxLength="60" value={this.state.message} className="mx_StatusMessageContextMenu_message"
key="message"
placeholder={_t("Set a new status...")}
autoFocus={true}
maxLength="60"
value={this.state.message}
onChange={this._onStatusChange} onChange={this._onStatusChange}
/> />
<div className="mx_StatusMessageContextMenu_actionContainer"> <div className="mx_StatusMessageContextMenu_actionContainer">

View file

@ -76,7 +76,8 @@ const WidgetContextMenu: React.FC<IProps> = ({
onFinished(); onFinished();
}; };
streamAudioStreamButton = <IconizedContextMenuOption streamAudioStreamButton = <IconizedContextMenuOption
onClick={onStreamAudioClick} label={_t("Start audio stream")} onClick={onStreamAudioClick}
label={_t("Start audio stream")}
/>; />;
} }

View file

@ -17,7 +17,6 @@ limitations under the License.
import React, { ReactNode, useContext, useMemo, useState } from "react"; import React, { ReactNode, useContext, useMemo, useState } from "react";
import classNames from "classnames"; import classNames from "classnames";
import { Room } from "matrix-js-sdk/src/models/room"; import { Room } from "matrix-js-sdk/src/models/room";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { sleep } from "matrix-js-sdk/src/utils"; import { sleep } from "matrix-js-sdk/src/utils";
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
@ -44,9 +43,8 @@ import EntityTile from "../rooms/EntityTile";
import BaseAvatar from "../avatars/BaseAvatar"; import BaseAvatar from "../avatars/BaseAvatar";
interface IProps extends IDialogProps { interface IProps extends IDialogProps {
matrixClient: MatrixClient;
space: Room; space: Room;
onCreateRoomClick(cli: MatrixClient, space: Room): void; onCreateRoomClick(space: Room): void;
} }
const Entry = ({ room, checked, onChange }) => { const Entry = ({ room, checked, onChange }) => {
@ -211,10 +209,16 @@ export const AddExistingToSpace: React.FC<IAddExistingToSpaceProps> = ({
function overflowTile(overflowCount, totalCount) { function overflowTile(overflowCount, totalCount) {
const text = _t("and %(count)s others...", { count: overflowCount }); const text = _t("and %(count)s others...", { count: overflowCount });
return ( return (
<EntityTile className="mx_EntityTile_ellipsis" avatarJsx={ <EntityTile
<BaseAvatar url={require("../../../../res/img/ellipsis.svg")} name="..." width={36} height={36} /> className="mx_EntityTile_ellipsis"
} name={text} presenceState="online" suppressOnHover={true} avatarJsx={
onClick={() => setTruncateAt(totalCount)} /> <BaseAvatar url={require("../../../../res/img/ellipsis.svg")} name="..." width={36} height={36} />
}
name={text}
presenceState="online"
suppressOnHover={true}
onClick={() => setTruncateAt(totalCount)}
/>
); );
} }
@ -295,7 +299,7 @@ export const AddExistingToSpace: React.FC<IAddExistingToSpaceProps> = ({
</div>; </div>;
}; };
const AddExistingToSpaceDialog: React.FC<IProps> = ({ matrixClient: cli, space, onCreateRoomClick, onFinished }) => { const AddExistingToSpaceDialog: React.FC<IProps> = ({ space, onCreateRoomClick, onFinished }) => {
const [selectedSpace, setSelectedSpace] = useState(space); const [selectedSpace, setSelectedSpace] = useState(space);
const existingSubspaces = SpaceStore.instance.getChildSpaces(space.roomId); const existingSubspaces = SpaceStore.instance.getChildSpaces(space.roomId);
@ -344,13 +348,13 @@ const AddExistingToSpaceDialog: React.FC<IProps> = ({ matrixClient: cli, space,
onFinished={onFinished} onFinished={onFinished}
fixedWidth={false} fixedWidth={false}
> >
<MatrixClientContext.Provider value={cli}> <MatrixClientContext.Provider value={space.client}>
<AddExistingToSpace <AddExistingToSpace
space={space} space={space}
onFinished={onFinished} onFinished={onFinished}
footerPrompt={<> footerPrompt={<>
<div>{ _t("Want to add a new room instead?") }</div> <div>{ _t("Want to add a new room instead?") }</div>
<AccessibleButton onClick={() => onCreateRoomClick(cli, space)} kind="link"> <AccessibleButton onClick={() => onCreateRoomClick(space)} kind="link">
{ _t("Create a new room") } { _t("Create a new room") }
</AccessibleButton> </AccessibleButton>
</>} </>}

View file

@ -18,14 +18,12 @@ limitations under the License.
*/ */
import React, { createRef } from 'react'; import React, { createRef } from 'react';
import PropTypes from 'prop-types';
import { sleep } from "matrix-js-sdk/src/utils"; import { sleep } from "matrix-js-sdk/src/utils";
import { _t, _td } from '../../../languageHandler'; import { _t, _td } from '../../../languageHandler';
import * as sdk from '../../../index';
import { MatrixClientPeg } from '../../../MatrixClientPeg'; import { MatrixClientPeg } from '../../../MatrixClientPeg';
import dis from '../../../dispatcher/dispatcher'; import dis from '../../../dispatcher/dispatcher';
import { addressTypes, getAddressType } from '../../../UserAddress'; import { AddressType, addressTypes, getAddressType, IUserAddress } from '../../../UserAddress';
import GroupStore from '../../../stores/GroupStore'; import GroupStore from '../../../stores/GroupStore';
import * as Email from '../../../email'; import * as Email from '../../../email';
import IdentityAuthClient from '../../../IdentityAuthClient'; import IdentityAuthClient from '../../../IdentityAuthClient';
@ -34,6 +32,10 @@ import { abbreviateUrl } from '../../../utils/UrlUtils';
import { Key } from "../../../Keyboard"; import { Key } from "../../../Keyboard";
import { Action } from "../../../dispatcher/actions"; import { Action } from "../../../dispatcher/actions";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import AddressSelector from '../elements/AddressSelector';
import AddressTile from '../elements/AddressTile';
import BaseDialog from "./BaseDialog";
import DialogButtons from "../elements/DialogButtons";
const TRUNCATE_QUERY_LIST = 40; const TRUNCATE_QUERY_LIST = 40;
const QUERY_USER_DIRECTORY_DEBOUNCE_MS = 200; const QUERY_USER_DIRECTORY_DEBOUNCE_MS = 200;
@ -44,29 +46,64 @@ const addressTypeName = {
'email': _td("email address"), 'email': _td("email address"),
}; };
@replaceableComponent("views.dialogs.AddressPickerDialog") interface IResult {
export default class AddressPickerDialog extends React.Component { user_id: string; // eslint-disable-line camelcase
static propTypes = { room_id?: string; // eslint-disable-line camelcase
title: PropTypes.string.isRequired, name?: string;
description: PropTypes.node, display_name?: string; // eslint-disable-line camelcase
// Extra node inserted after picker input, dropdown and errors avatar_url?: string;// eslint-disable-line camelcase
extraNode: PropTypes.node, }
value: PropTypes.string,
placeholder: PropTypes.oneOfType([PropTypes.string, PropTypes.func]),
roomId: PropTypes.string,
button: PropTypes.string,
focus: PropTypes.bool,
validAddressTypes: PropTypes.arrayOf(PropTypes.oneOf(addressTypes)),
onFinished: PropTypes.func.isRequired,
groupId: PropTypes.string,
// The type of entity to search for. Default: 'user'.
pickerType: PropTypes.oneOf(['user', 'room']),
// Whether the current user should be included in the addresses returned. Only
// applicable when pickerType is `user`. Default: false.
includeSelf: PropTypes.bool,
};
static defaultProps = { interface IProps {
title: string;
description?: JSX.Element;
// Extra node inserted after picker input, dropdown and errors
extraNode?: JSX.Element;
value?: string;
placeholder?: ((validAddressTypes: any) => string) | string;
roomId?: string;
button?: string;
focus?: boolean;
validAddressTypes?: AddressType[];
onFinished: (success: boolean, list?: IUserAddress[]) => void;
groupId?: string;
// The type of entity to search for. Default: 'user'.
pickerType?: 'user' | 'room';
// Whether the current user should be included in the addresses returned. Only
// applicable when pickerType is `user`. Default: false.
includeSelf?: boolean;
}
interface IState {
// Whether to show an error message because of an invalid address
invalidAddressError: boolean;
// List of UserAddressType objects representing
// the list of addresses we're going to invite
selectedList: IUserAddress[];
// Whether a search is ongoing
busy: boolean;
// An error message generated during the user directory search
searchError: string;
// Whether the server supports the user_directory API
serverSupportsUserDirectory: boolean;
// The query being searched for
query: string;
// List of UserAddressType objects representing the set of
// auto-completion results for the current search query.
suggestedList: IUserAddress[];
// List of address types initialised from props, but may change while the
// dialog is open and represents the supported list of address types at this time.
validAddressTypes: AddressType[];
}
@replaceableComponent("views.dialogs.AddressPickerDialog")
export default class AddressPickerDialog extends React.Component<IProps, IState> {
private textinput = createRef<HTMLTextAreaElement>();
private addressSelector = createRef<AddressSelector>();
private queryChangedDebouncer: number;
private cancelThreepidLookup: () => void;
static defaultProps: Partial<IProps> = {
value: "", value: "",
focus: true, focus: true,
validAddressTypes: addressTypes, validAddressTypes: addressTypes,
@ -74,36 +111,23 @@ export default class AddressPickerDialog extends React.Component {
includeSelf: false, includeSelf: false,
}; };
constructor(props) { constructor(props: IProps) {
super(props); super(props);
this._textinput = createRef();
let validAddressTypes = this.props.validAddressTypes; let validAddressTypes = this.props.validAddressTypes;
// Remove email from validAddressTypes if no IS is configured. It may be added at a later stage by the user // Remove email from validAddressTypes if no IS is configured. It may be added at a later stage by the user
if (!MatrixClientPeg.get().getIdentityServerUrl() && validAddressTypes.includes("email")) { if (!MatrixClientPeg.get().getIdentityServerUrl() && validAddressTypes.includes(AddressType.Email)) {
validAddressTypes = validAddressTypes.filter(type => type !== "email"); validAddressTypes = validAddressTypes.filter(type => type !== AddressType.Email);
} }
this.state = { this.state = {
// Whether to show an error message because of an invalid address
invalidAddressError: false, invalidAddressError: false,
// List of UserAddressType objects representing
// the list of addresses we're going to invite
selectedList: [], selectedList: [],
// Whether a search is ongoing
busy: false, busy: false,
// An error message generated during the user directory search
searchError: null, searchError: null,
// Whether the server supports the user_directory API
serverSupportsUserDirectory: true, serverSupportsUserDirectory: true,
// The query being searched for
query: "", query: "",
// List of UserAddressType objects representing the set of
// auto-completion results for the current search query.
suggestedList: [], suggestedList: [],
// List of address types initialised from props, but may change while the
// dialog is open and represents the supported list of address types at this time.
validAddressTypes, validAddressTypes,
}; };
} }
@ -111,11 +135,11 @@ export default class AddressPickerDialog extends React.Component {
componentDidMount() { componentDidMount() {
if (this.props.focus) { if (this.props.focus) {
// Set the cursor at the end of the text input // Set the cursor at the end of the text input
this._textinput.current.value = this.props.value; this.textinput.current.value = this.props.value;
} }
} }
getPlaceholder() { private getPlaceholder(): string {
const { placeholder } = this.props; const { placeholder } = this.props;
if (typeof placeholder === "string") { if (typeof placeholder === "string") {
return placeholder; return placeholder;
@ -124,23 +148,23 @@ export default class AddressPickerDialog extends React.Component {
return placeholder(this.state.validAddressTypes); return placeholder(this.state.validAddressTypes);
} }
onButtonClick = () => { private onButtonClick = (): void => {
let selectedList = this.state.selectedList.slice(); let selectedList = this.state.selectedList.slice();
// Check the text input field to see if user has an unconverted address // Check the text input field to see if user has an unconverted address
// If there is and it's valid add it to the local selectedList // If there is and it's valid add it to the local selectedList
if (this._textinput.current.value !== '') { if (this.textinput.current.value !== '') {
selectedList = this._addAddressesToList([this._textinput.current.value]); selectedList = this.addAddressesToList([this.textinput.current.value]);
if (selectedList === null) return; if (selectedList === null) return;
} }
this.props.onFinished(true, selectedList); this.props.onFinished(true, selectedList);
}; };
onCancel = () => { private onCancel = (): void => {
this.props.onFinished(false); this.props.onFinished(false);
}; };
onKeyDown = e => { private onKeyDown = (e: React.KeyboardEvent): void => {
const textInput = this._textinput.current ? this._textinput.current.value : undefined; const textInput = this.textinput.current ? this.textinput.current.value : undefined;
if (e.key === Key.ESCAPE) { if (e.key === Key.ESCAPE) {
e.stopPropagation(); e.stopPropagation();
@ -149,15 +173,15 @@ export default class AddressPickerDialog extends React.Component {
} else if (e.key === Key.ARROW_UP) { } else if (e.key === Key.ARROW_UP) {
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
if (this.addressSelector) this.addressSelector.moveSelectionUp(); if (this.addressSelector.current) this.addressSelector.current.moveSelectionUp();
} else if (e.key === Key.ARROW_DOWN) { } else if (e.key === Key.ARROW_DOWN) {
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
if (this.addressSelector) this.addressSelector.moveSelectionDown(); if (this.addressSelector.current) this.addressSelector.current.moveSelectionDown();
} else if (this.state.suggestedList.length > 0 && [Key.COMMA, Key.ENTER, Key.TAB].includes(e.key)) { } else if (this.state.suggestedList.length > 0 && [Key.COMMA, Key.ENTER, Key.TAB].includes(e.key)) {
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
if (this.addressSelector) this.addressSelector.chooseSelection(); if (this.addressSelector.current) this.addressSelector.current.chooseSelection();
} else if (textInput.length === 0 && this.state.selectedList.length && e.key === Key.BACKSPACE) { } else if (textInput.length === 0 && this.state.selectedList.length && e.key === Key.BACKSPACE) {
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
@ -169,17 +193,17 @@ export default class AddressPickerDialog extends React.Component {
// if there's nothing in the input box, submit the form // if there's nothing in the input box, submit the form
this.onButtonClick(); this.onButtonClick();
} else { } else {
this._addAddressesToList([textInput]); this.addAddressesToList([textInput]);
} }
} else if (textInput && (e.key === Key.COMMA || e.key === Key.TAB)) { } else if (textInput && (e.key === Key.COMMA || e.key === Key.TAB)) {
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
this._addAddressesToList([textInput]); this.addAddressesToList([textInput]);
} }
}; };
onQueryChanged = ev => { private onQueryChanged = (ev: React.ChangeEvent): void => {
const query = ev.target.value; const query = (ev.target as HTMLTextAreaElement).value;
if (this.queryChangedDebouncer) { if (this.queryChangedDebouncer) {
clearTimeout(this.queryChangedDebouncer); clearTimeout(this.queryChangedDebouncer);
} }
@ -188,17 +212,17 @@ export default class AddressPickerDialog extends React.Component {
this.queryChangedDebouncer = setTimeout(() => { this.queryChangedDebouncer = setTimeout(() => {
if (this.props.pickerType === 'user') { if (this.props.pickerType === 'user') {
if (this.props.groupId) { if (this.props.groupId) {
this._doNaiveGroupSearch(query); this.doNaiveGroupSearch(query);
} else if (this.state.serverSupportsUserDirectory) { } else if (this.state.serverSupportsUserDirectory) {
this._doUserDirectorySearch(query); this.doUserDirectorySearch(query);
} else { } else {
this._doLocalSearch(query); this.doLocalSearch(query);
} }
} else if (this.props.pickerType === 'room') { } else if (this.props.pickerType === 'room') {
if (this.props.groupId) { if (this.props.groupId) {
this._doNaiveGroupRoomSearch(query); this.doNaiveGroupRoomSearch(query);
} else { } else {
this._doRoomSearch(query); this.doRoomSearch(query);
} }
} else { } else {
console.error('Unknown pickerType', this.props.pickerType); console.error('Unknown pickerType', this.props.pickerType);
@ -213,7 +237,7 @@ export default class AddressPickerDialog extends React.Component {
} }
}; };
onDismissed = index => () => { private onDismissed = (index: number) => () => {
const selectedList = this.state.selectedList.slice(); const selectedList = this.state.selectedList.slice();
selectedList.splice(index, 1); selectedList.splice(index, 1);
this.setState({ this.setState({
@ -221,25 +245,21 @@ export default class AddressPickerDialog extends React.Component {
suggestedList: [], suggestedList: [],
query: "", query: "",
}); });
if (this._cancelThreepidLookup) this._cancelThreepidLookup(); if (this.cancelThreepidLookup) this.cancelThreepidLookup();
}; };
onClick = index => () => { private onSelected = (index: number): void => {
this.onSelected(index);
};
onSelected = index => {
const selectedList = this.state.selectedList.slice(); const selectedList = this.state.selectedList.slice();
selectedList.push(this._getFilteredSuggestions()[index]); selectedList.push(this.getFilteredSuggestions()[index]);
this.setState({ this.setState({
selectedList, selectedList,
suggestedList: [], suggestedList: [],
query: "", query: "",
}); });
if (this._cancelThreepidLookup) this._cancelThreepidLookup(); if (this.cancelThreepidLookup) this.cancelThreepidLookup();
}; };
_doNaiveGroupSearch(query) { private doNaiveGroupSearch(query: string): void {
const lowerCaseQuery = query.toLowerCase(); const lowerCaseQuery = query.toLowerCase();
this.setState({ this.setState({
busy: true, busy: true,
@ -260,7 +280,7 @@ export default class AddressPickerDialog extends React.Component {
display_name: u.displayname, display_name: u.displayname,
}); });
}); });
this._processResults(results, query); this.processResults(results, query);
}).catch((err) => { }).catch((err) => {
console.error('Error whilst searching group rooms: ', err); console.error('Error whilst searching group rooms: ', err);
this.setState({ this.setState({
@ -273,7 +293,7 @@ export default class AddressPickerDialog extends React.Component {
}); });
} }
_doNaiveGroupRoomSearch(query) { private doNaiveGroupRoomSearch(query: string): void {
const lowerCaseQuery = query.toLowerCase(); const lowerCaseQuery = query.toLowerCase();
const results = []; const results = [];
GroupStore.getGroupRooms(this.props.groupId).forEach((r) => { GroupStore.getGroupRooms(this.props.groupId).forEach((r) => {
@ -289,13 +309,13 @@ export default class AddressPickerDialog extends React.Component {
name: r.name || r.canonical_alias, name: r.name || r.canonical_alias,
}); });
}); });
this._processResults(results, query); this.processResults(results, query);
this.setState({ this.setState({
busy: false, busy: false,
}); });
} }
_doRoomSearch(query) { private doRoomSearch(query: string): void {
const lowerCaseQuery = query.toLowerCase(); const lowerCaseQuery = query.toLowerCase();
const rooms = MatrixClientPeg.get().getRooms(); const rooms = MatrixClientPeg.get().getRooms();
const results = []; const results = [];
@ -346,13 +366,13 @@ export default class AddressPickerDialog extends React.Component {
return a.rank - b.rank; return a.rank - b.rank;
}); });
this._processResults(sortedResults, query); this.processResults(sortedResults, query);
this.setState({ this.setState({
busy: false, busy: false,
}); });
} }
_doUserDirectorySearch(query) { private doUserDirectorySearch(query: string): void {
this.setState({ this.setState({
busy: true, busy: true,
query, query,
@ -366,7 +386,7 @@ export default class AddressPickerDialog extends React.Component {
if (this.state.query !== query) { if (this.state.query !== query) {
return; return;
} }
this._processResults(resp.results, query); this.processResults(resp.results, query);
}).catch((err) => { }).catch((err) => {
console.error('Error whilst searching user directory: ', err); console.error('Error whilst searching user directory: ', err);
this.setState({ this.setState({
@ -377,7 +397,7 @@ export default class AddressPickerDialog extends React.Component {
serverSupportsUserDirectory: false, serverSupportsUserDirectory: false,
}); });
// Do a local search immediately // Do a local search immediately
this._doLocalSearch(query); this.doLocalSearch(query);
} }
}).then(() => { }).then(() => {
this.setState({ this.setState({
@ -386,7 +406,7 @@ export default class AddressPickerDialog extends React.Component {
}); });
} }
_doLocalSearch(query) { private doLocalSearch(query: string): void {
this.setState({ this.setState({
query, query,
searchError: null, searchError: null,
@ -407,10 +427,10 @@ export default class AddressPickerDialog extends React.Component {
avatar_url: user.avatarUrl, avatar_url: user.avatarUrl,
}); });
}); });
this._processResults(results, query); this.processResults(results, query);
} }
_processResults(results, query) { private processResults(results: IResult[], query: string): void {
const suggestedList = []; const suggestedList = [];
results.forEach((result) => { results.forEach((result) => {
if (result.room_id) { if (result.room_id) {
@ -465,27 +485,27 @@ export default class AddressPickerDialog extends React.Component {
address: query, address: query,
isKnown: false, isKnown: false,
}); });
if (this._cancelThreepidLookup) this._cancelThreepidLookup(); if (this.cancelThreepidLookup) this.cancelThreepidLookup();
if (addrType === 'email') { if (addrType === 'email') {
this._lookupThreepid(addrType, query); this.lookupThreepid(addrType, query);
} }
} }
this.setState({ this.setState({
suggestedList, suggestedList,
invalidAddressError: false, invalidAddressError: false,
}, () => { }, () => {
if (this.addressSelector) this.addressSelector.moveSelectionTop(); if (this.addressSelector.current) this.addressSelector.current.moveSelectionTop();
}); });
} }
_addAddressesToList(addressTexts) { private addAddressesToList(addressTexts: string[]): IUserAddress[] {
const selectedList = this.state.selectedList.slice(); const selectedList = this.state.selectedList.slice();
let hasError = false; let hasError = false;
addressTexts.forEach((addressText) => { addressTexts.forEach((addressText) => {
addressText = addressText.trim(); addressText = addressText.trim();
const addrType = getAddressType(addressText); const addrType = getAddressType(addressText);
const addrObj = { const addrObj: IUserAddress = {
addressType: addrType, addressType: addrType,
address: addressText, address: addressText,
isKnown: false, isKnown: false,
@ -504,7 +524,6 @@ export default class AddressPickerDialog extends React.Component {
const room = MatrixClientPeg.get().getRoom(addrObj.address); const room = MatrixClientPeg.get().getRoom(addrObj.address);
if (room) { if (room) {
addrObj.displayName = room.name; addrObj.displayName = room.name;
addrObj.avatarMxc = room.avatarUrl;
addrObj.isKnown = true; addrObj.isKnown = true;
} }
} }
@ -518,17 +537,17 @@ export default class AddressPickerDialog extends React.Component {
query: "", query: "",
invalidAddressError: hasError ? true : this.state.invalidAddressError, invalidAddressError: hasError ? true : this.state.invalidAddressError,
}); });
if (this._cancelThreepidLookup) this._cancelThreepidLookup(); if (this.cancelThreepidLookup) this.cancelThreepidLookup();
return hasError ? null : selectedList; return hasError ? null : selectedList;
} }
async _lookupThreepid(medium, address) { private async lookupThreepid(medium: AddressType, address: string): Promise<string> {
let cancelled = false; let cancelled = false;
// Note that we can't safely remove this after we're done // Note that we can't safely remove this after we're done
// because we don't know that it's the same one, so we just // because we don't know that it's the same one, so we just
// leave it: it's replacing the old one each time so it's // leave it: it's replacing the old one each time so it's
// not like they leak. // not like they leak.
this._cancelThreepidLookup = function() { this.cancelThreepidLookup = function() {
cancelled = true; cancelled = true;
}; };
@ -570,7 +589,7 @@ export default class AddressPickerDialog extends React.Component {
} }
} }
_getFilteredSuggestions() { private getFilteredSuggestions(): IUserAddress[] {
// map addressType => set of addresses to avoid O(n*m) operation // map addressType => set of addresses to avoid O(n*m) operation
const selectedAddresses = {}; const selectedAddresses = {};
this.state.selectedList.forEach(({ address, addressType }) => { this.state.selectedList.forEach(({ address, addressType }) => {
@ -584,15 +603,15 @@ export default class AddressPickerDialog extends React.Component {
}); });
} }
_onPaste = e => { private onPaste = (e: React.ClipboardEvent): void => {
// Prevent the text being pasted into the textarea // Prevent the text being pasted into the textarea
e.preventDefault(); e.preventDefault();
const text = e.clipboardData.getData("text"); const text = e.clipboardData.getData("text");
// Process it as a list of addresses to add instead // Process it as a list of addresses to add instead
this._addAddressesToList(text.split(/[\s,]+/)); this.addAddressesToList(text.split(/[\s,]+/));
}; };
onUseDefaultIdentityServerClick = e => { private onUseDefaultIdentityServerClick = (e: React.MouseEvent): void => {
e.preventDefault(); e.preventDefault();
// Update the IS in account data. Actually using it may trigger terms. // Update the IS in account data. Actually using it may trigger terms.
@ -601,22 +620,17 @@ export default class AddressPickerDialog extends React.Component {
// Add email as a valid address type. // Add email as a valid address type.
const { validAddressTypes } = this.state; const { validAddressTypes } = this.state;
validAddressTypes.push('email'); validAddressTypes.push(AddressType.Email);
this.setState({ validAddressTypes }); this.setState({ validAddressTypes });
}; };
onManageSettingsClick = e => { private onManageSettingsClick = (e: React.MouseEvent): void => {
e.preventDefault(); e.preventDefault();
dis.fire(Action.ViewUserSettings); dis.fire(Action.ViewUserSettings);
this.onCancel(); this.onCancel();
}; };
render() { render() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
const AddressSelector = sdk.getComponent("elements.AddressSelector");
this.scrollElement = null;
let inputLabel; let inputLabel;
if (this.props.description) { if (this.props.description) {
inputLabel = <div className="mx_AddressPickerDialog_label"> inputLabel = <div className="mx_AddressPickerDialog_label">
@ -627,7 +641,6 @@ export default class AddressPickerDialog extends React.Component {
const query = []; const query = [];
// create the invite list // create the invite list
if (this.state.selectedList.length > 0) { if (this.state.selectedList.length > 0) {
const AddressTile = sdk.getComponent("elements.AddressTile");
for (let i = 0; i < this.state.selectedList.length; i++) { for (let i = 0; i < this.state.selectedList.length; i++) {
query.push( query.push(
<AddressTile <AddressTile
@ -644,19 +657,19 @@ export default class AddressPickerDialog extends React.Component {
query.push( query.push(
<textarea <textarea
key={this.state.selectedList.length} key={this.state.selectedList.length}
onPaste={this._onPaste} onPaste={this.onPaste}
rows="1" rows={1}
id="textinput" id="textinput"
ref={this._textinput} ref={this.textinput}
className="mx_AddressPickerDialog_input" className="mx_AddressPickerDialog_input"
onChange={this.onQueryChanged} onChange={this.onQueryChanged}
placeholder={this.getPlaceholder()} placeholder={this.getPlaceholder()}
defaultValue={this.props.value} defaultValue={this.props.value}
autoFocus={this.props.focus}> autoFocus={this.props.focus}
</textarea>, />,
); );
const filteredSuggestedList = this._getFilteredSuggestions(); const filteredSuggestedList = this.getFilteredSuggestions();
let error; let error;
let addressSelector; let addressSelector;
@ -675,7 +688,7 @@ export default class AddressPickerDialog extends React.Component {
error = <div className="mx_AddressPickerDialog_error">{ _t("No results") }</div>; error = <div className="mx_AddressPickerDialog_error">{ _t("No results") }</div>;
} else { } else {
addressSelector = ( addressSelector = (
<AddressSelector ref={(ref) => {this.addressSelector = ref;}} <AddressSelector ref={this.addressSelector}
addressList={filteredSuggestedList} addressList={filteredSuggestedList}
showAddress={this.props.pickerType === 'user'} showAddress={this.props.pickerType === 'user'}
onSelected={this.onSelected} onSelected={this.onSelected}
@ -686,8 +699,8 @@ export default class AddressPickerDialog extends React.Component {
let identityServer; let identityServer;
// If picker cannot currently accept e-mail but should be able to // If picker cannot currently accept e-mail but should be able to
if (this.props.pickerType === 'user' && !this.state.validAddressTypes.includes('email') if (this.props.pickerType === 'user' && !this.state.validAddressTypes.includes(AddressType.Email)
&& this.props.validAddressTypes.includes('email')) { && this.props.validAddressTypes.includes(AddressType.Email)) {
const defaultIdentityServerUrl = getDefaultIdentityServerUrl(); const defaultIdentityServerUrl = getDefaultIdentityServerUrl();
if (defaultIdentityServerUrl) { if (defaultIdentityServerUrl) {
identityServer = <div className="mx_AddressPickerDialog_identityServer">{ _t( identityServer = <div className="mx_AddressPickerDialog_identityServer">{ _t(
@ -714,8 +727,12 @@ export default class AddressPickerDialog extends React.Component {
} }
return ( return (
<BaseDialog className="mx_AddressPickerDialog" onKeyDown={this.onKeyDown} <BaseDialog
onFinished={this.props.onFinished} title={this.props.title}> className="mx_AddressPickerDialog"
onKeyDown={this.onKeyDown}
onFinished={this.props.onFinished}
title={this.props.title}
>
{ inputLabel } { inputLabel }
<div className="mx_Dialog_content"> <div className="mx_Dialog_content">
<div className="mx_AddressPickerDialog_inputContainer">{ query }</div> <div className="mx_AddressPickerDialog_inputContainer">{ query }</div>

View file

@ -118,9 +118,7 @@ export default class BaseDialog extends React.Component {
let headerImage; let headerImage;
if (this.props.headerImage) { if (this.props.headerImage) {
headerImage = <img className="mx_Dialog_titleImage" src={this.props.headerImage} headerImage = <img className="mx_Dialog_titleImage" src={this.props.headerImage} alt="" />;
alt=""
/>;
} }
return ( return (

View file

@ -71,13 +71,16 @@ const BetaFeedbackDialog: React.FC<IProps> = ({ featureId, onFinished }) => {
&nbsp; &nbsp;
{ _t("Your platform and username will be noted to help us use your feedback as much as we can.") } { _t("Your platform and username will be noted to help us use your feedback as much as we can.") }
<AccessibleButton kind="link" onClick={() => { <AccessibleButton
onFinished(false); kind="link"
defaultDispatcher.dispatch({ onClick={() => {
onFinished(false);
defaultDispatcher.dispatch({
action: Action.ViewUserSettings, action: Action.ViewUserSettings,
initialTabId: UserTab.Labs, initialTabId: UserTab.Labs,
}); });
}}> }}
>
{ _t("To leave the beta, visit your settings.") } { _t("To leave the beta, visit your settings.") }
</AccessibleButton> </AccessibleButton>
</div> </div>

View file

@ -188,7 +188,9 @@ export default class BugReportDialog extends React.Component<IProps, IState> {
} }
return ( return (
<BaseDialog className="mx_BugReportDialog" onFinished={this.onCancel} <BaseDialog
className="mx_BugReportDialog"
onFinished={this.onCancel}
title={_t('Submit debug logs')} title={_t('Submit debug logs')}
contentId='mx_Dialog_content' contentId='mx_Dialog_content'
> >

View file

@ -205,9 +205,12 @@ export default class CommunityPrototypeInviteDialog extends React.PureComponent<
people.push(( people.push((
<AccessibleButton <AccessibleButton
onClick={this.onShowMorePeople} onClick={this.onShowMorePeople}
kind="link" key="more" kind="link"
key="more"
className="mx_CommunityPrototypeInviteDialog_morePeople" className="mx_CommunityPrototypeInviteDialog_morePeople"
>{ _t("Show more") }</AccessibleButton> >
{ _t("Show more") }
</AccessibleButton>
)); ));
} }
} }
@ -240,10 +243,13 @@ export default class CommunityPrototypeInviteDialog extends React.PureComponent<
{ peopleIntro } { peopleIntro }
{ people } { people }
<AccessibleButton <AccessibleButton
kind="primary" onClick={this.onSubmit} kind="primary"
onClick={this.onSubmit}
disabled={this.state.busy} disabled={this.state.busy}
className="mx_CommunityPrototypeInviteDialog_primaryButton" className="mx_CommunityPrototypeInviteDialog_primaryButton"
>{ buttonText }</AccessibleButton> >
{ buttonText }
</AccessibleButton>
</div> </div>
</form> </form>
</BaseDialog> </BaseDialog>

View file

@ -37,8 +37,8 @@ export default class ConfirmRedactDialog extends React.Component<IProps> {
"Note that if you delete a room name or topic change, it could undo the change.")} "Note that if you delete a room name or topic change, it could undo the change.")}
placeholder={_t("Reason (optional)")} placeholder={_t("Reason (optional)")}
focus focus
button={_t("Remove")}> button={_t("Remove")}
</TextInputDialog> />
); );
} }
} }

View file

@ -104,7 +104,9 @@ export default class ConfirmUserActionDialog extends React.Component<IProps> {
} }
return ( return (
<BaseDialog className="mx_ConfirmUserActionDialog" onFinished={this.props.onFinished} <BaseDialog
className="mx_ConfirmUserActionDialog"
onFinished={this.props.onFinished}
title={this.props.title} title={this.props.title}
contentId='mx_Dialog_content' contentId='mx_Dialog_content'
> >

View file

@ -204,8 +204,10 @@ export default class CreateCommunityPrototypeDialog extends React.PureComponent<
</div> </div>
<div className="mx_CreateCommunityPrototypeDialog_colAvatar"> <div className="mx_CreateCommunityPrototypeDialog_colAvatar">
<input <input
type="file" style={{ display: "none" }} type="file"
ref={this.avatarUploadRef} accept="image/*" style={{ display: "none" }}
ref={this.avatarUploadRef}
accept="image/*"
onChange={this.onAvatarChanged} onChange={this.onAvatarChanged}
/> />
<AccessibleButton <AccessibleButton

View file

@ -123,7 +123,9 @@ export default class CreateGroupDialog extends React.Component<IProps, IState> {
} }
return ( return (
<BaseDialog className="mx_CreateGroupDialog" onFinished={this.props.onFinished} <BaseDialog
className="mx_CreateGroupDialog"
onFinished={this.props.onFinished}
title={_t('Create Community')} title={_t('Create Community')}
> >
<form onSubmit={this.onFormSubmit}> <form onSubmit={this.onFormSubmit}>
@ -133,8 +135,11 @@ export default class CreateGroupDialog extends React.Component<IProps, IState> {
<label htmlFor="groupname">{ _t('Community Name') }</label> <label htmlFor="groupname">{ _t('Community Name') }</label>
</div> </div>
<div> <div>
<input id="groupname" className="mx_CreateGroupDialog_input" <input
autoFocus={true} size={64} id="groupname"
className="mx_CreateGroupDialog_input"
autoFocus={true}
size={64}
placeholder={_t('Example')} placeholder={_t('Example')}
onChange={this.onGroupNameChange} onChange={this.onGroupNameChange}
value={this.state.groupName} value={this.state.groupName}

View file

@ -17,6 +17,7 @@ limitations under the License.
import React, { ChangeEvent, createRef, KeyboardEvent, SyntheticEvent } from "react"; import React, { ChangeEvent, createRef, KeyboardEvent, SyntheticEvent } from "react";
import { Room } from "matrix-js-sdk/src/models/room"; import { Room } from "matrix-js-sdk/src/models/room";
import { JoinRule, Preset, Visibility } from "matrix-js-sdk/src/@types/partials";
import SdkConfig from '../../../SdkConfig'; import SdkConfig from '../../../SdkConfig';
import withValidation, { IFieldState } from '../elements/Validation'; import withValidation, { IFieldState } from '../elements/Validation';
@ -31,7 +32,8 @@ import RoomAliasField from "../elements/RoomAliasField";
import LabelledToggleSwitch from "../elements/LabelledToggleSwitch"; import LabelledToggleSwitch from "../elements/LabelledToggleSwitch";
import DialogButtons from "../elements/DialogButtons"; import DialogButtons from "../elements/DialogButtons";
import BaseDialog from "../dialogs/BaseDialog"; import BaseDialog from "../dialogs/BaseDialog";
import { Preset, Visibility } from "matrix-js-sdk/src/@types/partials"; import Dropdown from "../elements/Dropdown";
import SpaceStore from "../../../stores/SpaceStore";
interface IProps { interface IProps {
defaultPublic?: boolean; defaultPublic?: boolean;
@ -41,7 +43,7 @@ interface IProps {
} }
interface IState { interface IState {
isPublic: boolean; joinRule: JoinRule;
isEncrypted: boolean; isEncrypted: boolean;
name: string; name: string;
topic: string; topic: string;
@ -54,15 +56,25 @@ interface IState {
@replaceableComponent("views.dialogs.CreateRoomDialog") @replaceableComponent("views.dialogs.CreateRoomDialog")
export default class CreateRoomDialog extends React.Component<IProps, IState> { export default class CreateRoomDialog extends React.Component<IProps, IState> {
private readonly supportsRestricted: boolean;
private nameField = createRef<Field>(); private nameField = createRef<Field>();
private aliasField = createRef<RoomAliasField>(); private aliasField = createRef<RoomAliasField>();
constructor(props) { constructor(props) {
super(props); super(props);
this.supportsRestricted = this.props.parentSpace && !!SpaceStore.instance.restrictedJoinRuleSupport?.preferred;
let joinRule = JoinRule.Invite;
if (this.props.defaultPublic) {
joinRule = JoinRule.Public;
} else if (this.supportsRestricted) {
joinRule = JoinRule.Restricted;
}
const config = SdkConfig.get(); const config = SdkConfig.get();
this.state = { this.state = {
isPublic: this.props.defaultPublic || false, joinRule,
isEncrypted: privateShouldBeEncrypted(), isEncrypted: privateShouldBeEncrypted(),
name: this.props.defaultName || "", name: this.props.defaultName || "",
topic: "", topic: "",
@ -81,13 +93,18 @@ export default class CreateRoomDialog extends React.Component<IProps, IState> {
const opts: IOpts = {}; const opts: IOpts = {};
const createOpts: IOpts["createOpts"] = opts.createOpts = {}; const createOpts: IOpts["createOpts"] = opts.createOpts = {};
createOpts.name = this.state.name; createOpts.name = this.state.name;
if (this.state.isPublic) {
if (this.state.joinRule === JoinRule.Public) {
createOpts.visibility = Visibility.Public; createOpts.visibility = Visibility.Public;
createOpts.preset = Preset.PublicChat; createOpts.preset = Preset.PublicChat;
opts.guestAccess = false; opts.guestAccess = false;
const { alias } = this.state; const { alias } = this.state;
createOpts.room_alias_name = alias.substr(1, alias.indexOf(":") - 1); createOpts.room_alias_name = alias.substr(1, alias.indexOf(":") - 1);
} else {
// If we cannot change encryption we pass `true` for safety, the server should automatically do this for us.
opts.encryption = this.state.canChangeEncryption ? this.state.isEncrypted : true;
} }
if (this.state.topic) { if (this.state.topic) {
createOpts.topic = this.state.topic; createOpts.topic = this.state.topic;
} }
@ -95,22 +112,13 @@ export default class CreateRoomDialog extends React.Component<IProps, IState> {
createOpts.creation_content = { 'm.federate': false }; createOpts.creation_content = { 'm.federate': false };
} }
if (!this.state.isPublic) {
if (this.state.canChangeEncryption) {
opts.encryption = this.state.isEncrypted;
} else {
// the server should automatically do this for us, but for safety
// we'll demand it too.
opts.encryption = true;
}
}
if (CommunityPrototypeStore.instance.getSelectedCommunityId()) { if (CommunityPrototypeStore.instance.getSelectedCommunityId()) {
opts.associatedWithCommunity = CommunityPrototypeStore.instance.getSelectedCommunityId(); opts.associatedWithCommunity = CommunityPrototypeStore.instance.getSelectedCommunityId();
} }
if (this.props.parentSpace) { if (this.props.parentSpace && this.state.joinRule === JoinRule.Restricted) {
opts.parentSpace = this.props.parentSpace; opts.parentSpace = this.props.parentSpace;
opts.joinRule = JoinRule.Restricted;
} }
return opts; return opts;
@ -172,8 +180,8 @@ export default class CreateRoomDialog extends React.Component<IProps, IState> {
this.setState({ topic: ev.target.value }); this.setState({ topic: ev.target.value });
}; };
private onPublicChange = (isPublic: boolean) => { private onJoinRuleChange = (joinRule: JoinRule) => {
this.setState({ isPublic }); this.setState({ joinRule });
}; };
private onEncryptedChange = (isEncrypted: boolean) => { private onEncryptedChange = (isEncrypted: boolean) => {
@ -210,7 +218,7 @@ export default class CreateRoomDialog extends React.Component<IProps, IState> {
render() { render() {
let aliasField; let aliasField;
if (this.state.isPublic) { if (this.state.joinRule === JoinRule.Public) {
const domain = MatrixClientPeg.get().getDomain(); const domain = MatrixClientPeg.get().getDomain();
aliasField = ( aliasField = (
<div className="mx_CreateRoomDialog_aliasContainer"> <div className="mx_CreateRoomDialog_aliasContainer">
@ -224,19 +232,46 @@ export default class CreateRoomDialog extends React.Component<IProps, IState> {
); );
} }
let publicPrivateLabel = <p>{ _t( let publicPrivateLabel: JSX.Element;
"Private rooms can be found and joined by invitation only. Public rooms can be " +
"found and joined by anyone.",
) }</p>;
if (CommunityPrototypeStore.instance.getSelectedCommunityId()) { if (CommunityPrototypeStore.instance.getSelectedCommunityId()) {
publicPrivateLabel = <p>{ _t( publicPrivateLabel = <p>
"Private rooms can be found and joined by invitation only. Public rooms can be " + { _t(
"found and joined by anyone in this community.", "Private rooms can be found and joined by invitation only. Public rooms can be " +
) }</p>; "found and joined by anyone in this community.",
) }
</p>;
} else if (this.state.joinRule === JoinRule.Restricted) {
publicPrivateLabel = <p>
{ _t(
"Everyone in <SpaceName/> will be able to find and join this room.", {}, {
SpaceName: () => <b>{ this.props.parentSpace.name }</b>,
},
) }
&nbsp;
{ _t("You can change this at any time from room settings.") }
</p>;
} else if (this.state.joinRule === JoinRule.Public) {
publicPrivateLabel = <p>
{ _t(
"Anyone will be able to find and join this room, not just members of <SpaceName/>.", {}, {
SpaceName: () => <b>{ this.props.parentSpace.name }</b>,
},
) }
&nbsp;
{ _t("You can change this at any time from room settings.") }
</p>;
} else if (this.state.joinRule === JoinRule.Invite) {
publicPrivateLabel = <p>
{ _t(
"Only people invited will be able to find and join this room.",
) }
&nbsp;
{ _t("You can change this at any time from room settings.") }
</p>;
} }
let e2eeSection; let e2eeSection;
if (!this.state.isPublic) { if (this.state.joinRule !== JoinRule.Public) {
let microcopy; let microcopy;
if (privateShouldBeEncrypted()) { if (privateShouldBeEncrypted()) {
if (this.state.canChangeEncryption) { if (this.state.canChangeEncryption) {
@ -273,15 +308,31 @@ export default class CreateRoomDialog extends React.Component<IProps, IState> {
); );
} }
let title = this.state.isPublic ? _t('Create a public room') : _t('Create a private room'); let title = _t("Create a room");
if (CommunityPrototypeStore.instance.getSelectedCommunityId()) { if (CommunityPrototypeStore.instance.getSelectedCommunityId()) {
const name = CommunityPrototypeStore.instance.getSelectedCommunityName(); const name = CommunityPrototypeStore.instance.getSelectedCommunityName();
title = _t("Create a room in %(communityName)s", { communityName: name }); title = _t("Create a room in %(communityName)s", { communityName: name });
} else if (!this.props.parentSpace) {
title = this.state.joinRule === JoinRule.Public ? _t('Create a public room') : _t('Create a private room');
} }
const options = [
<div key={JoinRule.Invite} className="mx_CreateRoomDialog_dropdown_invite">
{ _t("Private room (invite only)") }
</div>,
<div key={JoinRule.Public} className="mx_CreateRoomDialog_dropdown_public">
{ _t("Public room") }
</div>,
];
if (this.supportsRestricted) {
options.unshift(<div key={JoinRule.Restricted} className="mx_CreateRoomDialog_dropdown_restricted">
{ _t("Visible to space members") }
</div>);
}
return ( return (
<BaseDialog className="mx_CreateRoomDialog" onFinished={this.props.onFinished} <BaseDialog className="mx_CreateRoomDialog" onFinished={this.props.onFinished} title={title}>
title={title}
>
<form onSubmit={this.onOk} onKeyDown={this.onKeyDown}> <form onSubmit={this.onOk} onKeyDown={this.onKeyDown}>
<div className="mx_Dialog_content"> <div className="mx_Dialog_content">
<Field <Field
@ -298,11 +349,18 @@ export default class CreateRoomDialog extends React.Component<IProps, IState> {
value={this.state.topic} value={this.state.topic}
className="mx_CreateRoomDialog_topic" className="mx_CreateRoomDialog_topic"
/> />
<LabelledToggleSwitch
label={_t("Make this room public")} <Dropdown
onChange={this.onPublicChange} id="mx_CreateRoomDialog_typeDropdown"
value={this.state.isPublic} className="mx_CreateRoomDialog_typeDropdown"
/> onOptionChange={this.onJoinRuleChange}
menuWidth={448}
value={this.state.joinRule}
label={_t("Room visibility")}
>
{ options }
</Dropdown>
{ publicPrivateLabel } { publicPrivateLabel }
{ e2eeSection } { e2eeSection }
{ aliasField } { aliasField }

View file

@ -72,7 +72,7 @@ const CryptoStoreTooNewDialog: React.FC<IProps> = (props: IProps) => {
hasCancel={false} hasCancel={false}
onPrimaryButtonClick={props.onFinished} onPrimaryButtonClick={props.onFinished}
> >
<button onClick={_onLogoutClicked} > <button onClick={_onLogoutClicked}>
{ _t('Sign out') } { _t('Sign out') }
</button> </button>
</DialogButtons> </DialogButtons>

View file

@ -182,14 +182,23 @@ export class SendCustomEvent extends GenericEditor<ISendCustomEventProps, ISendC
<br /> <br />
<Field id="evContent" label={_t("Event Content")} type="text" className="mx_DevTools_textarea" <Field
autoComplete="off" value={this.state.evContent} onChange={this.onChange} element="textarea" /> id="evContent"
label={_t("Event Content")}
type="text"
className="mx_DevTools_textarea"
autoComplete="off"
value={this.state.evContent}
onChange={this.onChange}
element="textarea" />
</div> </div>
<div className="mx_Dialog_buttons"> <div className="mx_Dialog_buttons">
<button onClick={this.onBack}>{ _t('Back') }</button> <button onClick={this.onBack}>{ _t('Back') }</button>
{ !this.state.message && <button onClick={this.send}>{ _t('Send') }</button> } { !this.state.message && <button onClick={this.send}>{ _t('Send') }</button> }
{ showTglFlip && <div style={{ float: "right" }}> { showTglFlip && <div style={{ float: "right" }}>
<input id="isStateEvent" className="mx_DevTools_tgl mx_DevTools_tgl-flip" <input
id="isStateEvent"
className="mx_DevTools_tgl mx_DevTools_tgl-flip"
type="checkbox" type="checkbox"
checked={this.state.isStateEvent} checked={this.state.isStateEvent}
onChange={this.onChange} onChange={this.onChange}
@ -282,14 +291,24 @@ class SendAccountData extends GenericEditor<ISendAccountDataProps, ISendAccountD
{ this.textInput('eventType', _t('Event Type')) } { this.textInput('eventType', _t('Event Type')) }
<br /> <br />
<Field id="evContent" label={_t("Event Content")} type="text" className="mx_DevTools_textarea" <Field
autoComplete="off" value={this.state.evContent} onChange={this.onChange} element="textarea" /> id="evContent"
label={_t("Event Content")}
type="text"
className="mx_DevTools_textarea"
autoComplete="off"
value={this.state.evContent}
onChange={this.onChange}
element="textarea"
/>
</div> </div>
<div className="mx_Dialog_buttons"> <div className="mx_Dialog_buttons">
<button onClick={this.onBack}>{ _t('Back') }</button> <button onClick={this.onBack}>{ _t('Back') }</button>
{ !this.state.message && <button onClick={this.send}>{ _t('Send') }</button> } { !this.state.message && <button onClick={this.send}>{ _t('Send') }</button> }
{ !this.state.message && <div style={{ float: "right" }}> { !this.state.message && <div style={{ float: "right" }}>
<input id="isRoomAccountData" className="mx_DevTools_tgl mx_DevTools_tgl-flip" <input
id="isRoomAccountData"
className="mx_DevTools_tgl mx_DevTools_tgl-flip"
type="checkbox" type="checkbox"
checked={this.state.isRoomAccountData} checked={this.state.isRoomAccountData}
disabled={this.props.forceMode} disabled={this.props.forceMode}
@ -371,11 +390,18 @@ class FilteredList extends React.PureComponent<IFilteredListProps, IFilteredList
render() { render() {
return <div> return <div>
<Field label={_t('Filter results')} autoFocus={true} size={64} <Field
type="text" autoComplete="off" value={this.props.query} onChange={this.onQuery} label={_t('Filter results')}
autoFocus={true}
size={64}
type="text"
autoComplete="off"
value={this.props.query}
onChange={this.onQuery}
className="mx_TextInputDialog_input mx_DevTools_RoomStateExplorer_query" className="mx_TextInputDialog_input mx_DevTools_RoomStateExplorer_query"
// force re-render so that autoFocus is applied when this component is re-used // force re-render so that autoFocus is applied when this component is re-used
key={this.props.children[0] ? this.props.children[0].key : ''} /> key={this.props.children[0] ? this.props.children[0].key : ''}
/>
<TruncatedList getChildren={this.getChildren} <TruncatedList getChildren={this.getChildren}
getChildCount={this.getChildCount} getChildCount={this.getChildCount}
@ -459,11 +485,16 @@ class RoomStateExplorer extends React.PureComponent<IExplorerProps, IRoomStateEx
render() { render() {
if (this.state.event) { if (this.state.event) {
if (this.state.editing) { if (this.state.editing) {
return <SendCustomEvent room={this.props.room} forceStateEvent={true} onBack={this.onBack} inputs={{ return <SendCustomEvent
room={this.props.room}
forceStateEvent={true}
onBack={this.onBack}
inputs={{
eventType: this.state.event.getType(), eventType: this.state.event.getType(),
evContent: JSON.stringify(this.state.event.getContent(), null, '\t'), evContent: JSON.stringify(this.state.event.getContent(), null, '\t'),
stateKey: this.state.event.getStateKey(), stateKey: this.state.event.getStateKey(),
}} />; }}
/>;
} }
return <div className="mx_ViewSource"> return <div className="mx_ViewSource">
@ -594,7 +625,9 @@ class AccountDataExplorer extends React.PureComponent<IExplorerProps, IAccountDa
inputs={{ inputs={{
eventType: this.state.event.getType(), eventType: this.state.event.getType(),
evContent: JSON.stringify(this.state.event.getContent(), null, '\t'), evContent: JSON.stringify(this.state.event.getContent(), null, '\t'),
}} forceMode={true} />; }}
forceMode={true}
/>;
} }
return <div className="mx_ViewSource"> return <div className="mx_ViewSource">
@ -631,7 +664,9 @@ class AccountDataExplorer extends React.PureComponent<IExplorerProps, IAccountDa
<div className="mx_Dialog_buttons"> <div className="mx_Dialog_buttons">
<button onClick={this.onBack}>{ _t('Back') }</button> <button onClick={this.onBack}>{ _t('Back') }</button>
<div style={{ float: "right" }}> <div style={{ float: "right" }}>
<input id="isRoomAccountData" className="mx_DevTools_tgl mx_DevTools_tgl-flip" <input
id="isRoomAccountData"
className="mx_DevTools_tgl mx_DevTools_tgl-flip"
type="checkbox" type="checkbox"
checked={this.state.isRoomAccountData} checked={this.state.isRoomAccountData}
onChange={this.onChange} onChange={this.onChange}
@ -1021,8 +1056,13 @@ class SettingsExplorer extends React.PureComponent<IExplorerProps, ISettingsExpl
<div> <div>
<div className="mx_Dialog_content mx_DevTools_SettingsExplorer"> <div className="mx_Dialog_content mx_DevTools_SettingsExplorer">
<Field <Field
label={_t('Filter results')} autoFocus={true} size={64} label={_t('Filter results')}
type="text" autoComplete="off" value={this.state.query} onChange={this.onQueryChange} autoFocus={true}
size={64}
type="text"
autoComplete="off"
value={this.state.query}
onChange={this.onQueryChange}
className="mx_TextInputDialog_input mx_DevTools_RoomStateExplorer_query" className="mx_TextInputDialog_input mx_DevTools_RoomStateExplorer_query"
/> />
<table> <table>
@ -1040,7 +1080,9 @@ class SettingsExplorer extends React.PureComponent<IExplorerProps, ISettingsExpl
<a href="" onClick={(e) => this.onViewClick(e, i)}> <a href="" onClick={(e) => this.onViewClick(e, i)}>
<code>{ i }</code> <code>{ i }</code>
</a> </a>
<a href="" onClick={(e) => this.onEditClick(e, i)} <a
href=""
onClick={(e) => this.onEditClick(e, i)}
className='mx_DevTools_SettingsExplorer_edit' className='mx_DevTools_SettingsExplorer_edit'
> >
@ -1104,18 +1146,26 @@ class SettingsExplorer extends React.PureComponent<IExplorerProps, ISettingsExpl
<div> <div>
<Field <Field
id="valExpl" label={_t("Values at explicit levels")} type="text" id="valExpl"
className="mx_DevTools_textarea" element="textarea" label={_t("Values at explicit levels")}
autoComplete="off" value={this.state.explicitValues} type="text"
className="mx_DevTools_textarea"
element="textarea"
autoComplete="off"
value={this.state.explicitValues}
onChange={this.onExplValuesEdit} onChange={this.onExplValuesEdit}
/> />
</div> </div>
<div> <div>
<Field <Field
id="valExpl" label={_t("Values at explicit levels in this room")} type="text" id="valExpl"
className="mx_DevTools_textarea" element="textarea" label={_t("Values at explicit levels in this room")}
autoComplete="off" value={this.state.explicitRoomValues} type="text"
className="mx_DevTools_textarea"
element="textarea"
autoComplete="off"
value={this.state.explicitRoomValues}
onChange={this.onExplRoomValuesEdit} onChange={this.onExplRoomValuesEdit}
/> />
</div> </div>

View file

@ -144,8 +144,10 @@ export default class EditCommunityPrototypeDialog extends React.PureComponent<IP
</div> </div>
<div className="mx_EditCommunityPrototypeDialog_rowAvatar"> <div className="mx_EditCommunityPrototypeDialog_rowAvatar">
<input <input
type="file" style={{ display: "none" }} type="file"
ref={this.avatarUploadRef} accept="image/*" style={{ display: "none" }}
ref={this.avatarUploadRef}
accept="image/*"
onChange={this.onAvatarChanged} onChange={this.onAvatarChanged}
/> />
<AccessibleButton <AccessibleButton

View file

@ -106,12 +106,12 @@ const Entry: React.FC<IEntryProps> = ({ room, event, matrixClient: cli, onFinish
className = "mx_ForwardList_sending"; className = "mx_ForwardList_sending";
disabled = true; disabled = true;
title = _t("Sending"); title = _t("Sending");
icon = <div className="mx_ForwardList_sendIcon" aria-label={title}></div>; icon = <div className="mx_ForwardList_sendIcon" aria-label={title} />;
} else if (sendState === SendState.Sent) { } else if (sendState === SendState.Sent) {
className = "mx_ForwardList_sent"; className = "mx_ForwardList_sent";
disabled = true; disabled = true;
title = _t("Sent"); title = _t("Sent");
icon = <div className="mx_ForwardList_sendIcon" aria-label={title}></div>; icon = <div className="mx_ForwardList_sendIcon" aria-label={title} />;
} else { } else {
className = "mx_ForwardList_sendFailed"; className = "mx_ForwardList_sendFailed";
disabled = true; disabled = true;
@ -204,10 +204,16 @@ const ForwardDialog: React.FC<IProps> = ({ matrixClient: cli, event, permalinkCr
function overflowTile(overflowCount, totalCount) { function overflowTile(overflowCount, totalCount) {
const text = _t("and %(count)s others...", { count: overflowCount }); const text = _t("and %(count)s others...", { count: overflowCount });
return ( return (
<EntityTile className="mx_EntityTile_ellipsis" avatarJsx={ <EntityTile
<BaseAvatar url={require("../../../../res/img/ellipsis.svg")} name="..." width={36} height={36} /> className="mx_EntityTile_ellipsis"
} name={text} presenceState="online" suppressOnHover={true} avatarJsx={
onClick={() => setTruncateAt(totalCount)} /> <BaseAvatar url={require("../../../../res/img/ellipsis.svg")} name="..." width={36} height={36} />
}
name={text}
presenceState="online"
suppressOnHover={true}
onClick={() => setTruncateAt(totalCount)}
/>
); );
} }

View file

@ -133,18 +133,23 @@ export default class IncomingSasDialog extends React.Component {
? mediaFromMxc(oppProfile.avatar_url).getSquareThumbnailHttp(48) ? mediaFromMxc(oppProfile.avatar_url).getSquareThumbnailHttp(48)
: null; : null;
profile = <div className="mx_IncomingSasDialog_opponentProfile"> profile = <div className="mx_IncomingSasDialog_opponentProfile">
<BaseAvatar name={oppProfile.displayname} <BaseAvatar
name={oppProfile.displayname}
idName={this.props.verifier.userId} idName={this.props.verifier.userId}
url={url} url={url}
width={48} height={48} resizeMethod='crop' width={48}
height={48}
resizeMethod='crop'
/> />
<h2>{ oppProfile.displayname }</h2> <h2>{ oppProfile.displayname }</h2>
</div>; </div>;
} else if (this.state.opponentProfileError) { } else if (this.state.opponentProfileError) {
profile = <div> profile = <div>
<BaseAvatar name={this.props.verifier.userId.slice(1)} <BaseAvatar
name={this.props.verifier.userId.slice(1)}
idName={this.props.verifier.userId} idName={this.props.verifier.userId}
width={48} height={48} width={48}
height={48}
/> />
<h2>{ this.props.verifier.userId }</h2> <h2>{ this.props.verifier.userId }</h2>
</div>; </div>;

View file

@ -1,7 +1,6 @@
/* /*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 New Vector Ltd.
Copyright 2019 Bastian Masanek, Noxware IT <matrix@noxware.de> Copyright 2019 Bastian Masanek, Noxware IT <matrix@noxware.de>
Copyright 2015 - 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -16,31 +15,31 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React from 'react'; import React, { ReactNode, KeyboardEvent } from 'react';
import PropTypes from 'prop-types';
import * as sdk from '../../../index';
import { _t } from '../../../languageHandler';
import classNames from "classnames"; import classNames from "classnames";
export default class InfoDialog extends React.Component { import { _t } from '../../../languageHandler';
static propTypes = { import * as sdk from '../../../index';
className: PropTypes.string, import { IDialogProps } from "./IDialogProps";
title: PropTypes.string,
description: PropTypes.node,
button: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]),
onFinished: PropTypes.func,
hasCloseButton: PropTypes.bool,
onKeyDown: PropTypes.func,
fixedWidth: PropTypes.bool,
};
interface IProps extends IDialogProps {
title?: string;
description?: ReactNode;
className?: string;
button?: boolean | string;
hasCloseButton?: boolean;
fixedWidth?: boolean;
onKeyDown?(event: KeyboardEvent): void;
}
export default class InfoDialog extends React.Component<IProps> {
static defaultProps = { static defaultProps = {
title: '', title: '',
description: '', description: '',
hasCloseButton: false, hasCloseButton: false,
}; };
onFinished = () => { private onFinished = () => {
this.props.onFinished(); this.props.onFinished();
}; };
@ -63,8 +62,7 @@ export default class InfoDialog extends React.Component {
{ this.props.button !== false && <DialogButtons primaryButton={this.props.button || _t('OK')} { this.props.button !== false && <DialogButtons primaryButton={this.props.button || _t('OK')}
onPrimaryButtonClick={this.onFinished} onPrimaryButtonClick={this.onFinished}
hasCancel={false} hasCancel={false}
> /> }
</DialogButtons> }
</BaseDialog> </BaseDialog>
); );
} }

View file

@ -196,7 +196,9 @@ class DMUserTile extends React.PureComponent<IDMUserTileProps> {
? <img ? <img
className='mx_InviteDialog_userTile_avatar mx_InviteDialog_userTile_threepidAvatar' className='mx_InviteDialog_userTile_avatar mx_InviteDialog_userTile_threepidAvatar'
src={require("../../../../res/img/icon-email-pill-avatar.svg")} src={require("../../../../res/img/icon-email-pill-avatar.svg")}
width={avatarSize} height={avatarSize} /> width={avatarSize}
height={avatarSize}
/>
: <BaseAvatar : <BaseAvatar
className='mx_InviteDialog_userTile_avatar' className='mx_InviteDialog_userTile_avatar'
url={this.props.member.getMxcAvatarUrl() url={this.props.member.getMxcAvatarUrl()
@ -214,8 +216,11 @@ class DMUserTile extends React.PureComponent<IDMUserTileProps> {
className='mx_InviteDialog_userTile_remove' className='mx_InviteDialog_userTile_remove'
onClick={this.onRemove} onClick={this.onRemove}
> >
<img src={require("../../../../res/img/icon-pill-remove.svg")} <img
alt={_t('Remove')} width={8} height={8} src={require("../../../../res/img/icon-pill-remove.svg")}
alt={_t('Remove')}
width={8}
height={8}
/> />
</AccessibleButton> </AccessibleButton>
); );
@ -297,7 +302,9 @@ class DMRoomTile extends React.PureComponent<IDMRoomTileProps> {
const avatar = (this.props.member as ThreepidMember).isEmail const avatar = (this.props.member as ThreepidMember).isEmail
? <img ? <img
src={require("../../../../res/img/icon-email-pill-avatar.svg")} src={require("../../../../res/img/icon-email-pill-avatar.svg")}
width={avatarSize} height={avatarSize} /> width={avatarSize}
height={avatarSize}
/>
: <BaseAvatar : <BaseAvatar
url={this.props.member.getMxcAvatarUrl() url={this.props.member.getMxcAvatarUrl()
? mediaFromMxc(this.props.member.getMxcAvatarUrl()).getSquareThumbnailHttp(avatarSize) ? mediaFromMxc(this.props.member.getMxcAvatarUrl()).getSquareThumbnailHttp(avatarSize)
@ -1458,7 +1465,8 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
<p className='mx_InviteDialog_helpText'> <p className='mx_InviteDialog_helpText'>
<img <img
src={require("../../../../res/img/element-icons/info.svg")} src={require("../../../../res/img/element-icons/info.svg")}
width={14} height={14} /> width={14}
height={14} />
{ " " + _t("Invited people will be able to read old messages.") } { " " + _t("Invited people will be able to read old messages.") }
</p>; </p>;
} }
@ -1534,14 +1542,18 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
// Only show the backspace button if the field has content // Only show the backspace button if the field has content
let dialPadField; let dialPadField;
if (this.state.dialPadValue.length !== 0) { if (this.state.dialPadValue.length !== 0) {
dialPadField = <Field className="mx_InviteDialog_dialPadField" id="dialpad_number" dialPadField = <Field
className="mx_InviteDialog_dialPadField"
id="dialpad_number"
value={this.state.dialPadValue} value={this.state.dialPadValue}
autoFocus={true} autoFocus={true}
onChange={this.onDialChange} onChange={this.onDialChange}
postfixComponent={backspaceButton} postfixComponent={backspaceButton}
/>; />;
} else { } else {
dialPadField = <Field className="mx_InviteDialog_dialPadField" id="dialpad_number" dialPadField = <Field
className="mx_InviteDialog_dialPadField"
id="dialpad_number"
value={this.state.dialPadValue} value={this.state.dialPadValue}
autoFocus={true} autoFocus={true}
onChange={this.onDialChange} onChange={this.onDialChange}
@ -1552,14 +1564,19 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
<form onSubmit={this.onDialFormSubmit}> <form onSubmit={this.onDialFormSubmit}>
{ dialPadField } { dialPadField }
</form> </form>
<Dialpad hasDial={false} <Dialpad
onDigitPress={this.onDigitPress} onDeletePress={this.onDeletePress} hasDial={false}
onDigitPress={this.onDigitPress}
onDeletePress={this.onDeletePress}
/> />
</div>; </div>;
tabs.push(new Tab(TabId.DialPad, _td("Dial pad"), 'mx_InviteDialog_dialPadIcon', dialPadSection)); tabs.push(new Tab(TabId.DialPad, _td("Dial pad"), 'mx_InviteDialog_dialPadIcon', dialPadSection));
dialogContent = <React.Fragment> dialogContent = <React.Fragment>
<TabbedView tabs={tabs} initialTabId={this.state.currentTabId} <TabbedView
tabLocation={TabLocation.TOP} onChange={this.onTabChange} tabs={tabs}
initialTabId={this.state.currentTabId}
tabLocation={TabLocation.TOP}
onChange={this.onTabChange}
/> />
{ consultConnectSection } { consultConnectSection }
</React.Fragment>; </React.Fragment>;

View file

@ -0,0 +1,192 @@
/*
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, { useMemo, useState } from "react";
import { Room } from "matrix-js-sdk/src/models/room";
import { _t } from '../../../languageHandler';
import { IDialogProps } from "./IDialogProps";
import BaseDialog from "./BaseDialog";
import SearchBox from "../../structures/SearchBox";
import SpaceStore from "../../../stores/SpaceStore";
import RoomAvatar from "../avatars/RoomAvatar";
import AccessibleButton from "../elements/AccessibleButton";
import AutoHideScrollbar from "../../structures/AutoHideScrollbar";
import StyledCheckbox from "../elements/StyledCheckbox";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
interface IProps extends IDialogProps {
room: Room;
selected?: string[];
}
const Entry = ({ room, checked, onChange }) => {
const localRoom = room instanceof Room;
let description;
if (localRoom) {
description = _t("%(count)s members", { count: room.getJoinedMemberCount() });
const numChildRooms = SpaceStore.instance.getChildRooms(room.roomId).length;
if (numChildRooms > 0) {
description += " · " + _t("%(count)s rooms", { count: numChildRooms });
}
}
return <label className="mx_ManageRestrictedJoinRuleDialog_entry">
<div>
<div>
{ localRoom
? <RoomAvatar room={room} height={20} width={20} />
: <RoomAvatar oobData={room} height={20} width={20} />
}
<span className="mx_ManageRestrictedJoinRuleDialog_entry_name">{ room.name }</span>
</div>
{ description && <div className="mx_ManageRestrictedJoinRuleDialog_entry_description">
{ description }
</div> }
</div>
<StyledCheckbox
onChange={onChange ? (e) => onChange(e.target.checked) : null}
checked={checked}
disabled={!onChange}
/>
</label>;
};
const ManageRestrictedJoinRuleDialog: React.FC<IProps> = ({ room, selected = [], onFinished }) => {
const cli = room.client;
const [newSelected, setNewSelected] = useState(new Set<string>(selected));
const [query, setQuery] = useState("");
const lcQuery = query.toLowerCase().trim();
const [spacesContainingRoom, otherEntries] = useMemo(() => {
const spaces = cli.getVisibleRooms().filter(r => r.getMyMembership() === "join" && r.isSpaceRoom());
return [
spaces.filter(r => SpaceStore.instance.getSpaceFilteredRoomIds(r).has(room.roomId)),
selected.map(roomId => {
const room = cli.getRoom(roomId);
if (!room) {
return { roomId, name: roomId } as Room;
}
if (room.getMyMembership() !== "join" || !room.isSpaceRoom()) {
return room;
}
}).filter(Boolean),
];
}, [cli, selected, room.roomId]);
const [filteredSpacesContainingRooms, filteredOtherEntries] = useMemo(() => [
spacesContainingRoom.filter(r => r.name.toLowerCase().includes(lcQuery)),
otherEntries.filter(r => r.name.toLowerCase().includes(lcQuery)),
], [spacesContainingRoom, otherEntries, lcQuery]);
const onChange = (checked: boolean, room: Room): void => {
if (checked) {
newSelected.add(room.roomId);
} else {
newSelected.delete(room.roomId);
}
setNewSelected(new Set(newSelected));
};
let inviteOnlyWarning;
if (newSelected.size < 1) {
inviteOnlyWarning = <div className="mx_ManageRestrictedJoinRuleDialog_section_info">
{ _t("You're removing all spaces. Access will default to invite only") }
</div>;
}
return <BaseDialog
title={_t("Select spaces")}
className="mx_ManageRestrictedJoinRuleDialog"
onFinished={onFinished}
fixedWidth={false}
>
<p>
{ _t("Decide which spaces can access this room. " +
"If a space is selected, its members can find and join <RoomName/>.", {}, {
RoomName: () => <b>{ room.name }</b>,
}) }
</p>
<MatrixClientContext.Provider value={cli}>
<SearchBox
className="mx_textinput_icon mx_textinput_search"
placeholder={_t("Search spaces")}
onSearch={setQuery}
autoComplete={true}
autoFocus={true}
/>
<AutoHideScrollbar className="mx_ManageRestrictedJoinRuleDialog_content">
{ filteredSpacesContainingRooms.length > 0 ? (
<div className="mx_ManageRestrictedJoinRuleDialog_section">
<h3>{ _t("Spaces you know that contain this room") }</h3>
{ filteredSpacesContainingRooms.map(space => {
return <Entry
key={space.roomId}
room={space}
checked={newSelected.has(space.roomId)}
onChange={(checked: boolean) => {
onChange(checked, space);
}}
/>;
}) }
</div>
) : undefined }
{ filteredOtherEntries.length > 0 ? (
<div className="mx_ManageRestrictedJoinRuleDialog_section">
<h3>{ _t("Other spaces or rooms you might not know") }</h3>
<div className="mx_ManageRestrictedJoinRuleDialog_section_info">
<div>{ _t("These are likely ones other room admins are a part of.") }</div>
</div>
{ filteredOtherEntries.map(space => {
return <Entry
key={space.roomId}
room={space}
checked={newSelected.has(space.roomId)}
onChange={(checked: boolean) => {
onChange(checked, space);
}}
/>;
}) }
</div>
) : null }
{ filteredSpacesContainingRooms.length + filteredOtherEntries.length < 1
? <span className="mx_ManageRestrictedJoinRuleDialog_noResults">
{ _t("No results") }
</span>
: undefined
}
</AutoHideScrollbar>
<div className="mx_ManageRestrictedJoinRuleDialog_footer">
{ inviteOnlyWarning }
<div className="mx_ManageRestrictedJoinRuleDialog_footer_buttons">
<AccessibleButton kind="primary_outline" onClick={() => onFinished()}>
{ _t("Cancel") }
</AccessibleButton>
<AccessibleButton kind="primary" onClick={() => onFinished(Array.from(newSelected))}>
{ _t("Confirm") }
</AccessibleButton>
</div>
</div>
</MatrixClientContext.Provider>
</BaseDialog>;
};
export default ManageRestrictedJoinRuleDialog;

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2018 New Vector Ltd Copyright 2018 - 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -15,19 +15,29 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import { Room } from "matrix-js-sdk/src/models/room";
import * as sdk from '../../../index';
import { MatrixClientPeg } from '../../../MatrixClientPeg';
import Modal from '../../../Modal'; import Modal from '../../../Modal';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import { upgradeRoom } from "../../../utils/RoomUpgrade";
import { IDialogProps } from "./IDialogProps";
import BaseDialog from "./BaseDialog";
import ErrorDialog from './ErrorDialog';
import DialogButtons from '../elements/DialogButtons';
import Spinner from "../elements/Spinner";
interface IProps extends IDialogProps {
room: Room;
}
interface IState {
busy: boolean;
}
@replaceableComponent("views.dialogs.RoomUpgradeDialog") @replaceableComponent("views.dialogs.RoomUpgradeDialog")
export default class RoomUpgradeDialog extends React.Component { export default class RoomUpgradeDialog extends React.Component<IProps, IState> {
static propTypes = { private targetVersion: string;
room: PropTypes.object.isRequired,
onFinished: PropTypes.func.isRequired,
};
state = { state = {
busy: true, busy: true,
@ -35,20 +45,19 @@ export default class RoomUpgradeDialog extends React.Component {
async componentDidMount() { async componentDidMount() {
const recommended = await this.props.room.getRecommendedVersion(); const recommended = await this.props.room.getRecommendedVersion();
this._targetVersion = recommended.version; this.targetVersion = recommended.version;
this.setState({ busy: false }); this.setState({ busy: false });
} }
_onCancelClick = () => { private onCancelClick = (): void => {
this.props.onFinished(false); this.props.onFinished(false);
}; };
_onUpgradeClick = () => { private onUpgradeClick = (): void => {
this.setState({ busy: true }); this.setState({ busy: true });
MatrixClientPeg.get().upgradeRoom(this.props.room.roomId, this._targetVersion).then(() => { upgradeRoom(this.props.room, this.targetVersion, false, false).then(() => {
this.props.onFinished(true); this.props.onFinished(true);
}).catch((err) => { }).catch((err) => {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Failed to upgrade room', '', ErrorDialog, { Modal.createTrackedDialog('Failed to upgrade room', '', ErrorDialog, {
title: _t("Failed to upgrade room"), title: _t("Failed to upgrade room"),
description: ((err && err.message) ? err.message : _t("The room upgrade could not be completed")), description: ((err && err.message) ? err.message : _t("The room upgrade could not be completed")),
@ -59,29 +68,22 @@ export default class RoomUpgradeDialog extends React.Component {
}; };
render() { render() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
const Spinner = sdk.getComponent('views.elements.Spinner');
let buttons; let buttons;
if (this.state.busy) { if (this.state.busy) {
buttons = <Spinner />; buttons = <Spinner />;
} else { } else {
buttons = <DialogButtons buttons = <DialogButtons
primaryButton={_t( primaryButton={_t('Upgrade this room to version %(version)s', { version: this.targetVersion })}
'Upgrade this room to version %(version)s',
{ version: this._targetVersion },
)}
primaryButtonClass="danger" primaryButtonClass="danger"
hasCancel={true} hasCancel={true}
onPrimaryButtonClick={this._onUpgradeClick} onPrimaryButtonClick={this.onUpgradeClick}
focus={this.props.focus} onCancel={this.onCancelClick}
onCancel={this._onCancelClick}
/>; />;
} }
return ( return (
<BaseDialog className="mx_RoomUpgradeDialog" <BaseDialog
className="mx_RoomUpgradeDialog"
onFinished={this.props.onFinished} onFinished={this.props.onFinished}
title={_t("Upgrade Room Version")} title={_t("Upgrade Room Version")}
contentId='mx_Dialog_content' contentId='mx_Dialog_content'
@ -97,8 +99,10 @@ export default class RoomUpgradeDialog extends React.Component {
<ol> <ol>
<li>{ _t("Create a new room with the same name, description and avatar") }</li> <li>{ _t("Create a new room with the same name, description and avatar") }</li>
<li>{ _t("Update any local room aliases to point to the new room") }</li> <li>{ _t("Update any local room aliases to point to the new room") }</li>
<li>{ _t("Stop users from speaking in the old version of the room, and post a message advising users to move to the new room") }</li> <li>{ _t("Stop users from speaking in the old version of the room, " +
<li>{ _t("Put a link back to the old room at the start of the new room so people can see old messages") }</li> "and post a message advising users to move to the new room") }</li>
<li>{ _t("Put a link back to the old room at the start of the new room " +
"so people can see old messages") }</li>
</ol> </ol>
{ buttons } { buttons }
</BaseDialog> </BaseDialog>

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2019, 2020 The Matrix.org Foundation C.I.C. Copyright 2019 - 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -14,73 +14,82 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React from 'react'; import React, { ReactNode } from 'react';
import PropTypes from 'prop-types'; import { EventType } from 'matrix-js-sdk/src/@types/event';
import { JoinRule } from 'matrix-js-sdk/src/@types/partials';
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import SdkConfig from "../../../SdkConfig"; import SdkConfig from "../../../SdkConfig";
import * as sdk from "../../../index";
import LabelledToggleSwitch from "../elements/LabelledToggleSwitch"; import LabelledToggleSwitch from "../elements/LabelledToggleSwitch";
import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { MatrixClientPeg } from "../../../MatrixClientPeg";
import Modal from "../../../Modal"; import Modal from "../../../Modal";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import { IDialogProps } from "./IDialogProps";
import BugReportDialog from './BugReportDialog';
import BaseDialog from "./BaseDialog";
import DialogButtons from "../elements/DialogButtons";
interface IProps extends IDialogProps {
roomId: string;
targetVersion: string;
description?: ReactNode;
}
interface IState {
inviteUsersToNewRoom: boolean;
}
@replaceableComponent("views.dialogs.RoomUpgradeWarningDialog") @replaceableComponent("views.dialogs.RoomUpgradeWarningDialog")
export default class RoomUpgradeWarningDialog extends React.Component { export default class RoomUpgradeWarningDialog extends React.Component<IProps, IState> {
static propTypes = { private readonly isPrivate: boolean;
onFinished: PropTypes.func.isRequired, private readonly currentVersion: string;
roomId: PropTypes.string.isRequired,
targetVersion: PropTypes.string.isRequired,
};
constructor(props) { constructor(props) {
super(props); super(props);
const room = MatrixClientPeg.get().getRoom(this.props.roomId); const room = MatrixClientPeg.get().getRoom(this.props.roomId);
const joinRules = room ? room.currentState.getStateEvents("m.room.join_rules", "") : null; const joinRules = room?.currentState.getStateEvents(EventType.RoomJoinRules, "");
const isPrivate = joinRules ? joinRules.getContent()['join_rule'] !== 'public' : true; this.isPrivate = joinRules?.getContent()['join_rule'] !== JoinRule.Public ?? true;
this.currentVersion = room?.getVersion() || "1";
this.state = { this.state = {
currentVersion: room ? room.getVersion() : "1",
isPrivate,
inviteUsersToNewRoom: true, inviteUsersToNewRoom: true,
}; };
} }
_onContinue = () => { private onContinue = () => {
this.props.onFinished({ continue: true, invite: this.state.isPrivate && this.state.inviteUsersToNewRoom }); this.props.onFinished({ continue: true, invite: this.isPrivate && this.state.inviteUsersToNewRoom });
}; };
_onCancel = () => { private onCancel = () => {
this.props.onFinished({ continue: false, invite: false }); this.props.onFinished({ continue: false, invite: false });
}; };
_onInviteUsersToggle = (newVal) => { private onInviteUsersToggle = (inviteUsersToNewRoom: boolean) => {
this.setState({ inviteUsersToNewRoom: newVal }); this.setState({ inviteUsersToNewRoom });
}; };
_openBugReportDialog = (e) => { private openBugReportDialog = (e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
const BugReportDialog = sdk.getComponent("dialogs.BugReportDialog");
Modal.createTrackedDialog('Bug Report Dialog', '', BugReportDialog, {}); Modal.createTrackedDialog('Bug Report Dialog', '', BugReportDialog, {});
}; };
render() { render() {
const brand = SdkConfig.get().brand; const brand = SdkConfig.get().brand;
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
let inviteToggle = null; let inviteToggle = null;
if (this.state.isPrivate) { if (this.isPrivate) {
inviteToggle = ( inviteToggle = (
<LabelledToggleSwitch <LabelledToggleSwitch
value={this.state.inviteUsersToNewRoom} value={this.state.inviteUsersToNewRoom}
onChange={this._onInviteUsersToggle} onChange={this.onInviteUsersToggle}
label={_t("Automatically invite users")} /> label={_t("Automatically invite members from this room to the new one")} />
); );
} }
const title = this.state.isPrivate ? _t("Upgrade private room") : _t("Upgrade public room"); const title = this.isPrivate ? _t("Upgrade private room") : _t("Upgrade public room");
let bugReports = ( let bugReports = (
<p> <p>
@ -101,7 +110,7 @@ export default class RoomUpgradeWarningDialog extends React.Component {
}, },
{ {
"a": (sub) => { "a": (sub) => {
return <a href='#' onClick={this._openBugReportDialog}>{ sub }</a>; return <a href='#' onClick={this.openBugReportDialog}>{ sub }</a>;
}, },
}, },
) } ) }
@ -119,18 +128,26 @@ export default class RoomUpgradeWarningDialog extends React.Component {
> >
<div> <div>
<p> <p>
{ _t( { this.props.description || _t(
"Upgrading a room is an advanced action and is usually recommended when a room " + "Upgrading a room is an advanced action and is usually recommended when a room " +
"is unstable due to bugs, missing features or security vulnerabilities.", "is unstable due to bugs, missing features or security vulnerabilities.",
) } ) }
</p> </p>
<p>
{ _t(
"<b>Please note upgrading will make a new version of the room</b>. " +
"All current messages will stay in this archived room.", {}, {
b: sub => <b>{ sub }</b>,
},
) }
</p>
{ bugReports } { bugReports }
<p> <p>
{ _t( { _t(
"You'll upgrade this room from <oldVersion /> to <newVersion />.", "You'll upgrade this room from <oldVersion /> to <newVersion />.",
{}, {},
{ {
oldVersion: () => <code>{ this.state.currentVersion }</code>, oldVersion: () => <code>{ this.currentVersion }</code>,
newVersion: () => <code>{ this.props.targetVersion }</code>, newVersion: () => <code>{ this.props.targetVersion }</code>,
}, },
) } ) }
@ -139,9 +156,9 @@ export default class RoomUpgradeWarningDialog extends React.Component {
</div> </div>
<DialogButtons <DialogButtons
primaryButton={_t("Upgrade")} primaryButton={_t("Upgrade")}
onPrimaryButtonClick={this._onContinue} onPrimaryButtonClick={this.onContinue}
cancelButton={_t("Cancel")} cancelButton={_t("Cancel")}
onCancel={this._onCancel} onCancel={this.onCancel}
/> />
</BaseDialog> </BaseDialog>
); );

View file

@ -85,7 +85,9 @@ export default class SessionRestoreErrorDialog extends React.Component {
} }
return ( return (
<BaseDialog className="mx_ErrorDialog" onFinished={this.props.onFinished} <BaseDialog
className="mx_ErrorDialog"
onFinished={this.props.onFinished}
title={_t('Unable to restore session')} title={_t('Unable to restore session')}
contentId='mx_Dialog_content' contentId='mx_Dialog_content'
hasCancel={false} hasCancel={false}

View file

@ -54,7 +54,9 @@ export default class StorageEvictedDialog extends React.Component {
} }
return ( return (
<BaseDialog className="mx_ErrorDialog" onFinished={this.props.onFinished} <BaseDialog
className="mx_ErrorDialog"
onFinished={this.props.onFinished}
title={_t('Missing session data')} title={_t('Missing session data')}
contentId='mx_Dialog_content' contentId='mx_Dialog_content'
hasCancel={false} hasCancel={false}

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