Merge branch 'develop' of github.com:matrix-org/matrix-react-sdk into t3chguy/fix/17686
Conflicts: src/stores/SpaceStore.tsx
This commit is contained in:
commit
18bb4bce35
52 changed files with 2572 additions and 2251 deletions
|
@ -160,10 +160,10 @@
|
||||||
@import "./views/groups/_GroupPublicityToggle.scss";
|
@import "./views/groups/_GroupPublicityToggle.scss";
|
||||||
@import "./views/groups/_GroupRoomList.scss";
|
@import "./views/groups/_GroupRoomList.scss";
|
||||||
@import "./views/groups/_GroupUserSettings.scss";
|
@import "./views/groups/_GroupUserSettings.scss";
|
||||||
|
@import "./views/messages/_CallEvent.scss";
|
||||||
@import "./views/messages/_CreateEvent.scss";
|
@import "./views/messages/_CreateEvent.scss";
|
||||||
@import "./views/messages/_DateSeparator.scss";
|
@import "./views/messages/_DateSeparator.scss";
|
||||||
@import "./views/messages/_EventTileBubble.scss";
|
@import "./views/messages/_EventTileBubble.scss";
|
||||||
@import "./views/messages/_CallEvent.scss";
|
|
||||||
@import "./views/messages/_MEmoteBody.scss";
|
@import "./views/messages/_MEmoteBody.scss";
|
||||||
@import "./views/messages/_MFileBody.scss";
|
@import "./views/messages/_MFileBody.scss";
|
||||||
@import "./views/messages/_MImageBody.scss";
|
@import "./views/messages/_MImageBody.scss";
|
||||||
|
@ -173,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";
|
||||||
|
@ -202,8 +201,8 @@
|
||||||
@import "./views/rooms/_E2EIcon.scss";
|
@import "./views/rooms/_E2EIcon.scss";
|
||||||
@import "./views/rooms/_EditMessageComposer.scss";
|
@import "./views/rooms/_EditMessageComposer.scss";
|
||||||
@import "./views/rooms/_EntityTile.scss";
|
@import "./views/rooms/_EntityTile.scss";
|
||||||
@import "./views/rooms/_EventTile.scss";
|
|
||||||
@import "./views/rooms/_EventBubbleTile.scss";
|
@import "./views/rooms/_EventBubbleTile.scss";
|
||||||
|
@import "./views/rooms/_EventTile.scss";
|
||||||
@import "./views/rooms/_GroupLayout.scss";
|
@import "./views/rooms/_GroupLayout.scss";
|
||||||
@import "./views/rooms/_IRCLayout.scss";
|
@import "./views/rooms/_IRCLayout.scss";
|
||||||
@import "./views/rooms/_JumpToBottomButton.scss";
|
@import "./views/rooms/_JumpToBottomButton.scss";
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -20,7 +20,8 @@ limitations under the License.
|
||||||
margin-right: 0;
|
margin-right: 0;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
padding-left: 10px;
|
padding-left: 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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
@ -80,7 +80,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,7 +91,7 @@ limitations under the License.
|
||||||
float: right;
|
float: right;
|
||||||
> a {
|
> a {
|
||||||
left: auto;
|
left: auto;
|
||||||
right: -48px;
|
right: -68px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.mx_SenderProfile {
|
.mx_SenderProfile {
|
||||||
|
@ -126,7 +126,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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -254,7 +256,7 @@ limitations under the License.
|
||||||
}
|
}
|
||||||
|
|
||||||
.mx_MessageActionBar {
|
.mx_MessageActionBar {
|
||||||
transform: translate3d(50%, 0, 0);
|
transform: translate3d(90%, 0, 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -212,43 +212,11 @@ $hover-select-border: 4px;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* all the overflow-y: hidden; are to trap Zalgos -
|
|
||||||
but they introduce an implicit overflow-x: auto.
|
|
||||||
so make that explicitly hidden too to avoid random
|
|
||||||
horizontal scrollbars occasionally appearing, like in
|
|
||||||
https://github.com/vector-im/vector-web/issues/1154
|
|
||||||
*/
|
|
||||||
.mx_EventTile_content {
|
|
||||||
display: block;
|
|
||||||
overflow-y: hidden;
|
|
||||||
overflow-x: hidden;
|
|
||||||
margin-right: 34px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* De-zalgoing */
|
/* De-zalgoing */
|
||||||
.mx_EventTile_body {
|
.mx_EventTile_body {
|
||||||
overflow-y: hidden;
|
overflow-y: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Spoiler stuff */
|
|
||||||
.mx_EventTile_spoiler {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_EventTile_spoiler_reason {
|
|
||||||
color: $event-timestamp-color;
|
|
||||||
font-size: $font-11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_EventTile_spoiler_content {
|
|
||||||
filter: blur(5px) saturate(0.1) sepia(1);
|
|
||||||
transition-duration: 0.5s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_EventTile_spoiler.visible > .mx_EventTile_spoiler_content {
|
|
||||||
filter: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover.mx_EventTile_verified .mx_EventTile_line,
|
&:hover.mx_EventTile_verified .mx_EventTile_line,
|
||||||
&:hover.mx_EventTile_unverified .mx_EventTile_line,
|
&:hover.mx_EventTile_unverified .mx_EventTile_line,
|
||||||
&:hover.mx_EventTile_unknown .mx_EventTile_line {
|
&:hover.mx_EventTile_unknown .mx_EventTile_line {
|
||||||
|
@ -311,6 +279,36 @@ $hover-select-border: 4px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* all the overflow-y: hidden; are to trap Zalgos -
|
||||||
|
but they introduce an implicit overflow-x: auto.
|
||||||
|
so make that explicitly hidden too to avoid random
|
||||||
|
horizontal scrollbars occasionally appearing, like in
|
||||||
|
https://github.com/vector-im/vector-web/issues/1154 */
|
||||||
|
.mx_EventTile_content {
|
||||||
|
overflow-y: hidden;
|
||||||
|
overflow-x: hidden;
|
||||||
|
margin-right: 34px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Spoiler stuff */
|
||||||
|
.mx_EventTile_spoiler {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_EventTile_spoiler_reason {
|
||||||
|
color: $event-timestamp-color;
|
||||||
|
font-size: $font-11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_EventTile_spoiler_content {
|
||||||
|
filter: blur(5px) saturate(0.1) sepia(1);
|
||||||
|
transition-duration: 0.5s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_EventTile_spoiler.visible > .mx_EventTile_spoiler_content {
|
||||||
|
filter: none;
|
||||||
|
}
|
||||||
|
|
||||||
.mx_RoomView_timeline_rr_enabled {
|
.mx_RoomView_timeline_rr_enabled {
|
||||||
|
|
||||||
.mx_EventTile:not([data-layout=bubble]) {
|
.mx_EventTile:not([data-layout=bubble]) {
|
||||||
|
@ -473,6 +471,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:
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -45,6 +44,11 @@ limitations under the License.
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
padding: 0 16px 9px 16px;
|
padding: 0 16px 9px 16px;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
display: flow-root;
|
||||||
|
|
||||||
|
.mx_EventTile[data-layout=bubble] {
|
||||||
|
margin-top: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
.mx_EventTile_msgOption {
|
.mx_EventTile_msgOption {
|
||||||
display: none;
|
display: none;
|
||||||
|
@ -154,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;
|
||||||
|
@ -210,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 {
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
|
@ -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)\/(.+?)\/(.+?)(?:[?/]|$)/;
|
||||||
|
|
||||||
|
@ -79,8 +79,8 @@ function mightContainEmoji(str: string): boolean {
|
||||||
* @return {String} The shortcode (such as :thumbup:)
|
* @return {String} The shortcode (such as :thumbup:)
|
||||||
*/
|
*/
|
||||||
export function unicodeToShortcode(char: string): string {
|
export function unicodeToShortcode(char: string): string {
|
||||||
const shortcodes = getEmojiFromUnicode(char).shortcodes;
|
const shortcodes = getEmojiFromUnicode(char)?.shortcodes;
|
||||||
return shortcodes.length > 0 ? `:${shortcodes[0]}:` : '';
|
return shortcodes?.length ? `:${shortcodes[0]}:` : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function processHtmlForSending(html: string): string {
|
export function processHtmlForSending(html: string): string {
|
||||||
|
|
|
@ -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)) {
|
||||||
|
|
|
@ -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>,
|
||||||
);
|
);
|
||||||
|
|
|
@ -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),
|
|
||||||
}),
|
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -36,6 +36,7 @@ interface IProps {
|
||||||
|
|
||||||
interface IState {
|
interface IState {
|
||||||
playbackPhase: PlaybackState;
|
playbackPhase: PlaybackState;
|
||||||
|
error?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@replaceableComponent("views.audio_messages.AudioPlayer")
|
@replaceableComponent("views.audio_messages.AudioPlayer")
|
||||||
|
@ -55,8 +56,10 @@ export default class AudioPlayer extends React.PureComponent<IProps, IState> {
|
||||||
|
|
||||||
// Don't wait for the promise to complete - it will emit a progress update when it
|
// 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.
|
// is done, and it's not meant to take long anyhow.
|
||||||
// noinspection JSIgnoredPromiseFromCall
|
this.props.playback.prepare().catch(e => {
|
||||||
this.props.playback.prepare();
|
console.error("Error processing audio file:", e);
|
||||||
|
this.setState({ error: true });
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private onPlaybackUpdate = (ev: PlaybackState) => {
|
private onPlaybackUpdate = (ev: PlaybackState) => {
|
||||||
|
@ -91,34 +94,37 @@ export default class AudioPlayer extends React.PureComponent<IProps, IState> {
|
||||||
public render(): ReactNode {
|
public render(): 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'>
|
||||||
{ /* easiest way to introduce a gap between the components */ }
|
<DurationClock playback={this.props.playback} />
|
||||||
{ this.renderFileSize() }
|
{ /* 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'>
|
{ this.state.error && <div className="text-warning">{ _t("Error downloading audio") }</div> }
|
||||||
<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>;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -22,6 +22,7 @@ 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 { _t } from "../../../languageHandler";
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
// Playback instance to render. Cannot change during component lifecycle: create
|
// Playback instance to render. Cannot change during component lifecycle: create
|
||||||
|
@ -33,6 +34,7 @@ interface IProps {
|
||||||
|
|
||||||
interface IState {
|
interface IState {
|
||||||
playbackPhase: PlaybackState;
|
playbackPhase: PlaybackState;
|
||||||
|
error?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@replaceableComponent("views.audio_messages.RecordingPlayback")
|
@replaceableComponent("views.audio_messages.RecordingPlayback")
|
||||||
|
@ -49,8 +51,10 @@ export default class RecordingPlayback extends React.PureComponent<IProps, IStat
|
||||||
|
|
||||||
// Don't wait for the promise to complete - it will emit a progress update when it
|
// 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.
|
// is done, and it's not meant to take long anyhow.
|
||||||
// noinspection JSIgnoredPromiseFromCall
|
this.props.playback.prepare().catch(e => {
|
||||||
this.props.playback.prepare();
|
console.error("Error processing audio file:", e);
|
||||||
|
this.setState({ error: true });
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private get isWaveformable(): boolean {
|
private get isWaveformable(): boolean {
|
||||||
|
@ -65,10 +69,13 @@ export default class RecordingPlayback extends React.PureComponent<IProps, IStat
|
||||||
|
|
||||||
public render(): ReactNode {
|
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>
|
||||||
|
{ this.state.error && <div className="text-warning">{ _t("Error downloading audio") }</div> }
|
||||||
|
</>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,10 +657,10 @@ 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()}
|
||||||
|
@ -656,7 +669,7 @@ export default class AddressPickerDialog extends React.Component {
|
||||||
</textarea>,
|
</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(
|
|
@ -15,56 +15,62 @@ limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import AccessibleButton from './AccessibleButton';
|
import AccessibleButton from './AccessibleButton';
|
||||||
import dis from '../../../dispatcher/dispatcher';
|
import dis from '../../../dispatcher/dispatcher';
|
||||||
import * as sdk from '../../../index';
|
|
||||||
import Analytics from '../../../Analytics';
|
import Analytics from '../../../Analytics';
|
||||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||||
|
import Tooltip from './Tooltip';
|
||||||
|
|
||||||
|
interface IProps {
|
||||||
|
size?: string;
|
||||||
|
tooltip?: boolean;
|
||||||
|
action: string;
|
||||||
|
mouseOverAction?: string;
|
||||||
|
label: string;
|
||||||
|
iconPath?: string;
|
||||||
|
className?: string;
|
||||||
|
children?: JSX.Element;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IState {
|
||||||
|
showTooltip: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
@replaceableComponent("views.elements.ActionButton")
|
@replaceableComponent("views.elements.ActionButton")
|
||||||
export default class ActionButton extends React.Component {
|
export default class ActionButton extends React.Component<IProps, IState> {
|
||||||
static propTypes = {
|
static defaultProps: Partial<IProps> = {
|
||||||
size: PropTypes.string,
|
|
||||||
tooltip: PropTypes.bool,
|
|
||||||
action: PropTypes.string.isRequired,
|
|
||||||
mouseOverAction: PropTypes.string,
|
|
||||||
label: PropTypes.string.isRequired,
|
|
||||||
iconPath: PropTypes.string,
|
|
||||||
className: PropTypes.string,
|
|
||||||
children: PropTypes.node,
|
|
||||||
};
|
|
||||||
|
|
||||||
static defaultProps = {
|
|
||||||
size: "25",
|
size: "25",
|
||||||
tooltip: false,
|
tooltip: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
state = {
|
constructor(props: IProps) {
|
||||||
showTooltip: false,
|
super(props);
|
||||||
};
|
|
||||||
|
|
||||||
_onClick = (ev) => {
|
this.state = {
|
||||||
|
showTooltip: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private onClick = (ev: React.MouseEvent): void => {
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
Analytics.trackEvent('Action Button', 'click', this.props.action);
|
Analytics.trackEvent('Action Button', 'click', this.props.action);
|
||||||
dis.dispatch({ action: this.props.action });
|
dis.dispatch({ action: this.props.action });
|
||||||
};
|
};
|
||||||
|
|
||||||
_onMouseEnter = () => {
|
private onMouseEnter = (): void => {
|
||||||
if (this.props.tooltip) this.setState({ showTooltip: true });
|
if (this.props.tooltip) this.setState({ showTooltip: true });
|
||||||
if (this.props.mouseOverAction) {
|
if (this.props.mouseOverAction) {
|
||||||
dis.dispatch({ action: this.props.mouseOverAction });
|
dis.dispatch({ action: this.props.mouseOverAction });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
_onMouseLeave = () => {
|
private onMouseLeave = (): void => {
|
||||||
this.setState({ showTooltip: false });
|
this.setState({ showTooltip: false });
|
||||||
};
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
let tooltip;
|
let tooltip;
|
||||||
if (this.state.showTooltip) {
|
if (this.state.showTooltip) {
|
||||||
const Tooltip = sdk.getComponent("elements.Tooltip");
|
|
||||||
tooltip = <Tooltip className="mx_RoleButton_tooltip" label={this.props.label} />;
|
tooltip = <Tooltip className="mx_RoleButton_tooltip" label={this.props.label} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -80,9 +86,9 @@ export default class ActionButton extends React.Component {
|
||||||
return (
|
return (
|
||||||
<AccessibleButton
|
<AccessibleButton
|
||||||
className={classNames.join(" ")}
|
className={classNames.join(" ")}
|
||||||
onClick={this._onClick}
|
onClick={this.onClick}
|
||||||
onMouseEnter={this._onMouseEnter}
|
onMouseEnter={this.onMouseEnter}
|
||||||
onMouseLeave={this._onMouseLeave}
|
onMouseLeave={this.onMouseLeave}
|
||||||
aria-label={this.props.label}
|
aria-label={this.props.label}
|
||||||
>
|
>
|
||||||
{ icon }
|
{ icon }
|
|
@ -15,30 +15,37 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React, { createRef } from 'react';
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import * as sdk from '../../../index';
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { UserAddressType } from '../../../UserAddress';
|
|
||||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||||
|
import { IUserAddress } from '../../../UserAddress';
|
||||||
|
import AddressTile from './AddressTile';
|
||||||
|
|
||||||
|
interface IProps {
|
||||||
|
onSelected: (index: number) => void;
|
||||||
|
|
||||||
|
// List of the addresses to display
|
||||||
|
addressList: IUserAddress[];
|
||||||
|
// Whether to show the address on the address tiles
|
||||||
|
showAddress?: boolean;
|
||||||
|
truncateAt: number;
|
||||||
|
selected?: number;
|
||||||
|
|
||||||
|
// Element to put as a header on top of the list
|
||||||
|
header?: JSX.Element;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IState {
|
||||||
|
selected: number;
|
||||||
|
hover: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
@replaceableComponent("views.elements.AddressSelector")
|
@replaceableComponent("views.elements.AddressSelector")
|
||||||
export default class AddressSelector extends React.Component {
|
export default class AddressSelector extends React.Component<IProps, IState> {
|
||||||
static propTypes = {
|
private scrollElement = createRef<HTMLDivElement>();
|
||||||
onSelected: PropTypes.func.isRequired,
|
private addressListElement = createRef<HTMLDivElement>();
|
||||||
|
|
||||||
// List of the addresses to display
|
constructor(props: IProps) {
|
||||||
addressList: PropTypes.arrayOf(UserAddressType).isRequired,
|
|
||||||
// Whether to show the address on the address tiles
|
|
||||||
showAddress: PropTypes.bool,
|
|
||||||
truncateAt: PropTypes.number.isRequired,
|
|
||||||
selected: PropTypes.number,
|
|
||||||
|
|
||||||
// Element to put as a header on top of the list
|
|
||||||
header: PropTypes.node,
|
|
||||||
};
|
|
||||||
|
|
||||||
constructor(props) {
|
|
||||||
super(props);
|
super(props);
|
||||||
|
|
||||||
this.state = {
|
this.state = {
|
||||||
|
@ -48,10 +55,10 @@ export default class AddressSelector extends React.Component {
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
|
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
|
||||||
UNSAFE_componentWillReceiveProps(props) { // eslint-disable-line camelcase
|
UNSAFE_componentWillReceiveProps(props: IProps) { // eslint-disable-line
|
||||||
// Make sure the selected item isn't outside the list bounds
|
// Make sure the selected item isn't outside the list bounds
|
||||||
const selected = this.state.selected;
|
const selected = this.state.selected;
|
||||||
const maxSelected = this._maxSelected(props.addressList);
|
const maxSelected = this.maxSelected(props.addressList);
|
||||||
if (selected > maxSelected) {
|
if (selected > maxSelected) {
|
||||||
this.setState({ selected: maxSelected });
|
this.setState({ selected: maxSelected });
|
||||||
}
|
}
|
||||||
|
@ -60,13 +67,13 @@ export default class AddressSelector extends React.Component {
|
||||||
componentDidUpdate() {
|
componentDidUpdate() {
|
||||||
// As the user scrolls with the arrow keys keep the selected item
|
// As the user scrolls with the arrow keys keep the selected item
|
||||||
// at the top of the window.
|
// at the top of the window.
|
||||||
if (this.scrollElement && this.props.addressList.length > 0 && !this.state.hover) {
|
if (this.scrollElement.current && this.props.addressList.length > 0 && !this.state.hover) {
|
||||||
const elementHeight = this.addressListElement.getBoundingClientRect().height;
|
const elementHeight = this.addressListElement.current.getBoundingClientRect().height;
|
||||||
this.scrollElement.scrollTop = (this.state.selected * elementHeight) - elementHeight;
|
this.scrollElement.current.scrollTop = (this.state.selected * elementHeight) - elementHeight;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
moveSelectionTop = () => {
|
public moveSelectionTop = (): void => {
|
||||||
if (this.state.selected > 0) {
|
if (this.state.selected > 0) {
|
||||||
this.setState({
|
this.setState({
|
||||||
selected: 0,
|
selected: 0,
|
||||||
|
@ -75,7 +82,7 @@ export default class AddressSelector extends React.Component {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
moveSelectionUp = () => {
|
public moveSelectionUp = (): void => {
|
||||||
if (this.state.selected > 0) {
|
if (this.state.selected > 0) {
|
||||||
this.setState({
|
this.setState({
|
||||||
selected: this.state.selected - 1,
|
selected: this.state.selected - 1,
|
||||||
|
@ -84,8 +91,8 @@ export default class AddressSelector extends React.Component {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
moveSelectionDown = () => {
|
public moveSelectionDown = (): void => {
|
||||||
if (this.state.selected < this._maxSelected(this.props.addressList)) {
|
if (this.state.selected < this.maxSelected(this.props.addressList)) {
|
||||||
this.setState({
|
this.setState({
|
||||||
selected: this.state.selected + 1,
|
selected: this.state.selected + 1,
|
||||||
hover: false,
|
hover: false,
|
||||||
|
@ -93,26 +100,26 @@ export default class AddressSelector extends React.Component {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
chooseSelection = () => {
|
public chooseSelection = (): void => {
|
||||||
this.selectAddress(this.state.selected);
|
this.selectAddress(this.state.selected);
|
||||||
};
|
};
|
||||||
|
|
||||||
onClick = index => {
|
private onClick = (index: number): void => {
|
||||||
this.selectAddress(index);
|
this.selectAddress(index);
|
||||||
};
|
};
|
||||||
|
|
||||||
onMouseEnter = index => {
|
private onMouseEnter = (index: number): void => {
|
||||||
this.setState({
|
this.setState({
|
||||||
selected: index,
|
selected: index,
|
||||||
hover: true,
|
hover: true,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
onMouseLeave = () => {
|
private onMouseLeave = (): void => {
|
||||||
this.setState({ hover: false });
|
this.setState({ hover: false });
|
||||||
};
|
};
|
||||||
|
|
||||||
selectAddress = index => {
|
private selectAddress = (index: number): void => {
|
||||||
// Only try to select an address if one exists
|
// Only try to select an address if one exists
|
||||||
if (this.props.addressList.length !== 0) {
|
if (this.props.addressList.length !== 0) {
|
||||||
this.props.onSelected(index);
|
this.props.onSelected(index);
|
||||||
|
@ -120,9 +127,8 @@ export default class AddressSelector extends React.Component {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
createAddressListTiles() {
|
private createAddressListTiles(): JSX.Element[] {
|
||||||
const AddressTile = sdk.getComponent("elements.AddressTile");
|
const maxSelected = this.maxSelected(this.props.addressList);
|
||||||
const maxSelected = this._maxSelected(this.props.addressList);
|
|
||||||
const addressList = [];
|
const addressList = [];
|
||||||
|
|
||||||
// Only create the address elements if there are address
|
// Only create the address elements if there are address
|
||||||
|
@ -143,14 +149,12 @@ export default class AddressSelector extends React.Component {
|
||||||
onMouseEnter={this.onMouseEnter.bind(this, i)}
|
onMouseEnter={this.onMouseEnter.bind(this, i)}
|
||||||
onMouseLeave={this.onMouseLeave}
|
onMouseLeave={this.onMouseLeave}
|
||||||
key={this.props.addressList[i].addressType + "/" + this.props.addressList[i].address}
|
key={this.props.addressList[i].addressType + "/" + this.props.addressList[i].address}
|
||||||
ref={(ref) => { this.addressListElement = ref; }}
|
ref={this.addressListElement}
|
||||||
>
|
>
|
||||||
<AddressTile
|
<AddressTile
|
||||||
address={this.props.addressList[i]}
|
address={this.props.addressList[i]}
|
||||||
showAddress={this.props.showAddress}
|
showAddress={this.props.showAddress}
|
||||||
justified={true}
|
justified={true}
|
||||||
networkName="vector"
|
|
||||||
networkUrl={require("../../../../res/img/search-icon-vector.svg")}
|
|
||||||
/>
|
/>
|
||||||
</div>,
|
</div>,
|
||||||
);
|
);
|
||||||
|
@ -159,7 +163,7 @@ export default class AddressSelector extends React.Component {
|
||||||
return addressList;
|
return addressList;
|
||||||
}
|
}
|
||||||
|
|
||||||
_maxSelected(list) {
|
private maxSelected(list: IUserAddress[]): number {
|
||||||
const listSize = list.length === 0 ? 0 : list.length - 1;
|
const listSize = list.length === 0 ? 0 : list.length - 1;
|
||||||
const maxSelected = listSize > (this.props.truncateAt - 1) ? (this.props.truncateAt - 1) : listSize;
|
const maxSelected = listSize > (this.props.truncateAt - 1) ? (this.props.truncateAt - 1) : listSize;
|
||||||
return maxSelected;
|
return maxSelected;
|
||||||
|
@ -172,7 +176,7 @@ export default class AddressSelector extends React.Component {
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classes} ref={(ref) => {this.scrollElement = ref;}}>
|
<div className={classes} ref={this.scrollElement}>
|
||||||
{ this.props.header }
|
{ this.props.header }
|
||||||
{ this.createAddressListTiles() }
|
{ this.createAddressListTiles() }
|
||||||
</div>
|
</div>
|
|
@ -16,24 +16,25 @@ limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import * as sdk from "../../../index";
|
|
||||||
import { _t } from '../../../languageHandler';
|
import { _t } from '../../../languageHandler';
|
||||||
import { UserAddressType } from '../../../UserAddress';
|
|
||||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||||
import { mediaFromMxc } from "../../../customisations/Media";
|
import { mediaFromMxc } from "../../../customisations/Media";
|
||||||
|
import { IUserAddress } from '../../../UserAddress';
|
||||||
|
import BaseAvatar from '../avatars/BaseAvatar';
|
||||||
|
import EmailUserIcon from "../../../../res/img/icon-email-user.svg";
|
||||||
|
|
||||||
|
interface IProps {
|
||||||
|
address: IUserAddress;
|
||||||
|
canDismiss?: boolean;
|
||||||
|
onDismissed?: () => void;
|
||||||
|
justified?: boolean;
|
||||||
|
showAddress?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
@replaceableComponent("views.elements.AddressTile")
|
@replaceableComponent("views.elements.AddressTile")
|
||||||
export default class AddressTile extends React.Component {
|
export default class AddressTile extends React.Component<IProps> {
|
||||||
static propTypes = {
|
static defaultProps: Partial<IProps> = {
|
||||||
address: UserAddressType.isRequired,
|
|
||||||
canDismiss: PropTypes.bool,
|
|
||||||
onDismissed: PropTypes.func,
|
|
||||||
justified: PropTypes.bool,
|
|
||||||
};
|
|
||||||
|
|
||||||
static defaultProps = {
|
|
||||||
canDismiss: false,
|
canDismiss: false,
|
||||||
onDismissed: function() {}, // NOP
|
onDismissed: function() {}, // NOP
|
||||||
justified: false,
|
justified: false,
|
||||||
|
@ -49,11 +50,9 @@ export default class AddressTile extends React.Component {
|
||||||
if (isMatrixAddress && address.avatarMxc) {
|
if (isMatrixAddress && address.avatarMxc) {
|
||||||
imgUrls.push(mediaFromMxc(address.avatarMxc).getSquareThumbnailHttp(25));
|
imgUrls.push(mediaFromMxc(address.avatarMxc).getSquareThumbnailHttp(25));
|
||||||
} else if (address.addressType === 'email') {
|
} else if (address.addressType === 'email') {
|
||||||
imgUrls.push(require("../../../../res/img/icon-email-user.svg"));
|
imgUrls.push(EmailUserIcon);
|
||||||
}
|
}
|
||||||
|
|
||||||
const BaseAvatar = sdk.getComponent('avatars.BaseAvatar');
|
|
||||||
|
|
||||||
const nameClasses = classNames({
|
const nameClasses = classNames({
|
||||||
"mx_AddressTile_name": true,
|
"mx_AddressTile_name": true,
|
||||||
"mx_AddressTile_justified": this.props.justified,
|
"mx_AddressTile_justified": this.props.justified,
|
||||||
|
@ -70,9 +69,10 @@ export default class AddressTile extends React.Component {
|
||||||
info = (
|
info = (
|
||||||
<div className="mx_AddressTile_mx">
|
<div className="mx_AddressTile_mx">
|
||||||
<div className={nameClasses}>{ name }</div>
|
<div className={nameClasses}>{ name }</div>
|
||||||
{ this.props.showAddress ?
|
{
|
||||||
<div className={idClasses}>{ address.address }</div> :
|
this.props.showAddress
|
||||||
<div />
|
? <div className={idClasses}>{ address.address }</div>
|
||||||
|
: <div />
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
|
@ -17,30 +17,39 @@ limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import url from 'url';
|
import url from 'url';
|
||||||
import * as sdk from '../../../index';
|
|
||||||
import { _t } from '../../../languageHandler';
|
import { _t } from '../../../languageHandler';
|
||||||
import SdkConfig from '../../../SdkConfig';
|
import SdkConfig from '../../../SdkConfig';
|
||||||
import WidgetUtils from "../../../utils/WidgetUtils";
|
import WidgetUtils from "../../../utils/WidgetUtils";
|
||||||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||||
|
import { RoomMember } from 'matrix-js-sdk/src/models/room-member';
|
||||||
|
import MemberAvatar from '../avatars/MemberAvatar';
|
||||||
|
import BaseAvatar from '../avatars/BaseAvatar';
|
||||||
|
import AccessibleButton from './AccessibleButton';
|
||||||
|
import TextWithTooltip from "./TextWithTooltip";
|
||||||
|
|
||||||
|
interface IProps {
|
||||||
|
url: string;
|
||||||
|
creatorUserId: string;
|
||||||
|
roomId: string;
|
||||||
|
onPermissionGranted: () => void;
|
||||||
|
isRoomEncrypted?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IState {
|
||||||
|
roomMember: RoomMember;
|
||||||
|
isWrapped: boolean;
|
||||||
|
widgetDomain: string;
|
||||||
|
}
|
||||||
|
|
||||||
@replaceableComponent("views.elements.AppPermission")
|
@replaceableComponent("views.elements.AppPermission")
|
||||||
export default class AppPermission extends React.Component {
|
export default class AppPermission extends React.Component<IProps, IState> {
|
||||||
static propTypes = {
|
static defaultProps: Partial<IProps> = {
|
||||||
url: PropTypes.string.isRequired,
|
|
||||||
creatorUserId: PropTypes.string.isRequired,
|
|
||||||
roomId: PropTypes.string.isRequired,
|
|
||||||
onPermissionGranted: PropTypes.func.isRequired,
|
|
||||||
isRoomEncrypted: PropTypes.bool,
|
|
||||||
};
|
|
||||||
|
|
||||||
static defaultProps = {
|
|
||||||
onPermissionGranted: () => {},
|
onPermissionGranted: () => {},
|
||||||
};
|
};
|
||||||
|
|
||||||
constructor(props) {
|
constructor(props: IProps) {
|
||||||
super(props);
|
super(props);
|
||||||
|
|
||||||
// The first step is to pick apart the widget so we can render information about it
|
// The first step is to pick apart the widget so we can render information about it
|
||||||
|
@ -55,16 +64,18 @@ export default class AppPermission extends React.Component {
|
||||||
this.state = {
|
this.state = {
|
||||||
...urlInfo,
|
...urlInfo,
|
||||||
roomMember,
|
roomMember,
|
||||||
|
isWrapped: null,
|
||||||
|
widgetDomain: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
parseWidgetUrl() {
|
private parseWidgetUrl(): { isWrapped: boolean, widgetDomain: string } {
|
||||||
const widgetUrl = url.parse(this.props.url);
|
const widgetUrl = url.parse(this.props.url);
|
||||||
const params = new URLSearchParams(widgetUrl.search);
|
const params = new URLSearchParams(widgetUrl.search);
|
||||||
|
|
||||||
// HACK: We're relying on the query params when we should be relying on the widget's `data`.
|
// HACK: We're relying on the query params when we should be relying on the widget's `data`.
|
||||||
// This is a workaround for Scalar.
|
// This is a workaround for Scalar.
|
||||||
if (WidgetUtils.isScalarUrl(widgetUrl) && params && params.get('url')) {
|
if (WidgetUtils.isScalarUrl(this.props.url) && params && params.get('url')) {
|
||||||
const unwrappedUrl = url.parse(params.get('url'));
|
const unwrappedUrl = url.parse(params.get('url'));
|
||||||
return {
|
return {
|
||||||
widgetDomain: unwrappedUrl.host || unwrappedUrl.hostname,
|
widgetDomain: unwrappedUrl.host || unwrappedUrl.hostname,
|
||||||
|
@ -80,10 +91,6 @@ export default class AppPermission extends React.Component {
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const brand = SdkConfig.get().brand;
|
const brand = SdkConfig.get().brand;
|
||||||
const AccessibleButton = sdk.getComponent("views.elements.AccessibleButton");
|
|
||||||
const MemberAvatar = sdk.getComponent("views.avatars.MemberAvatar");
|
|
||||||
const BaseAvatar = sdk.getComponent("views.avatars.BaseAvatar");
|
|
||||||
const TextWithTooltip = sdk.getComponent("views.elements.TextWithTooltip");
|
|
||||||
|
|
||||||
const displayName = this.state.roomMember ? this.state.roomMember.name : this.props.creatorUserId;
|
const displayName = this.state.roomMember ? this.state.roomMember.name : this.props.creatorUserId;
|
||||||
const userId = displayName === this.props.creatorUserId ? null : this.props.creatorUserId;
|
const userId = displayName === this.props.creatorUserId ? null : this.props.creatorUserId;
|
|
@ -23,6 +23,7 @@ import AudioPlayer from "../audio_messages/AudioPlayer";
|
||||||
import { IMediaEventContent } from "../../../customisations/models/IMediaEventContent";
|
import { IMediaEventContent } from "../../../customisations/models/IMediaEventContent";
|
||||||
import MFileBody from "./MFileBody";
|
import MFileBody from "./MFileBody";
|
||||||
import { IBodyProps } from "./IBodyProps";
|
import { IBodyProps } from "./IBodyProps";
|
||||||
|
import { PlaybackManager } from "../../../voice/PlaybackManager";
|
||||||
|
|
||||||
interface IState {
|
interface IState {
|
||||||
error?: Error;
|
error?: Error;
|
||||||
|
@ -62,7 +63,7 @@ export default class MAudioBody extends React.PureComponent<IBodyProps, IState>
|
||||||
const waveform = content?.["org.matrix.msc1767.audio"]?.waveform?.map(p => p / 1024);
|
const waveform = content?.["org.matrix.msc1767.audio"]?.waveform?.map(p => p / 1024);
|
||||||
|
|
||||||
// We should have a buffer to work with now: let's set it up
|
// We should have a buffer to work with now: let's set it up
|
||||||
const playback = new Playback(buffer, waveform);
|
const playback = PlaybackManager.instance.createPlaybackInstance(buffer, waveform);
|
||||||
playback.clockInfo.populatePlaceholdersFrom(this.props.mxEvent);
|
playback.clockInfo.populatePlaceholdersFrom(this.props.mxEvent);
|
||||||
this.setState({ playback });
|
this.setState({ playback });
|
||||||
|
|
||||||
|
|
|
@ -23,7 +23,7 @@ import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||||
import { mediaFromContent } from "../../../customisations/Media";
|
import { mediaFromContent } from "../../../customisations/Media";
|
||||||
import ErrorDialog from "../dialogs/ErrorDialog";
|
import ErrorDialog from "../dialogs/ErrorDialog";
|
||||||
import { TileShape } from "../rooms/EventTile";
|
import { TileShape } from "../rooms/EventTile";
|
||||||
import { IContent } from "matrix-js-sdk/src";
|
import { presentableTextForFile } from "../../../utils/FileUtils";
|
||||||
import { IMediaEventContent } from "../../../customisations/models/IMediaEventContent";
|
import { IMediaEventContent } from "../../../customisations/models/IMediaEventContent";
|
||||||
import { IBodyProps } from "./IBodyProps";
|
import { IBodyProps } from "./IBodyProps";
|
||||||
|
|
||||||
|
@ -93,35 +93,6 @@ export function computedStyle(element: HTMLElement) {
|
||||||
return cssText;
|
return cssText;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Extracts a human readable label for the file attachment to use as
|
|
||||||
* link text.
|
|
||||||
*
|
|
||||||
* @param {Object} content The "content" key of the matrix event.
|
|
||||||
* @param {boolean} withSize Whether to include size information. Default true.
|
|
||||||
* @return {string} the human readable link text for the attachment.
|
|
||||||
*/
|
|
||||||
export function presentableTextForFile(content: IContent, withSize = true): string {
|
|
||||||
let linkText = _t("Attachment");
|
|
||||||
if (content.body && content.body.length > 0) {
|
|
||||||
// The content body should be the name of the file including a
|
|
||||||
// file extension.
|
|
||||||
linkText = content.body;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (content.info && content.info.size && withSize) {
|
|
||||||
// If we know the size of the file then add it as human readable
|
|
||||||
// string to the end of the link text so that the user knows how
|
|
||||||
// big a file they are downloading.
|
|
||||||
// The content.info also contains a MIME-type but we don't display
|
|
||||||
// it since it is "ugly", users generally aren't aware what it
|
|
||||||
// means and the type of the attachment can usually be inferrered
|
|
||||||
// from the file extension.
|
|
||||||
linkText += ' (' + filesize(content.info.size) + ')';
|
|
||||||
}
|
|
||||||
return linkText;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface IProps extends IBodyProps {
|
interface IProps extends IBodyProps {
|
||||||
/* whether or not to show the default placeholder for the file. Defaults to true. */
|
/* whether or not to show the default placeholder for the file. Defaults to true. */
|
||||||
showGenericPlaceholder: boolean;
|
showGenericPlaceholder: boolean;
|
||||||
|
@ -170,10 +141,10 @@ export default class MFileBody extends React.Component<IProps, IState> {
|
||||||
let placeholder = null;
|
let placeholder = null;
|
||||||
if (this.props.showGenericPlaceholder) {
|
if (this.props.showGenericPlaceholder) {
|
||||||
placeholder = (
|
placeholder = (
|
||||||
<div className="mx_MFileBody_info">
|
<div className="mx_MediaBody mx_MFileBody_info">
|
||||||
<span className="mx_MFileBody_info_icon" />
|
<span className="mx_MFileBody_info_icon" />
|
||||||
<span className="mx_MFileBody_info_filename">
|
<span className="mx_MFileBody_info_filename">
|
||||||
{ presentableTextForFile(content, false) }
|
{ presentableTextForFile(content, _t("Attachment"), false) }
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -404,7 +404,7 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Overidden by MStickerBody
|
// Overidden by MStickerBody
|
||||||
protected getFileBody(): JSX.Element {
|
protected getFileBody(): string | JSX.Element {
|
||||||
// We only ever need the download bar if we're appearing outside of the timeline
|
// We only ever need the download bar if we're appearing outside of the timeline
|
||||||
if (this.props.tileShape) {
|
if (this.props.tileShape) {
|
||||||
return <MFileBody {...this.props} showGenericPlaceholder={false} />;
|
return <MFileBody {...this.props} showGenericPlaceholder={false} />;
|
||||||
|
|
|
@ -16,9 +16,11 @@ limitations under the License.
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import MImageBody from "./MImageBody";
|
import MImageBody from "./MImageBody";
|
||||||
import { presentableTextForFile } from "./MFileBody";
|
import { presentableTextForFile } from "../../../utils/FileUtils";
|
||||||
import { IMediaEventContent } from "../../../customisations/models/IMediaEventContent";
|
import { IMediaEventContent } from "../../../customisations/models/IMediaEventContent";
|
||||||
import SenderProfile from "./SenderProfile";
|
import SenderProfile from "./SenderProfile";
|
||||||
|
import { EventType } from "matrix-js-sdk/src/@types/event";
|
||||||
|
import { _t } from "../../../languageHandler";
|
||||||
|
|
||||||
const FORCED_IMAGE_HEIGHT = 44;
|
const FORCED_IMAGE_HEIGHT = 44;
|
||||||
|
|
||||||
|
@ -32,8 +34,9 @@ export default class MImageReplyBody extends MImageBody {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Don't show "Download this_file.png ..."
|
// Don't show "Download this_file.png ..."
|
||||||
public getFileBody(): JSX.Element {
|
public getFileBody(): string {
|
||||||
return <>{ presentableTextForFile(this.props.mxEvent.getContent()) }</>;
|
const sticker = this.props.mxEvent.getType() === EventType.Sticker;
|
||||||
|
return presentableTextForFile(this.props.mxEvent.getContent(), sticker ? _t("Sticker") : _t("Image"), !sticker);
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
|
|
|
@ -68,7 +68,7 @@ interface IState {
|
||||||
suggestedRooms: ISuggestedRoom[];
|
suggestedRooms: ISuggestedRoom[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const TAG_ORDER: TagID[] = [
|
export const TAG_ORDER: TagID[] = [
|
||||||
DefaultTagID.Invite,
|
DefaultTagID.Invite,
|
||||||
DefaultTagID.Favourite,
|
DefaultTagID.Favourite,
|
||||||
DefaultTagID.DM,
|
DefaultTagID.DM,
|
||||||
|
|
|
@ -419,7 +419,7 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
|
||||||
>
|
>
|
||||||
<IconizedContextMenuOptionList first>
|
<IconizedContextMenuOptionList first>
|
||||||
<IconizedContextMenuRadio
|
<IconizedContextMenuRadio
|
||||||
label={_t("Global")}
|
label={_t("Use default")}
|
||||||
active={state === ALL_MESSAGES}
|
active={state === ALL_MESSAGES}
|
||||||
iconClassName="mx_RoomTile_iconBell"
|
iconClassName="mx_RoomTile_iconBell"
|
||||||
onClick={this.onClickAllNotifs}
|
onClick={this.onClickAllNotifs}
|
||||||
|
|
|
@ -514,13 +514,11 @@ export default class SendMessageComposer extends React.Component<IProps> {
|
||||||
|
|
||||||
private onPaste = (event: ClipboardEvent<HTMLDivElement>): boolean => {
|
private onPaste = (event: ClipboardEvent<HTMLDivElement>): boolean => {
|
||||||
const { clipboardData } = event;
|
const { clipboardData } = event;
|
||||||
// Prioritize text on the clipboard over files as Office on macOS puts a bitmap
|
// Prioritize text on the clipboard over files if RTF is present as Office on macOS puts a bitmap
|
||||||
// in the clipboard as well as the content being copied.
|
// in the clipboard as well as the content being copied. Modern versions of Office seem to not do this anymore.
|
||||||
if (clipboardData.files.length && !clipboardData.types.some(t => t === "text/plain")) {
|
// We check text/rtf instead of text/plain as when copy+pasting a file from Finder or Gnome Image Viewer
|
||||||
// This actually not so much for 'files' as such (at time of writing
|
// it puts the filename in as text/plain which we want to ignore.
|
||||||
// neither chrome nor firefox let you paste a plain file copied
|
if (clipboardData.files.length && !clipboardData.types.includes("text/rtf")) {
|
||||||
// from Finder) but more images copied from a different website
|
|
||||||
// / word processor etc.
|
|
||||||
ContentMessages.sharedInstance().sendContentListToRoom(
|
ContentMessages.sharedInstance().sendContentListToRoom(
|
||||||
Array.from(clipboardData.files), this.props.room.roomId, this.context,
|
Array.from(clipboardData.files), this.props.room.roomId, this.context,
|
||||||
);
|
);
|
||||||
|
|
|
@ -68,37 +68,49 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.state.recorder.stop();
|
await this.state.recorder.stop();
|
||||||
const upload = await this.state.recorder.upload(this.props.room.roomId);
|
|
||||||
MatrixClientPeg.get().sendMessage(this.props.room.roomId, {
|
|
||||||
"body": "Voice message",
|
|
||||||
//"msgtype": "org.matrix.msc2516.voice",
|
|
||||||
"msgtype": MsgType.Audio,
|
|
||||||
"url": upload.mxc,
|
|
||||||
"file": upload.encrypted,
|
|
||||||
"info": {
|
|
||||||
duration: Math.round(this.state.recorder.durationSeconds * 1000),
|
|
||||||
mimetype: this.state.recorder.contentType,
|
|
||||||
size: this.state.recorder.contentLength,
|
|
||||||
},
|
|
||||||
|
|
||||||
// MSC1767 + Ideals of MSC2516 as MSC3245
|
try {
|
||||||
// https://github.com/matrix-org/matrix-doc/pull/3245
|
const upload = await this.state.recorder.upload(this.props.room.roomId);
|
||||||
"org.matrix.msc1767.text": "Voice message",
|
|
||||||
"org.matrix.msc1767.file": {
|
|
||||||
url: upload.mxc,
|
|
||||||
file: upload.encrypted,
|
|
||||||
name: "Voice message.ogg",
|
|
||||||
mimetype: this.state.recorder.contentType,
|
|
||||||
size: this.state.recorder.contentLength,
|
|
||||||
},
|
|
||||||
"org.matrix.msc1767.audio": {
|
|
||||||
duration: Math.round(this.state.recorder.durationSeconds * 1000),
|
|
||||||
|
|
||||||
// https://github.com/matrix-org/matrix-doc/pull/3246
|
// noinspection ES6MissingAwait - we don't care if it fails, it'll get queued.
|
||||||
waveform: this.state.recorder.getPlayback().thumbnailWaveform.map(v => Math.round(v * 1024)),
|
MatrixClientPeg.get().sendMessage(this.props.room.roomId, {
|
||||||
},
|
"body": "Voice message",
|
||||||
"org.matrix.msc3245.voice": {}, // No content, this is a rendering hint
|
//"msgtype": "org.matrix.msc2516.voice",
|
||||||
});
|
"msgtype": MsgType.Audio,
|
||||||
|
"url": upload.mxc,
|
||||||
|
"file": upload.encrypted,
|
||||||
|
"info": {
|
||||||
|
duration: Math.round(this.state.recorder.durationSeconds * 1000),
|
||||||
|
mimetype: this.state.recorder.contentType,
|
||||||
|
size: this.state.recorder.contentLength,
|
||||||
|
},
|
||||||
|
|
||||||
|
// MSC1767 + Ideals of MSC2516 as MSC3245
|
||||||
|
// https://github.com/matrix-org/matrix-doc/pull/3245
|
||||||
|
"org.matrix.msc1767.text": "Voice message",
|
||||||
|
"org.matrix.msc1767.file": {
|
||||||
|
url: upload.mxc,
|
||||||
|
file: upload.encrypted,
|
||||||
|
name: "Voice message.ogg",
|
||||||
|
mimetype: this.state.recorder.contentType,
|
||||||
|
size: this.state.recorder.contentLength,
|
||||||
|
},
|
||||||
|
"org.matrix.msc1767.audio": {
|
||||||
|
duration: Math.round(this.state.recorder.durationSeconds * 1000),
|
||||||
|
|
||||||
|
// https://github.com/matrix-org/matrix-doc/pull/3246
|
||||||
|
waveform: this.state.recorder.getPlayback().thumbnailWaveform.map(v => Math.round(v * 1024)),
|
||||||
|
},
|
||||||
|
"org.matrix.msc3245.voice": {}, // No content, this is a rendering hint
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error sending/uploading voice message:", e);
|
||||||
|
Modal.createTrackedDialog('Upload failed', '', ErrorDialog, {
|
||||||
|
title: _t('Upload Failed'),
|
||||||
|
description: _t("The voice message failed to upload."),
|
||||||
|
});
|
||||||
|
return; // don't dispose the recording so the user can retry, maybe
|
||||||
|
}
|
||||||
await this.disposeRecording();
|
await this.disposeRecording();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -393,7 +393,7 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I
|
||||||
<span className="mx_SettingsTab_subheading">{ _t("Message layout") }</span>
|
<span className="mx_SettingsTab_subheading">{ _t("Message layout") }</span>
|
||||||
|
|
||||||
<div className="mx_AppearanceUserSettingsTab_Layout_RadioButtons">
|
<div className="mx_AppearanceUserSettingsTab_Layout_RadioButtons">
|
||||||
<div className={classNames("mx_AppearanceUserSettingsTab_Layout_RadioButton", {
|
<label className={classNames("mx_AppearanceUserSettingsTab_Layout_RadioButton", {
|
||||||
mx_AppearanceUserSettingsTab_Layout_RadioButton_selected: this.state.layout == Layout.IRC,
|
mx_AppearanceUserSettingsTab_Layout_RadioButton_selected: this.state.layout == Layout.IRC,
|
||||||
})}>
|
})}>
|
||||||
<EventTilePreview
|
<EventTilePreview
|
||||||
|
@ -412,9 +412,8 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I
|
||||||
>
|
>
|
||||||
{ _t("IRC") }
|
{ _t("IRC") }
|
||||||
</StyledRadioButton>
|
</StyledRadioButton>
|
||||||
</div>
|
</label>
|
||||||
<div className="mx_AppearanceUserSettingsTab_spacer" />
|
<label className={classNames("mx_AppearanceUserSettingsTab_Layout_RadioButton", {
|
||||||
<div className={classNames("mx_AppearanceUserSettingsTab_Layout_RadioButton", {
|
|
||||||
mx_AppearanceUserSettingsTab_Layout_RadioButton_selected: this.state.layout == Layout.Group,
|
mx_AppearanceUserSettingsTab_Layout_RadioButton_selected: this.state.layout == Layout.Group,
|
||||||
})}>
|
})}>
|
||||||
<EventTilePreview
|
<EventTilePreview
|
||||||
|
@ -433,9 +432,8 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I
|
||||||
>
|
>
|
||||||
{ _t("Modern") }
|
{ _t("Modern") }
|
||||||
</StyledRadioButton>
|
</StyledRadioButton>
|
||||||
</div>
|
</label>
|
||||||
<div className="mx_AppearanceUserSettingsTab_spacer" />
|
<label className={classNames("mx_AppearanceUserSettingsTab_Layout_RadioButton", {
|
||||||
<div className={classNames("mx_AppearanceUserSettingsTab_Layout_RadioButton", {
|
|
||||||
mx_AppearanceUserSettingsTab_Layout_RadioButton_selected: this.state.layout === Layout.Bubble,
|
mx_AppearanceUserSettingsTab_Layout_RadioButton_selected: this.state.layout === Layout.Bubble,
|
||||||
})}>
|
})}>
|
||||||
<EventTilePreview
|
<EventTilePreview
|
||||||
|
@ -454,7 +452,7 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I
|
||||||
>
|
>
|
||||||
{ _t("Message bubbles") }
|
{ _t("Message bubbles") }
|
||||||
</StyledRadioButton>
|
</StyledRadioButton>
|
||||||
</div>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>;
|
</div>;
|
||||||
};
|
};
|
||||||
|
|
|
@ -76,7 +76,11 @@ const SpaceButton: React.FC<IButtonProps> = ({
|
||||||
let notifBadge;
|
let notifBadge;
|
||||||
if (notificationState) {
|
if (notificationState) {
|
||||||
notifBadge = <div className="mx_SpacePanel_badgeContainer">
|
notifBadge = <div className="mx_SpacePanel_badgeContainer">
|
||||||
<NotificationBadge forceCount={false} notification={notificationState} />
|
<NotificationBadge
|
||||||
|
onClick={() => SpaceStore.instance.setActiveRoomInSpace(space)}
|
||||||
|
forceCount={false}
|
||||||
|
notification={notificationState}
|
||||||
|
/>
|
||||||
</div>;
|
</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -401,7 +401,11 @@ export class SpaceItem extends React.PureComponent<IItemProps, IItemState> {
|
||||||
let notifBadge;
|
let notifBadge;
|
||||||
if (notificationState) {
|
if (notificationState) {
|
||||||
notifBadge = <div className="mx_SpacePanel_badgeContainer">
|
notifBadge = <div className="mx_SpacePanel_badgeContainer">
|
||||||
<NotificationBadge forceCount={false} notification={notificationState} />
|
<NotificationBadge
|
||||||
|
onClick={() => SpaceStore.instance.setActiveRoomInSpace(space)}
|
||||||
|
forceCount={false}
|
||||||
|
notification={notificationState}
|
||||||
|
/>
|
||||||
</div>;
|
</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -41,6 +41,10 @@ const RoomContext = createContext<IState>({
|
||||||
canReply: false,
|
canReply: false,
|
||||||
layout: Layout.Group,
|
layout: Layout.Group,
|
||||||
lowBandwidth: false,
|
lowBandwidth: false,
|
||||||
|
alwaysShowTimestamps: false,
|
||||||
|
showTwelveHourTimestamps: false,
|
||||||
|
readMarkerInViewThresholdMs: 3000,
|
||||||
|
readMarkerOutOfViewThresholdMs: 30000,
|
||||||
showHiddenEventsInTimeline: false,
|
showHiddenEventsInTimeline: false,
|
||||||
showReadReceipts: true,
|
showReadReceipts: true,
|
||||||
showRedactions: true,
|
showRedactions: true,
|
||||||
|
|
|
@ -655,6 +655,7 @@
|
||||||
"This homeserver has exceeded one of its resource limits.": "This homeserver has exceeded one of its resource limits.",
|
"This homeserver has exceeded one of its resource limits.": "This homeserver has exceeded one of its resource limits.",
|
||||||
"Please <a>contact your service administrator</a> to continue using the service.": "Please <a>contact your service administrator</a> to continue using the service.",
|
"Please <a>contact your service administrator</a> to continue using the service.": "Please <a>contact your service administrator</a> to continue using the service.",
|
||||||
"Unable to connect to Homeserver. Retrying...": "Unable to connect to Homeserver. Retrying...",
|
"Unable to connect to Homeserver. Retrying...": "Unable to connect to Homeserver. Retrying...",
|
||||||
|
"Attachment": "Attachment",
|
||||||
"%(items)s and %(count)s others|other": "%(items)s and %(count)s others",
|
"%(items)s and %(count)s others|other": "%(items)s and %(count)s others",
|
||||||
"%(items)s and %(count)s others|one": "%(items)s and one other",
|
"%(items)s and %(count)s others|one": "%(items)s and one other",
|
||||||
"%(items)s and %(lastItem)s": "%(items)s and %(lastItem)s",
|
"%(items)s and %(lastItem)s": "%(items)s and %(lastItem)s",
|
||||||
|
@ -1656,6 +1657,7 @@
|
||||||
"Show %(count)s more|other": "Show %(count)s more",
|
"Show %(count)s more|other": "Show %(count)s more",
|
||||||
"Show %(count)s more|one": "Show %(count)s more",
|
"Show %(count)s more|one": "Show %(count)s more",
|
||||||
"Show less": "Show less",
|
"Show less": "Show less",
|
||||||
|
"Use default": "Use default",
|
||||||
"All messages": "All messages",
|
"All messages": "All messages",
|
||||||
"Mentions & Keywords": "Mentions & Keywords",
|
"Mentions & Keywords": "Mentions & Keywords",
|
||||||
"Notification options": "Notification options",
|
"Notification options": "Notification options",
|
||||||
|
@ -1692,6 +1694,7 @@
|
||||||
"Invited by %(sender)s": "Invited by %(sender)s",
|
"Invited by %(sender)s": "Invited by %(sender)s",
|
||||||
"Jump to first unread message.": "Jump to first unread message.",
|
"Jump to first unread message.": "Jump to first unread message.",
|
||||||
"Mark all as read": "Mark all as read",
|
"Mark all as read": "Mark all as read",
|
||||||
|
"The voice message failed to upload.": "The voice message failed to upload.",
|
||||||
"Unable to access your microphone": "Unable to access your microphone",
|
"Unable to access your microphone": "Unable to access your microphone",
|
||||||
"We were unable to access your microphone. Please check your browser settings and try again.": "We were unable to access your microphone. Please check your browser settings and try again.",
|
"We were unable to access your microphone. Please check your browser settings and try again.": "We were unable to access your microphone. Please check your browser settings and try again.",
|
||||||
"No microphone found": "No microphone found",
|
"No microphone found": "No microphone found",
|
||||||
|
@ -1894,13 +1897,14 @@
|
||||||
"Retry": "Retry",
|
"Retry": "Retry",
|
||||||
"Reply": "Reply",
|
"Reply": "Reply",
|
||||||
"Message Actions": "Message Actions",
|
"Message Actions": "Message Actions",
|
||||||
"Attachment": "Attachment",
|
|
||||||
"Error decrypting attachment": "Error decrypting attachment",
|
"Error decrypting attachment": "Error decrypting attachment",
|
||||||
"Decrypt %(text)s": "Decrypt %(text)s",
|
"Decrypt %(text)s": "Decrypt %(text)s",
|
||||||
"Download %(text)s": "Download %(text)s",
|
"Download %(text)s": "Download %(text)s",
|
||||||
"Invalid file%(extra)s": "Invalid file%(extra)s",
|
"Invalid file%(extra)s": "Invalid file%(extra)s",
|
||||||
"Error decrypting image": "Error decrypting image",
|
"Error decrypting image": "Error decrypting image",
|
||||||
"Show image": "Show image",
|
"Show image": "Show image",
|
||||||
|
"Sticker": "Sticker",
|
||||||
|
"Image": "Image",
|
||||||
"Join the conference at the top of this room": "Join the conference at the top of this room",
|
"Join the conference at the top of this room": "Join the conference at the top of this room",
|
||||||
"Join the conference from the room information card on the right": "Join the conference from the room information card on the right",
|
"Join the conference from the room information card on the right": "Join the conference from the room information card on the right",
|
||||||
"Video conference ended by %(senderName)s": "Video conference ended by %(senderName)s",
|
"Video conference ended by %(senderName)s": "Video conference ended by %(senderName)s",
|
||||||
|
@ -2631,6 +2635,7 @@
|
||||||
"Use email to optionally be discoverable by existing contacts.": "Use email to optionally be discoverable by existing contacts.",
|
"Use email to optionally be discoverable by existing contacts.": "Use email to optionally be discoverable by existing contacts.",
|
||||||
"Sign in with SSO": "Sign in with SSO",
|
"Sign in with SSO": "Sign in with SSO",
|
||||||
"Unnamed audio": "Unnamed audio",
|
"Unnamed audio": "Unnamed audio",
|
||||||
|
"Error downloading audio": "Error downloading audio",
|
||||||
"Pause": "Pause",
|
"Pause": "Pause",
|
||||||
"Play": "Play",
|
"Play": "Play",
|
||||||
"Couldn't load page": "Couldn't load page",
|
"Couldn't load page": "Couldn't load page",
|
||||||
|
|
|
@ -41,6 +41,7 @@ import { arrayHasDiff } from "../utils/arrays";
|
||||||
import { objectDiff } from "../utils/objects";
|
import { objectDiff } from "../utils/objects";
|
||||||
import { arrayHasOrderChange } from "../utils/arrays";
|
import { arrayHasOrderChange } from "../utils/arrays";
|
||||||
import { reorderLexicographically } from "../utils/stringOrderField";
|
import { reorderLexicographically } from "../utils/stringOrderField";
|
||||||
|
import { TAG_ORDER } from "../components/views/rooms/RoomList";
|
||||||
import { shouldShowSpaceSettings } from "../utils/space";
|
import { shouldShowSpaceSettings } from "../utils/space";
|
||||||
import ToastStore from "./ToastStore";
|
import ToastStore from "./ToastStore";
|
||||||
import { _t } from "../languageHandler";
|
import { _t } from "../languageHandler";
|
||||||
|
@ -140,6 +141,41 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
|
||||||
return this._suggestedRooms;
|
return this._suggestedRooms;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async setActiveRoomInSpace(space: Room | null): Promise<void> {
|
||||||
|
if (space && !space.isSpaceRoom()) return;
|
||||||
|
if (space !== this.activeSpace) await this.setActiveSpace(space);
|
||||||
|
|
||||||
|
if (space) {
|
||||||
|
const notificationState = this.getNotificationState(space.roomId);
|
||||||
|
const roomId = notificationState.getFirstRoomWithNotifications();
|
||||||
|
defaultDispatcher.dispatch({
|
||||||
|
action: "view_room",
|
||||||
|
room_id: roomId,
|
||||||
|
context_switch: true,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const lists = RoomListStore.instance.unfilteredLists;
|
||||||
|
for (let i = 0; i < TAG_ORDER.length; i++) {
|
||||||
|
const t = TAG_ORDER[i];
|
||||||
|
const listRooms = lists[t];
|
||||||
|
const unreadRoom = listRooms.find((r: Room) => {
|
||||||
|
if (this.showInHomeSpace(r)) {
|
||||||
|
const state = RoomNotificationStateStore.instance.getRoomState(r);
|
||||||
|
return state.isUnread;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (unreadRoom) {
|
||||||
|
defaultDispatcher.dispatch({
|
||||||
|
action: "view_room",
|
||||||
|
room_id: unreadRoom.roomId,
|
||||||
|
context_switch: true,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public get restrictedJoinRuleSupport(): IRoomCapability {
|
public get restrictedJoinRuleSupport(): IRoomCapability {
|
||||||
return this._restrictedJoinRuleSupport;
|
return this._restrictedJoinRuleSupport;
|
||||||
}
|
}
|
||||||
|
@ -152,7 +188,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
|
||||||
* should not be done when the space switch is done implicitly due to another event like switching room.
|
* should not be done when the space switch is done implicitly due to another event like switching room.
|
||||||
*/
|
*/
|
||||||
public async setActiveSpace(space: Room | null, contextSwitch = true) {
|
public async setActiveSpace(space: Room | null, contextSwitch = true) {
|
||||||
if (space === this.activeSpace || (space && !space?.isSpaceRoom())) return;
|
if (space === this.activeSpace || (space && !space.isSpaceRoom())) return;
|
||||||
|
|
||||||
this._activeSpace = space;
|
this._activeSpace = space;
|
||||||
this.emit(UPDATE_SELECTED_SPACE, this.activeSpace);
|
this.emit(UPDATE_SELECTED_SPACE, this.activeSpace);
|
||||||
|
|
|
@ -15,7 +15,11 @@ limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import EventEmitter from "events";
|
import EventEmitter from "events";
|
||||||
import ResizeObserver from 'resize-observer-polyfill';
|
// XXX: resize-observer-polyfill has types that now conflict with typescript's
|
||||||
|
// own DOM types: https://github.com/que-etc/resize-observer-polyfill/issues/80
|
||||||
|
// Using require here rather than import is a horrenous workaround. We should
|
||||||
|
// be able to remove the polyfill once Safari 14 is released.
|
||||||
|
const ResizeObserverPolyfill = require('resize-observer-polyfill'); // eslint-disable-line @typescript-eslint/no-var-requires
|
||||||
import ResizeObserverEntry from 'resize-observer-polyfill/src/ResizeObserverEntry';
|
import ResizeObserverEntry from 'resize-observer-polyfill/src/ResizeObserverEntry';
|
||||||
|
|
||||||
export enum UI_EVENTS {
|
export enum UI_EVENTS {
|
||||||
|
@ -43,7 +47,7 @@ export default class UIStore extends EventEmitter {
|
||||||
// eslint-disable-next-line no-restricted-properties
|
// eslint-disable-next-line no-restricted-properties
|
||||||
this.windowHeight = window.innerHeight;
|
this.windowHeight = window.innerHeight;
|
||||||
|
|
||||||
this.resizeObserver = new ResizeObserver(this.resizeObserverCallback);
|
this.resizeObserver = new ResizeObserverPolyfill(this.resizeObserverCallback);
|
||||||
this.resizeObserver.observe(document.body);
|
this.resizeObserver.observe(document.body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -53,6 +53,10 @@ export class SpaceNotificationState extends NotificationState {
|
||||||
this.calculateTotalState();
|
this.calculateTotalState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getFirstRoomWithNotifications() {
|
||||||
|
return this.rooms.find((room) => room.getUnreadNotificationCount() > 0).roomId;
|
||||||
|
}
|
||||||
|
|
||||||
public destroy() {
|
public destroy() {
|
||||||
super.destroy();
|
super.destroy();
|
||||||
for (const state of Object.values(this.states)) {
|
for (const state of Object.values(this.states)) {
|
||||||
|
|
54
src/utils/FileUtils.ts
Normal file
54
src/utils/FileUtils.ts
Normal file
|
@ -0,0 +1,54 @@
|
||||||
|
/*
|
||||||
|
Copyright 2015 - 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");
|
||||||
|
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 filesize from 'filesize';
|
||||||
|
import { IMediaEventContent } from '../customisations/models/IMediaEventContent';
|
||||||
|
import { _t } from '../languageHandler';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts a human readable label for the file attachment to use as
|
||||||
|
* link text.
|
||||||
|
*
|
||||||
|
* @param {IMediaEventContent} content The "content" key of the matrix event.
|
||||||
|
* @param {string} fallbackText The fallback text
|
||||||
|
* @param {boolean} withSize Whether to include size information. Default true.
|
||||||
|
* @return {string} the human readable link text for the attachment.
|
||||||
|
*/
|
||||||
|
export function presentableTextForFile(
|
||||||
|
content: IMediaEventContent,
|
||||||
|
fallbackText = _t("Attachment"),
|
||||||
|
withSize = true,
|
||||||
|
): string {
|
||||||
|
let text = fallbackText;
|
||||||
|
if (content.body && content.body.length > 0) {
|
||||||
|
// The content body should be the name of the file including a
|
||||||
|
// file extension.
|
||||||
|
text = content.body;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (content.info && content.info.size && withSize) {
|
||||||
|
// If we know the size of the file then add it as human readable
|
||||||
|
// string to the end of the link text so that the user knows how
|
||||||
|
// big a file they are downloading.
|
||||||
|
// The content.info also contains a MIME-type but we don't display
|
||||||
|
// it since it is "ugly", users generally aren't aware what it
|
||||||
|
// means and the type of the attachment can usually be inferrered
|
||||||
|
// from the file extension.
|
||||||
|
text += ' (' + filesize(content.info.size) + ')';
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
|
@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import zxcvbn from 'zxcvbn';
|
import zxcvbn, { ZXCVBNFeedbackWarning } from 'zxcvbn';
|
||||||
|
|
||||||
import { MatrixClientPeg } from '../MatrixClientPeg';
|
import { MatrixClientPeg } from '../MatrixClientPeg';
|
||||||
import { _t, _td } from '../languageHandler';
|
import { _t, _td } from '../languageHandler';
|
||||||
|
@ -84,7 +84,7 @@ export function scorePassword(password: string) {
|
||||||
}
|
}
|
||||||
// and warning, if any
|
// and warning, if any
|
||||||
if (zxcvbnResult.feedback.warning) {
|
if (zxcvbnResult.feedback.warning) {
|
||||||
zxcvbnResult.feedback.warning = _t(zxcvbnResult.feedback.warning);
|
zxcvbnResult.feedback.warning = _t(zxcvbnResult.feedback.warning) as ZXCVBNFeedbackWarning;
|
||||||
}
|
}
|
||||||
|
|
||||||
return zxcvbnResult;
|
return zxcvbnResult;
|
||||||
|
|
37
src/voice/ManagedPlayback.ts
Normal file
37
src/voice/ManagedPlayback.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
|
@ -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*
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
54
src/voice/PlaybackManager.ts
Normal file
54
src/voice/PlaybackManager.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue