Merge branch 'develop' into gsouqet/ts-migration-1

This commit is contained in:
Germain Souquet 2021-07-20 18:43:08 +02:00
commit efce2d16f1
313 changed files with 4352 additions and 2957 deletions

View file

@ -46,6 +46,7 @@
"start:build": "babel src -w -s -d lib --verbose --extensions \".ts,.js\"",
"lint": "yarn lint:types && yarn lint:js && yarn lint:style",
"lint:js": "eslint --max-warnings 0 src test",
"lint:js-fix": "eslint --fix src test",
"lint:types": "tsc --noEmit --jsx react",
"lint:style": "stylelint 'res/css/**/*.scss'",
"test": "jest",
@ -64,8 +65,8 @@
"counterpart": "^0.18.6",
"diff-dom": "^4.2.2",
"diff-match-patch": "^1.0.5",
"emojibase-data": "^5.1.1",
"emojibase-regex": "^4.1.1",
"emojibase-data": "^6.2.0",
"emojibase-regex": "^5.1.3",
"escape-html": "^1.0.3",
"file-saver": "^2.0.5",
"filesize": "6.1.0",

View file

@ -162,6 +162,7 @@
@import "./views/messages/_CreateEvent.scss";
@import "./views/messages/_DateSeparator.scss";
@import "./views/messages/_EventTileBubble.scss";
@import "./views/messages/_CallEvent.scss";
@import "./views/messages/_MEmoteBody.scss";
@import "./views/messages/_MFileBody.scss";
@import "./views/messages/_MImageBody.scss";
@ -201,6 +202,7 @@
@import "./views/rooms/_EditMessageComposer.scss";
@import "./views/rooms/_EntityTile.scss";
@import "./views/rooms/_EventTile.scss";
@import "./views/rooms/_EventBubbleTile.scss";
@import "./views/rooms/_GroupLayout.scss";
@import "./views/rooms/_IRCLayout.scss";
@import "./views/rooms/_JumpToBottomButton.scss";

View file

@ -118,10 +118,6 @@ limitations under the License.
padding-left: 0px;
}
.mx_FilePanel .mx_EventTile:hover .mx_EventTile_line {
background-color: $primary-bg-color;
}
.mx_FilePanel_empty::before {
mask-image: url('$(res)/img/element-icons/room/files.svg');
}

View file

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

View file

@ -30,5 +30,12 @@ limitations under the License.
mask-position: center;
content: '';
vertical-align: middle;
}
.mx_InfoTooltip_icon_info::before {
mask-image: url('$(res)/img/element-icons/info.svg');
}
.mx_InfoTooltip_icon_warning::before {
mask-image: url('$(res)/img/element-icons/warning.svg');
}

View file

@ -0,0 +1,154 @@
/*
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.
*/
.mx_CallEvent {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
background-color: $dark-panel-bg-color;
border-radius: 8px;
margin: 10px auto;
max-width: 75%;
box-sizing: border-box;
height: 60px;
&.mx_CallEvent_voice {
.mx_CallEvent_type_icon::before,
.mx_CallEvent_content_button_callBack span::before,
.mx_CallEvent_content_button_answer span::before {
mask-image: url('$(res)/img/element-icons/call/voice-call.svg');
}
}
&.mx_CallEvent_video {
.mx_CallEvent_type_icon::before,
.mx_CallEvent_content_button_callBack span::before,
.mx_CallEvent_content_button_answer span::before {
mask-image: url('$(res)/img/element-icons/call/video-call.svg');
}
}
.mx_CallEvent_info {
display: flex;
flex-direction: row;
align-items: center;
margin-left: 12px;
.mx_CallEvent_info_basic {
display: flex;
flex-direction: column;
margin-left: 10px; // To match mx_CallEvent
.mx_CallEvent_sender {
font-weight: 600;
font-size: 1.5rem;
line-height: 1.8rem;
margin-bottom: 3px;
}
.mx_CallEvent_type {
font-weight: 400;
color: $secondary-fg-color;
font-size: 1.2rem;
line-height: $font-13px;
display: flex;
align-items: center;
.mx_CallEvent_type_icon {
height: 13px;
width: 13px;
margin-right: 5px;
&::before {
content: '';
position: absolute;
height: 13px;
width: 13px;
background-color: $tertiary-fg-color;
mask-repeat: no-repeat;
mask-size: contain;
}
}
}
}
}
.mx_CallEvent_content {
display: flex;
flex-direction: row;
align-items: center;
color: $secondary-fg-color;
margin-right: 16px;
.mx_CallEvent_content_button {
height: 24px;
padding: 0px 12px;
margin-left: 8px;
span {
padding: 8px 0;
display: flex;
align-items: center;
&::before {
content: '';
display: inline-block;
background-color: $button-fg-color;
mask-position: center;
mask-repeat: no-repeat;
mask-size: 16px;
width: 16px;
height: 16px;
margin-right: 8px;
}
}
}
.mx_CallEvent_content_button_reject span::before {
mask-image: url('$(res)/img/element-icons/call/hangup.svg');
}
.mx_CallEvent_content_tooltip {
margin-right: 5px;
}
.mx_CallEvent_iconButton {
display: inline-flex;
margin-right: 8px;
&::before {
content: '';
height: 16px;
width: 16px;
background-color: $tertiary-fg-color;
mask-repeat: no-repeat;
mask-size: contain;
mask-position: center;
}
}
.mx_CallEvent_silence::before {
mask-image: url('$(res)/img/voip/silence.svg');
}
.mx_CallEvent_unSilence::before {
mask-image: url('$(res)/img/voip/un-silence.svg');
}
}
}

View file

@ -18,7 +18,6 @@ $timelineImageBorderRadius: 4px;
.mx_MImageBody {
display: block;
margin-right: 34px;
}
.mx_MImageBody_thumbnail {
@ -29,6 +28,10 @@ $timelineImageBorderRadius: 4px;
top: 0;
border-radius: $timelineImageBorderRadius;
display: flex;
justify-content: center;
align-items: center;
> canvas {
border-radius: $timelineImageBorderRadius;
}
@ -43,17 +46,6 @@ $timelineImageBorderRadius: 4px;
position: relative;
}
.mx_MImageBody_thumbnail_spinner {
position: absolute;
left: 50%;
top: 50%;
}
// Inner img should be centered around 0, 0
.mx_MImageBody_thumbnail_spinner > * {
transform: translate(-50%, -50%);
}
.mx_MImageBody_gifLabel {
position: absolute;
display: block;

View file

@ -107,3 +107,12 @@ limitations under the License.
.mx_MessageActionBar_cancelButton::after {
mask-image: url('$(res)/img/element-icons/trashcan.svg');
}
.mx_MessageActionBar_downloadButton::after {
mask-size: 14px;
mask-image: url('$(res)/img/download.svg');
}
.mx_MessageActionBar_downloadButton.mx_MessageActionBar_downloadSpinnerButton::after {
background-color: transparent; // hide the download icon mask
}

View file

@ -26,6 +26,7 @@ limitations under the License.
height: 24px;
vertical-align: middle;
margin-left: 4px;
margin-right: 4px;
&::before {
content: '';

View file

@ -0,0 +1,323 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
.mx_EventTile[data-layout=bubble],
.mx_EventTile[data-layout=bubble] ~ .mx_EventListSummary {
--avatarSize: 32px;
--gutterSize: 11px;
--cornerRadius: 12px;
--maxWidth: 70%;
}
.mx_EventTile[data-layout=bubble] {
position: relative;
margin-top: var(--gutterSize);
margin-left: 50px;
margin-right: 100px;
&.mx_EventTile_continuation {
margin-top: 2px;
}
/* For replies */
.mx_EventTile {
padding-top: 0;
}
&:hover {
&::before {
content: '';
position: absolute;
top: -1px;
bottom: -1px;
left: -60px;
right: -60px;
z-index: -1;
background: $eventbubble-bg-hover;
border-radius: 4px;
}
.mx_EventTile_avatar {
img {
box-shadow: 0 0 0 3px $eventbubble-bg-hover;
}
}
}
.mx_SenderProfile,
.mx_EventTile_line {
width: fit-content;
max-width: 70%;
}
.mx_SenderProfile {
position: relative;
top: -2px;
left: 2px;
}
&[data-self=false] {
.mx_EventTile_line {
border-bottom-right-radius: var(--cornerRadius);
}
.mx_EventTile_avatar {
left: -34px;
}
.mx_MessageActionBar {
right: 0;
transform: translate3d(50%, 50%, 0);
}
--backgroundColor: $eventbubble-others-bg;
}
&[data-self=true] {
.mx_EventTile_line {
border-bottom-left-radius: var(--cornerRadius);
float: right;
> a {
left: auto;
right: -48px;
}
}
.mx_SenderProfile {
display: none;
}
.mx_ReactionsRow {
float: right;
clear: right;
display: flex;
/* Moving the "add reaction button" before the reactions */
> :last-child {
order: -1;
}
}
.mx_EventTile_avatar {
top: -19px; // height of the sender block
right: -35px;
}
--backgroundColor: $eventbubble-self-bg;
}
.mx_EventTile_line {
position: relative;
padding: var(--gutterSize);
border-top-left-radius: var(--cornerRadius);
border-top-right-radius: var(--cornerRadius);
background: var(--backgroundColor);
display: flex;
gap: 5px;
margin: 0 -12px 0 -9px;
> a {
position: absolute;
left: -48px;
}
}
&.mx_EventTile_continuation[data-self=false] .mx_EventTile_line {
border-top-left-radius: 0;
}
&.mx_EventTile_lastInSection[data-self=false] .mx_EventTile_line {
border-bottom-left-radius: var(--cornerRadius);
}
&.mx_EventTile_continuation[data-self=true] .mx_EventTile_line {
border-top-right-radius: 0;
}
&.mx_EventTile_lastInSection[data-self=true] .mx_EventTile_line {
border-bottom-right-radius: var(--cornerRadius);
}
.mx_EventTile_avatar {
position: absolute;
top: 0;
line-height: 1;
img {
box-shadow: 0 0 0 3px $eventbubble-avatar-outline;
border-radius: 50%;
}
}
&[data-has-reply=true] {
> .mx_EventTile_line {
flex-direction: column;
}
.mx_ReplyThread_show {
order: 99999;
}
.mx_ReplyThread {
margin: 0 calc(-1 * var(--gutterSize));
.mx_EventTile_reply {
max-width: 90%;
padding: 0;
> a {
display: none !important;
}
}
.mx_EventTile {
display: flex;
gap: var(--gutterSize);
.mx_EventTile_avatar {
position: static;
}
.mx_SenderProfile {
display: none;
}
}
}
}
.mx_EditMessageComposer_buttons {
position: static;
padding: 0;
margin: 0;
background: transparent;
}
.mx_ReactionsRow {
margin-right: -18px;
margin-left: -9px;
}
.mx_ReplyThread {
border-left-width: 2px;
border-left-color: $eventbubble-reply-color;
}
&.mx_EventTile_bubbleContainer,
&.mx_EventTile_info,
& ~ .mx_EventListSummary[data-expanded=false] {
--backgroundColor: transparent;
--gutterSize: 0;
display: flex;
align-items: center;
justify-content: center;
.mx_EventTile_avatar {
position: static;
order: -1;
margin-right: 5px;
}
}
& ~ .mx_EventListSummary {
--maxWidth: 80%;
margin-left: calc(var(--avatarSize) + var(--gutterSize));
margin-right: calc(var(--gutterSize) + var(--avatarSize));
.mx_EventListSummary_toggle {
float: none;
margin: 0;
order: 9;
margin-left: 5px;
}
.mx_EventListSummary_avatars {
padding-top: 0;
}
&::after {
content: "";
clear: both;
}
.mx_EventTile {
margin: 0 6px;
}
.mx_EventTile_line {
margin: 0 5px;
> a {
left: auto;
right: 0;
transform: translateX(calc(100% + 5px));
}
}
.mx_MessageActionBar {
transform: translate3d(50%, 0, 0);
}
}
& ~ .mx_EventListSummary[data-expanded=false] {
padding: 0 34px;
}
/* events that do not require bubble layout */
& ~ .mx_EventListSummary,
&.mx_EventTile_bad {
.mx_EventTile_line {
background: transparent;
}
&:hover {
&::before {
background: transparent;
}
}
}
& + .mx_EventListSummary {
.mx_EventTile {
margin-top: 0;
padding: 0;
}
}
.mx_EventListSummary_toggle {
margin-right: 55px;
}
/* Special layout scenario for "Unable To Decrypt (UTD)" events */
&.mx_EventTile_bad > .mx_EventTile_line {
display: grid;
grid-template:
"reply reply" auto
"shield body" auto
"shield link" auto
/ auto 1fr;
.mx_EventTile_e2eIcon {
grid-area: shield;
}
.mx_UnknownBody {
grid-area: body;
}
.mx_EventTile_keyRequestInfo {
grid-area: link;
}
.mx_ReplyThread_wrapper {
grid-area: reply;
}
}
.mx_EventTile_readAvatars {
position: absolute;
right: -110px;
bottom: 0;
top: auto;
}
.mx_MTextBody {
max-width: 100%;
}
}

View file

@ -1,6 +1,6 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2020 The Matrix.org Foundation C.I.C.
Copyright 2020-2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -18,15 +18,14 @@ limitations under the License.
$left-gutter: 64px;
$hover-select-border: 4px;
.mx_EventTile {
.mx_EventTile:not([data-layout=bubble]) {
max-width: 100%;
clear: both;
padding-top: 18px;
font-size: $font-14px;
position: relative;
}
.mx_EventTile.mx_EventTile_info {
&.mx_EventTile_info {
padding-top: 1px;
}
@ -37,12 +36,12 @@ $hover-select-border: 4px;
user-select: none;
}
.mx_EventTile.mx_EventTile_info .mx_EventTile_avatar {
&.mx_EventTile_info .mx_EventTile_avatar {
top: $font-6px;
left: $left-gutter;
}
.mx_EventTile_continuation {
&.mx_EventTile_continuation {
padding-top: 0px !important;
&.mx_EventTile_isEditing {
@ -51,11 +50,11 @@ $hover-select-border: 4px;
}
}
.mx_EventTile_isEditing {
&.mx_EventTile_isEditing {
background-color: $header-panel-bg-color;
}
.mx_EventTile .mx_SenderProfile {
.mx_SenderProfile {
color: $primary-fg-color;
font-size: $font-14px;
display: inline-block; /* anti-zalgo, with overflow hidden */
@ -70,7 +69,7 @@ $hover-select-border: 4px;
max-width: calc(100% - $left-gutter);
}
.mx_EventTile .mx_SenderProfile .mx_Flair {
.mx_SenderProfile .mx_Flair {
opacity: 0.7;
margin-left: 5px;
display: inline-block;
@ -85,11 +84,11 @@ $hover-select-border: 4px;
}
}
.mx_EventTile_isEditing .mx_MessageTimestamp {
&.mx_EventTile_isEditing .mx_MessageTimestamp {
visibility: hidden;
}
.mx_EventTile .mx_MessageTimestamp {
.mx_MessageTimestamp {
display: block;
white-space: nowrap;
left: 0px;
@ -97,7 +96,7 @@ $hover-select-border: 4px;
user-select: none;
}
.mx_EventTile_continuation .mx_EventTile_line {
&.mx_EventTile_continuation .mx_EventTile_line {
clear: both;
}
@ -107,63 +106,25 @@ $hover-select-border: 4px;
border-radius: 8px;
}
.mx_RoomView_timeline_rr_enabled,
// on ELS we need the margin to allow interaction with the expand/collapse button which is normally in the RR gutter
.mx_EventListSummary {
.mx_EventTile_line {
/* ideally should be 100px, but 95px gives us a max thumbnail size of 800x600, which is nice */
margin-right: 110px;
}
}
.mx_EventTile_bubbleContainer {
display: grid;
grid-template-columns: 1fr 100px;
.mx_EventTile_line {
margin-right: 0;
grid-column: 1 / 3;
// override default padding of mx_EventTile_line so that we can be centered
padding: 0 !important;
}
.mx_EventTile_msgOption {
grid-column: 2;
}
}
.mx_EventTile_reply {
margin-right: 10px;
}
/* HACK to override line-height which is already marked important elsewhere */
.mx_EventTile_bigEmoji.mx_EventTile_bigEmoji {
font-size: 48px !important;
line-height: 57px !important;
}
.mx_EventTile_selected > div > a > .mx_MessageTimestamp {
&.mx_EventTile_selected > div > a > .mx_MessageTimestamp {
left: calc(-$hover-select-border);
}
.mx_EventTile:hover .mx_MessageActionBar,
.mx_EventTile.mx_EventTile_actionBarFocused .mx_MessageActionBar,
[data-whatinput='keyboard'] .mx_EventTile:focus-within .mx_MessageActionBar,
.mx_EventTile.focus-visible:focus-within .mx_MessageActionBar {
visibility: visible;
}
/* this is used for the tile for the event which is selected via the URL.
* TODO: ultimately we probably want some transition on here.
*/
.mx_EventTile_selected > .mx_EventTile_line {
&.mx_EventTile_selected > .mx_EventTile_line {
border-left: $accent-color 4px solid;
padding-left: calc($left-gutter - $hover-select-border);
background-color: $event-selected-color;
}
.mx_EventTile_highlight,
.mx_EventTile_highlight .markdown-body {
&.mx_EventTile_highlight,
&.mx_EventTile_highlight .markdown-body {
color: $event-highlight-fg-color;
.mx_EventTile_line {
@ -171,17 +132,17 @@ $hover-select-border: 4px;
}
}
.mx_EventTile_info .mx_EventTile_line {
&.mx_EventTile_info .mx_EventTile_line {
padding-left: calc($left-gutter + 18px);
}
.mx_EventTile_selected.mx_EventTile_info .mx_EventTile_line {
&.mx_EventTile_selected.mx_EventTile_info .mx_EventTile_line {
padding-left: calc($left-gutter + 18px - $hover-select-border);
}
.mx_EventTile:hover .mx_EventTile_line,
.mx_EventTile.mx_EventTile_actionBarFocused .mx_EventTile_line,
.mx_EventTile.focus-visible:focus-within .mx_EventTile_line {
&.mx_EventTile:hover .mx_EventTile_line,
&.mx_EventTile.mx_EventTile_actionBarFocused .mx_EventTile_line,
&.mx_EventTile.focus-visible:focus-within .mx_EventTile_line {
background-color: $event-selected-color;
}
@ -225,7 +186,7 @@ $hover-select-border: 4px;
mask-image: url('$(res)/img/element-icons/circle-sending.svg');
}
.mx_EventTile_contextual {
&.mx_EventTile_contextual {
opacity: 0.4;
}
@ -247,36 +208,6 @@ $hover-select-border: 4px;
text-decoration: none;
}
.mx_EventTile_readAvatars {
position: relative;
display: inline-block;
width: 14px;
height: 14px;
// This aligns the avatar with the last line of the
// message. We want to move it one line up - 2.2rem
top: -2.2rem;
user-select: none;
z-index: 1;
}
.mx_EventTile_readAvatars .mx_BaseAvatar {
position: absolute;
display: inline-block;
height: $font-14px;
width: $font-14px;
will-change: left, top;
transition:
left var(--transition-short) ease-out,
top var(--transition-standard) ease-out;
}
.mx_EventTile_readAvatarRemainder {
color: $event-timestamp-color;
font-size: $font-11px;
position: absolute;
}
/* 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
@ -314,15 +245,154 @@ $hover-select-border: 4px;
filter: none;
}
&:hover.mx_EventTile_verified .mx_EventTile_line,
&:hover.mx_EventTile_unverified .mx_EventTile_line,
&:hover.mx_EventTile_unknown .mx_EventTile_line {
padding-left: calc($left-gutter - $hover-select-border);
}
&:hover.mx_EventTile_verified .mx_EventTile_line {
border-left: $e2e-verified-color $EventTile_e2e_state_indicator_width solid;
}
&:hover.mx_EventTile_unverified .mx_EventTile_line {
border-left: $e2e-unverified-color $EventTile_e2e_state_indicator_width solid;
}
&:hover.mx_EventTile_unknown .mx_EventTile_line {
border-left: $e2e-unknown-color $EventTile_e2e_state_indicator_width solid;
}
&:hover.mx_EventTile_verified.mx_EventTile_info .mx_EventTile_line,
&:hover.mx_EventTile_unverified.mx_EventTile_info .mx_EventTile_line,
&:hover.mx_EventTile_unknown.mx_EventTile_info .mx_EventTile_line {
padding-left: calc($left-gutter + 18px - $hover-select-border);
}
/* End to end encryption stuff */
&:hover .mx_EventTile_e2eIcon {
opacity: 1;
}
// Explicit relationships so that it doesn't apply to nested EventTile components (e.g in Replies)
&:hover.mx_EventTile_verified .mx_EventTile_line > a > .mx_MessageTimestamp,
&:hover.mx_EventTile_unverified .mx_EventTile_line > a > .mx_MessageTimestamp,
&:hover.mx_EventTile_unknown .mx_EventTile_line > a > .mx_MessageTimestamp {
left: calc(-$hover-select-border);
}
// Explicit relationships so that it doesn't apply to nested EventTile components (e.g in Replies)
&:hover.mx_EventTile_verified .mx_EventTile_line > .mx_EventTile_e2eIcon,
&:hover.mx_EventTile_unverified .mx_EventTile_line > .mx_EventTile_e2eIcon,
&:hover.mx_EventTile_unknown .mx_EventTile_line > .mx_EventTile_e2eIcon {
display: block;
left: 41px;
}
.mx_MImageBody {
margin-right: 34px;
}
.mx_EventTile_e2eIcon {
position: absolute;
top: 6px;
left: 44px;
bottom: 0;
right: 0;
}
.mx_ReactionsRow {
margin: 0;
padding: 6px 60px;
}
}
.mx_RoomView_timeline_rr_enabled {
.mx_EventTile:not([data-layout=bubble]) {
.mx_EventTile_line {
/* ideally should be 100px, but 95px gives us a max thumbnail size of 800x600, which is nice */
margin-right: 110px;
}
}
// on ELS we need the margin to allow interaction with the expand/collapse button which is normally in the RR gutter
}
.mx_EventTile_bubbleContainer {
display: grid;
grid-template-columns: 1fr 100px;
.mx_EventTile_line {
margin-right: 0;
grid-column: 1 / 3;
// override default padding of mx_EventTile_line so that we can be centered
padding: 0 !important;
}
.mx_EventTile_msgOption {
grid-column: 2;
}
&:hover {
.mx_EventTile_line {
// To avoid bubble events being highlighted
background-color: inherit !important;
}
}
}
.mx_EventTile_readAvatars {
position: relative;
display: inline-block;
width: 14px;
height: 14px;
// This aligns the avatar with the last line of the
// message. We want to move it one line up - 2.2rem
top: -2.2rem;
user-select: none;
z-index: 1;
}
.mx_EventTile_readAvatars .mx_BaseAvatar {
position: absolute;
display: inline-block;
height: $font-14px;
width: $font-14px;
will-change: left, top;
transition:
left var(--transition-short) ease-out,
top var(--transition-standard) ease-out;
}
.mx_EventTile_readAvatarRemainder {
color: $event-timestamp-color;
font-size: $font-11px;
position: absolute;
}
/* HACK to override line-height which is already marked important elsewhere */
.mx_EventTile_bigEmoji.mx_EventTile_bigEmoji {
font-size: 48px !important;
line-height: 57px !important;
}
.mx_EventTile_content .mx_EventTile_edited {
user-select: none;
font-size: $font-12px;
color: $roomtopic-color;
display: inline-block;
margin-left: 9px;
cursor: pointer;
}
.mx_EventTile_e2eIcon {
position: relative;
width: 14px;
height: 14px;
display: block;
bottom: 0;
right: 0;
opacity: 0.2;
background-repeat: no-repeat;
background-size: contain;
@ -381,87 +451,6 @@ $hover-select-border: 4px;
opacity: 1;
}
.mx_EventTile_keyRequestInfo {
font-size: $font-12px;
}
.mx_EventTile_keyRequestInfo_text {
opacity: 0.5;
}
.mx_EventTile_keyRequestInfo_text a {
color: $primary-fg-color;
text-decoration: underline;
cursor: pointer;
}
.mx_EventTile_keyRequestInfo_tooltip_contents p {
text-align: auto;
margin-left: 3px;
margin-right: 3px;
}
.mx_EventTile_keyRequestInfo_tooltip_contents p:first-child {
margin-top: 0px;
}
.mx_EventTile_keyRequestInfo_tooltip_contents p:last-child {
margin-bottom: 0px;
}
.mx_EventTile:hover.mx_EventTile_verified .mx_EventTile_line,
.mx_EventTile:hover.mx_EventTile_unverified .mx_EventTile_line,
.mx_EventTile:hover.mx_EventTile_unknown .mx_EventTile_line {
padding-left: calc($left-gutter - $hover-select-border);
}
.mx_EventTile:hover.mx_EventTile_verified .mx_EventTile_line {
border-left: $e2e-verified-color $EventTile_e2e_state_indicator_width solid;
}
.mx_EventTile:hover.mx_EventTile_unverified .mx_EventTile_line {
border-left: $e2e-unverified-color $EventTile_e2e_state_indicator_width solid;
}
.mx_EventTile:hover.mx_EventTile_unknown .mx_EventTile_line {
border-left: $e2e-unknown-color $EventTile_e2e_state_indicator_width solid;
}
.mx_EventTile:hover.mx_EventTile_verified.mx_EventTile_info .mx_EventTile_line,
.mx_EventTile:hover.mx_EventTile_unverified.mx_EventTile_info .mx_EventTile_line,
.mx_EventTile:hover.mx_EventTile_unknown.mx_EventTile_info .mx_EventTile_line {
padding-left: calc($left-gutter + 18px - $hover-select-border);
}
/* End to end encryption stuff */
.mx_EventTile:hover .mx_EventTile_e2eIcon {
opacity: 1;
}
// Explicit relationships so that it doesn't apply to nested EventTile components (e.g in Replies)
.mx_EventTile:hover.mx_EventTile_verified .mx_EventTile_line > a > .mx_MessageTimestamp,
.mx_EventTile:hover.mx_EventTile_unverified .mx_EventTile_line > a > .mx_MessageTimestamp,
.mx_EventTile:hover.mx_EventTile_unknown .mx_EventTile_line > a > .mx_MessageTimestamp {
left: calc(-$hover-select-border);
}
// Explicit relationships so that it doesn't apply to nested EventTile components (e.g in Replies)
.mx_EventTile:hover.mx_EventTile_verified .mx_EventTile_line > .mx_EventTile_e2eIcon,
.mx_EventTile:hover.mx_EventTile_unverified .mx_EventTile_line > .mx_EventTile_e2eIcon,
.mx_EventTile:hover.mx_EventTile_unknown .mx_EventTile_line > .mx_EventTile_e2eIcon {
display: block;
left: 41px;
}
.mx_EventTile_content .mx_EventTile_edited {
user-select: none;
font-size: $font-12px;
color: $roomtopic-color;
display: inline-block;
margin-left: 9px;
cursor: pointer;
}
/* Various markdown overrides */
.mx_EventTile_body pre {
@ -595,6 +584,35 @@ $hover-select-border: 4px;
/* end of overrides */
.mx_EventTile_keyRequestInfo {
font-size: $font-12px;
}
.mx_EventTile_keyRequestInfo_text {
opacity: 0.5;
}
.mx_EventTile_keyRequestInfo_text a {
color: $primary-fg-color;
text-decoration: underline;
cursor: pointer;
}
.mx_EventTile_keyRequestInfo_tooltip_contents p {
text-align: auto;
margin-left: 3px;
margin-right: 3px;
}
.mx_EventTile_keyRequestInfo_tooltip_contents p:first-child {
margin-top: 0px;
}
.mx_EventTile_keyRequestInfo_tooltip_contents p:last-child {
margin-bottom: 0px;
}
.mx_EventTile_tileError {
color: red;
text-align: center;
@ -615,6 +633,13 @@ $hover-select-border: 4px;
}
}
.mx_EventTile:hover .mx_MessageActionBar,
.mx_EventTile.mx_EventTile_actionBarFocused .mx_MessageActionBar,
[data-whatinput='keyboard'] .mx_EventTile:focus-within .mx_MessageActionBar,
.mx_EventTile.focus-visible:focus-within .mx_MessageActionBar {
visibility: visible;
}
@media only screen and (max-width: 480px) {
.mx_EventTile_line, .mx_EventTile_reply {
padding-left: 0;

View file

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M8 16C12.4183 16 16 12.4183 16 8C16 3.58172 12.4183 0 8 0C3.58172 0 0 3.58172 0 8C0 12.4183 3.58172 16 8 16ZM6.9806 4.5101C6.9306 3.9401 7.3506 3.4401 7.9206 3.4001C8.4806 3.3601 8.9806 3.7801 9.0406 4.3501V4.5101L8.7206 8.5101C8.6906 8.8801 8.3806 9.1601 8.0106 9.1601H7.9506C7.6006 9.1301 7.3306 8.8601 7.3006 8.5101L6.9806 4.5101ZM8.88012 11.1202C8.88012 11.6062 8.48613 12.0002 8.00012 12.0002C7.51411 12.0002 7.12012 11.6062 7.12012 11.1202C7.12012 10.6342 7.51411 10.2402 8.00012 10.2402C8.48613 10.2402 8.88012 10.6342 8.88012 11.1202Z" fill="#8D99A5"/>
</svg>

After

Width:  |  Height:  |  Size: 713 B

View file

@ -227,6 +227,13 @@ $groupFilterPanel-background-blur-amount: 30px;
$composer-shadow-color: rgba(0, 0, 0, 0.28);
// Bubble tiles
$eventbubble-self-bg: #143A34;
$eventbubble-others-bg: #394049;
$eventbubble-bg-hover: #433C23;
$eventbubble-avatar-outline: $bg-color;
$eventbubble-reply-color: #C1C6CD;
// ***** Mixins! *****
@define-mixin mx_DialogButton {

View file

@ -347,6 +347,13 @@ $appearance-tab-border-color: $input-darker-bg-color;
$composer-shadow-color: tranparent;
// Bubble tiles
$eventbubble-self-bg: #F8FDFC;
$eventbubble-others-bg: #F7F8F9;
$eventbubble-bg-hover: rgb(242, 242, 242);
$eventbubble-avatar-outline: #fff;
$eventbubble-reply-color: #C1C6CD;
// ***** Mixins! *****
@define-mixin mx_DialogButton {

View file

@ -349,6 +349,13 @@ $groupFilterPanel-background-blur-amount: 20px;
$composer-shadow-color: rgba(0, 0, 0, 0.04);
// Bubble tiles
$eventbubble-self-bg: #F8FDFC;
$eventbubble-others-bg: #F7F8F9;
$eventbubble-bg-hover: #FEFCF5;
$eventbubble-avatar-outline: $primary-bg-color;
$eventbubble-reply-color: #C1C6CD;
// ***** Mixins! *****
@define-mixin mx_DialogButton {

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { JSXElementConstructor } from "react";
import React, { JSXElementConstructor } from "react";
// Based on https://stackoverflow.com/a/53229857/3532235
export type Without<T, U> = {[P in Exclude<keyof T, keyof U>]?: never};
@ -22,3 +22,4 @@ export type XOR<T, U> = (T | U) extends object ? (Without<T, U> & U) | (Without<
export type Writeable<T> = { -readonly [P in keyof T]: T[P] };
export type ComponentClass = keyof JSX.IntrinsicElements | JSXElementConstructor<any>;
export type ReactAnyComponent = React.Component | React.ExoticComponent;

View file

@ -50,6 +50,8 @@ import UIStore from "../stores/UIStore";
import { SetupEncryptionStore } from "../stores/SetupEncryptionStore";
import { RoomScrollStateStore } from "../stores/RoomScrollStateStore";
/* eslint-disable @typescript-eslint/naming-convention */
declare global {
interface Window {
matrixChat: ReturnType<Renderer>;
@ -91,8 +93,7 @@ declare global {
mxSetupEncryptionStore?: SetupEncryptionStore;
mxRoomScrollStateStore?: RoomScrollStateStore;
grecaptcha: any;
// eslint-disable-next-line
mx_on_recaptcha_loaded: () => void;
mxOnRecaptchaLoaded?: () => void;
}
interface Document {
@ -188,4 +189,21 @@ declare global {
parameterDescriptors?: AudioParamDescriptor[];
}
);
// eslint-disable-next-line no-var
var grecaptcha:
| undefined
| {
reset: (id: string) => void;
render: (
divId: string,
options: {
sitekey: string;
callback: (response: string) => void;
},
) => string;
isReady: () => boolean;
};
}
/* eslint-enable @typescript-eslint/naming-convention */

View file

@ -270,7 +270,7 @@ export class Analytics {
localStorage.removeItem(LAST_VISIT_TS_KEY);
}
private async _track(data: IData) {
private async track(data: IData) {
if (this.disabled) return;
const now = new Date();
@ -304,7 +304,7 @@ export class Analytics {
}
public ping() {
this._track({
this.track({
ping: "1",
});
localStorage.setItem(LAST_VISIT_TS_KEY, String(new Date().getTime())); // update last visit ts
@ -324,14 +324,14 @@ export class Analytics {
// But continue anyway because we still want to track the change
}
this._track({
this.track({
gt_ms: String(generationTimeMs),
});
}
public trackEvent(category: string, action: string, name?: string, value?: string) {
if (this.disabled) return;
this._track({
this.track({
e_c: category,
e_a: action,
e_n: name,

View file

@ -99,7 +99,7 @@ const CHECK_PROTOCOLS_ATTEMPTS = 3;
// (and store the ID of their native room)
export const VIRTUAL_ROOM_EVENT_TYPE = 'im.vector.is_virtual_room';
export enum AudioID {
enum AudioID {
Ring = 'ringAudio',
Ringback = 'ringbackAudio',
CallEnd = 'callendAudio',
@ -142,6 +142,7 @@ export enum PlaceCallType {
export enum CallHandlerEvent {
CallsChanged = "calls_changed",
CallChangeRoom = "call_change_room",
SilencedCallsChanged = "silenced_calls_changed",
}
export default class CallHandler extends EventEmitter {
@ -164,6 +165,8 @@ export default class CallHandler extends EventEmitter {
// do the async lookup when we get new information and then store these mappings here
private assertedIdentityNativeUsers = new Map<string, string>();
private silencedCalls = new Set<string>(); // callIds
static sharedInstance() {
if (!window.mxCallHandler) {
window.mxCallHandler = new CallHandler();
@ -224,6 +227,33 @@ export default class CallHandler extends EventEmitter {
}
}
public silenceCall(callId: string) {
this.silencedCalls.add(callId);
this.emit(CallHandlerEvent.SilencedCallsChanged, this.silencedCalls);
// Don't pause audio if we have calls which are still ringing
if (this.areAnyCallsUnsilenced()) return;
this.pause(AudioID.Ring);
}
public unSilenceCall(callId: string) {
this.silencedCalls.delete(callId);
this.emit(CallHandlerEvent.SilencedCallsChanged, this.silencedCalls);
this.play(AudioID.Ring);
}
public isCallSilenced(callId: string): boolean {
return this.silencedCalls.has(callId);
}
/**
* Returns true if there is at least one unsilenced call
* @returns {boolean}
*/
private areAnyCallsUnsilenced(): boolean {
return this.calls.size > this.silencedCalls.size;
}
private async checkProtocols(maxTries) {
try {
const protocols = await MatrixClientPeg.get().getThirdpartyProtocols();
@ -301,6 +331,13 @@ export default class CallHandler extends EventEmitter {
}, true);
};
public getCallById(callId: string): MatrixCall {
for (const call of this.calls.values()) {
if (call.callId === callId) return call;
}
return null;
}
getCallForRoom(roomId: string): MatrixCall {
return this.calls.get(roomId) || null;
}
@ -441,6 +478,10 @@ export default class CallHandler extends EventEmitter {
break;
}
if (newState !== CallState.Ringing) {
this.silencedCalls.delete(call.callId);
}
switch (newState) {
case CallState.Ringing:
this.play(AudioID.Ring);

View file

@ -33,6 +33,7 @@ import { isSecretStorageBeingAccessed, accessSecretStorage } from "./SecurityMan
import { isSecureBackupRequired } from './utils/WellKnownUtils';
import { isLoggedIn } from './components/structures/MatrixChat';
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { ActionPayload } from "./dispatcher/payloads";
const KEY_BACKUP_POLL_INTERVAL = 5 * 60 * 1000;
@ -58,28 +59,28 @@ export default class DeviceListener {
}
start() {
MatrixClientPeg.get().on('crypto.willUpdateDevices', this._onWillUpdateDevices);
MatrixClientPeg.get().on('crypto.devicesUpdated', this._onDevicesUpdated);
MatrixClientPeg.get().on('deviceVerificationChanged', this._onDeviceVerificationChanged);
MatrixClientPeg.get().on('userTrustStatusChanged', this._onUserTrustStatusChanged);
MatrixClientPeg.get().on('crossSigning.keysChanged', this._onCrossSingingKeysChanged);
MatrixClientPeg.get().on('accountData', this._onAccountData);
MatrixClientPeg.get().on('sync', this._onSync);
MatrixClientPeg.get().on('RoomState.events', this._onRoomStateEvents);
this.dispatcherRef = dis.register(this._onAction);
this._recheck();
MatrixClientPeg.get().on('crypto.willUpdateDevices', this.onWillUpdateDevices);
MatrixClientPeg.get().on('crypto.devicesUpdated', this.onDevicesUpdated);
MatrixClientPeg.get().on('deviceVerificationChanged', this.onDeviceVerificationChanged);
MatrixClientPeg.get().on('userTrustStatusChanged', this.onUserTrustStatusChanged);
MatrixClientPeg.get().on('crossSigning.keysChanged', this.onCrossSingingKeysChanged);
MatrixClientPeg.get().on('accountData', this.onAccountData);
MatrixClientPeg.get().on('sync', this.onSync);
MatrixClientPeg.get().on('RoomState.events', this.onRoomStateEvents);
this.dispatcherRef = dis.register(this.onAction);
this.recheck();
}
stop() {
if (MatrixClientPeg.get()) {
MatrixClientPeg.get().removeListener('crypto.willUpdateDevices', this._onWillUpdateDevices);
MatrixClientPeg.get().removeListener('crypto.devicesUpdated', this._onDevicesUpdated);
MatrixClientPeg.get().removeListener('deviceVerificationChanged', this._onDeviceVerificationChanged);
MatrixClientPeg.get().removeListener('userTrustStatusChanged', this._onUserTrustStatusChanged);
MatrixClientPeg.get().removeListener('crossSigning.keysChanged', this._onCrossSingingKeysChanged);
MatrixClientPeg.get().removeListener('accountData', this._onAccountData);
MatrixClientPeg.get().removeListener('sync', this._onSync);
MatrixClientPeg.get().removeListener('RoomState.events', this._onRoomStateEvents);
MatrixClientPeg.get().removeListener('crypto.willUpdateDevices', this.onWillUpdateDevices);
MatrixClientPeg.get().removeListener('crypto.devicesUpdated', this.onDevicesUpdated);
MatrixClientPeg.get().removeListener('deviceVerificationChanged', this.onDeviceVerificationChanged);
MatrixClientPeg.get().removeListener('userTrustStatusChanged', this.onUserTrustStatusChanged);
MatrixClientPeg.get().removeListener('crossSigning.keysChanged', this.onCrossSingingKeysChanged);
MatrixClientPeg.get().removeListener('accountData', this.onAccountData);
MatrixClientPeg.get().removeListener('sync', this.onSync);
MatrixClientPeg.get().removeListener('RoomState.events', this.onRoomStateEvents);
}
if (this.dispatcherRef) {
dis.unregister(this.dispatcherRef);
@ -103,15 +104,15 @@ export default class DeviceListener {
this.dismissed.add(d);
}
this._recheck();
this.recheck();
}
dismissEncryptionSetup() {
this.dismissedThisDeviceToast = true;
this._recheck();
this.recheck();
}
_ensureDeviceIdsAtStartPopulated() {
private ensureDeviceIdsAtStartPopulated() {
if (this.ourDeviceIdsAtStart === null) {
const cli = MatrixClientPeg.get();
this.ourDeviceIdsAtStart = new Set(
@ -120,39 +121,39 @@ export default class DeviceListener {
}
}
_onWillUpdateDevices = async (users: string[], initialFetch?: boolean) => {
private onWillUpdateDevices = async (users: string[], initialFetch?: boolean) => {
// If we didn't know about *any* devices before (ie. it's fresh login),
// then they are all pre-existing devices, so ignore this and set the
// devicesAtStart list to the devices that we see after the fetch.
if (initialFetch) return;
const myUserId = MatrixClientPeg.get().getUserId();
if (users.includes(myUserId)) this._ensureDeviceIdsAtStartPopulated();
if (users.includes(myUserId)) this.ensureDeviceIdsAtStartPopulated();
// No need to do a recheck here: we just need to get a snapshot of our devices
// before we download any new ones.
};
_onDevicesUpdated = (users: string[]) => {
private onDevicesUpdated = (users: string[]) => {
if (!users.includes(MatrixClientPeg.get().getUserId())) return;
this._recheck();
this.recheck();
};
_onDeviceVerificationChanged = (userId: string) => {
private onDeviceVerificationChanged = (userId: string) => {
if (userId !== MatrixClientPeg.get().getUserId()) return;
this._recheck();
this.recheck();
};
_onUserTrustStatusChanged = (userId: string) => {
private onUserTrustStatusChanged = (userId: string) => {
if (userId !== MatrixClientPeg.get().getUserId()) return;
this._recheck();
this.recheck();
};
_onCrossSingingKeysChanged = () => {
this._recheck();
private onCrossSingingKeysChanged = () => {
this.recheck();
};
_onAccountData = (ev) => {
private onAccountData = (ev: MatrixEvent) => {
// User may have:
// * migrated SSSS to symmetric
// * uploaded keys to secret storage
@ -163,32 +164,32 @@ export default class DeviceListener {
ev.getType().startsWith('m.cross_signing.') ||
ev.getType() === 'm.megolm_backup.v1'
) {
this._recheck();
this.recheck();
}
};
_onSync = (state, prevState) => {
if (state === 'PREPARED' && prevState === null) this._recheck();
private onSync = (state, prevState) => {
if (state === 'PREPARED' && prevState === null) this.recheck();
};
_onRoomStateEvents = (ev: MatrixEvent) => {
private onRoomStateEvents = (ev: MatrixEvent) => {
if (ev.getType() !== "m.room.encryption") {
return;
}
// If a room changes to encrypted, re-check as it may be our first
// encrypted room. This also catches encrypted room creation as well.
this._recheck();
this.recheck();
};
_onAction = ({ action }) => {
private onAction = ({ action }: ActionPayload) => {
if (action !== "on_logged_in") return;
this._recheck();
this.recheck();
};
// The server doesn't tell us when key backup is set up, so we poll
// & cache the result
async _getKeyBackupInfo() {
private async getKeyBackupInfo() {
const now = (new Date()).getTime();
if (!this.keyBackupInfo || this.keyBackupFetchedAt < now - KEY_BACKUP_POLL_INTERVAL) {
this.keyBackupInfo = await MatrixClientPeg.get().getKeyBackupVersion();
@ -206,7 +207,7 @@ export default class DeviceListener {
return cli && cli.getRooms().some(r => cli.isRoomEncrypted(r.roomId));
}
async _recheck() {
private async recheck() {
const cli = MatrixClientPeg.get();
if (!await cli.doesServerSupportUnstableFeature("org.matrix.e2e_cross_signing")) return;
@ -235,7 +236,7 @@ export default class DeviceListener {
// Cross-signing on account but this device doesn't trust the master key (verify this session)
showSetupEncryptionToast(SetupKind.VERIFY_THIS_SESSION);
} else {
const backupInfo = await this._getKeyBackupInfo();
const backupInfo = await this.getKeyBackupInfo();
if (backupInfo) {
// No cross-signing on account but key backup available (upgrade encryption)
showSetupEncryptionToast(SetupKind.UPGRADE_ENCRYPTION);
@ -256,7 +257,7 @@ export default class DeviceListener {
// This needs to be done after awaiting on downloadKeys() above, so
// we make sure we get the devices after the fetch is done.
this._ensureDeviceIdsAtStartPopulated();
this.ensureDeviceIdsAtStartPopulated();
// Unverified devices that were there last time the app ran
// (technically could just be a boolean: we don't actually

View file

@ -33,7 +33,7 @@ import { IExtendedSanitizeOptions } from './@types/sanitize-html';
import linkifyMatrix from './linkify-matrix';
import SettingsStore from './settings/SettingsStore';
import { tryTransformPermalinkToLocalHref } from "./utils/permalinks/Permalinks";
import { SHORTCODE_TO_EMOJI, getEmojiFromUnicode } from "./emoji";
import { getEmojiFromUnicode } from "./emoji";
import ReplyThread from "./components/views/elements/ReplyThread";
import { mediaFromMxc } from "./customisations/Media";
@ -79,20 +79,8 @@ function mightContainEmoji(str: string): boolean {
* @return {String} The shortcode (such as :thumbup:)
*/
export function unicodeToShortcode(char: string): string {
const data = getEmojiFromUnicode(char);
return (data && data.shortcodes ? `:${data.shortcodes[0]}:` : '');
}
/**
* Returns the unicode character for an emoji shortcode
*
* @param {String} shortcode The shortcode (such as :thumbup:)
* @return {String} The emoji character; null if none exists
*/
export function shortcodeToUnicode(shortcode: string): string {
shortcode = shortcode.slice(1, shortcode.length - 1);
const data = SHORTCODE_TO_EMOJI.get(shortcode);
return data ? data.unicode : null;
const shortcodes = getEmojiFromUnicode(char).shortcodes;
return shortcodes.length > 0 ? `:${shortcodes[0]}:` : '';
}
export function processHtmlForSending(html: string): string {

View file

@ -105,7 +105,7 @@ export interface IMatrixClientPeg {
* This module provides a singleton instance of this class so the 'current'
* Matrix Client object is available easily.
*/
class _MatrixClientPeg implements IMatrixClientPeg {
class MatrixClientPegClass implements IMatrixClientPeg {
// These are the default options used when when the
// client is started in 'start'. These can be altered
// at any time up to after the 'will_start_client'
@ -300,7 +300,7 @@ class _MatrixClientPeg implements IMatrixClientPeg {
}
if (!window.mxMatrixClientPeg) {
window.mxMatrixClientPeg = new _MatrixClientPeg();
window.mxMatrixClientPeg = new MatrixClientPegClass();
}
export const MatrixClientPeg = window.mxMatrixClientPeg;

View file

@ -522,7 +522,7 @@ export const Commands = [
aliases: ['j', 'goto'],
args: '<room-address>',
description: _td('Joins room with given address'),
runFn: function(_, args) {
runFn: function(roomId, args) {
if (args) {
// Note: we support 2 versions of this command. The first is
// the public-facing one for most users and the other is a
@ -1069,7 +1069,7 @@ export const Commands = [
command: "msg",
description: _td("Sends a message to the given user"),
args: "<user-id> <message>",
runFn: function(_, args) {
runFn: function(roomId, args) {
if (args) {
// matches the first whitespace delimited group and then the rest of the string
const matches = args.match(/^(\S+?)(?: +(.*))?$/s);

View file

@ -14,7 +14,6 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import { MatrixClientPeg } from './MatrixClientPeg';
import { _t } from './languageHandler';
import * as Roles from './Roles';
import { isValid3pidInvite } from "./RoomInvite";
@ -318,90 +317,6 @@ function textForCanonicalAliasEvent(ev: MatrixEvent): () => string | null {
});
}
function textForCallAnswerEvent(event: MatrixEvent): () => string | null {
return () => {
const senderName = event.sender ? event.sender.name : _t('Someone');
const supported = MatrixClientPeg.get().supportsVoip() ? '' : _t('(not supported by this browser)');
return _t('%(senderName)s answered the call.', { senderName }) + ' ' + supported;
};
}
function textForCallHangupEvent(event: MatrixEvent): () => string | null {
const getSenderName = () => event.sender ? event.sender.name : _t('Someone');
const eventContent = event.getContent();
let getReason = () => "";
if (!MatrixClientPeg.get().supportsVoip()) {
getReason = () => _t('(not supported by this browser)');
} else if (eventContent.reason) {
if (eventContent.reason === "ice_failed") {
// We couldn't establish a connection at all
getReason = () => _t('(could not connect media)');
} else if (eventContent.reason === "ice_timeout") {
// We established a connection but it died
getReason = () => _t('(connection failed)');
} else if (eventContent.reason === "user_media_failed") {
// The other side couldn't open capture devices
getReason = () => _t("(their device couldn't start the camera / microphone)");
} else if (eventContent.reason === "unknown_error") {
// An error code the other side doesn't have a way to express
// (as opposed to an error code they gave but we don't know about,
// in which case we show the error code)
getReason = () => _t("(an error occurred)");
} else if (eventContent.reason === "invite_timeout") {
getReason = () => _t('(no answer)');
} else if (eventContent.reason === "user hangup" || eventContent.reason === "user_hangup") {
// workaround for https://github.com/vector-im/element-web/issues/5178
// it seems Android randomly sets a reason of "user hangup" which is
// interpreted as an error code :(
// https://github.com/vector-im/riot-android/issues/2623
// Also the correct hangup code as of VoIP v1 (with underscore)
getReason = () => '';
} else {
getReason = () => _t('(unknown failure: %(reason)s)', { reason: eventContent.reason });
}
}
return () => _t('%(senderName)s ended the call.', { senderName: getSenderName() }) + ' ' + getReason();
}
function textForCallRejectEvent(event: MatrixEvent): () => string | null {
return () => {
const senderName = event.sender ? event.sender.name : _t('Someone');
return _t('%(senderName)s declined the call.', { senderName });
};
}
function textForCallInviteEvent(event: MatrixEvent): () => string | null {
const getSenderName = () => event.sender ? event.sender.name : _t('Someone');
// FIXME: Find a better way to determine this from the event?
let isVoice = true;
if (event.getContent().offer && event.getContent().offer.sdp &&
event.getContent().offer.sdp.indexOf('m=video') !== -1) {
isVoice = false;
}
const isSupported = MatrixClientPeg.get().supportsVoip();
// This ladder could be reduced down to a couple string variables, however other languages
// can have a hard time translating those strings. In an effort to make translations easier
// and more accurate, we break out the string-based variables to a couple booleans.
if (isVoice && isSupported) {
return () => _t("%(senderName)s placed a voice call.", {
senderName: getSenderName(),
});
} else if (isVoice && !isSupported) {
return () => _t("%(senderName)s placed a voice call. (not supported by this browser)", {
senderName: getSenderName(),
});
} else if (!isVoice && isSupported) {
return () => _t("%(senderName)s placed a video call.", {
senderName: getSenderName(),
});
} else if (!isVoice && !isSupported) {
return () => _t("%(senderName)s placed a video call. (not supported by this browser)", {
senderName: getSenderName(),
});
}
}
function textForThreePidInviteEvent(event: MatrixEvent): () => string | null {
const senderName = event.sender ? event.sender.name : event.getSender();
@ -652,10 +567,6 @@ interface IHandlers {
const handlers: IHandlers = {
'm.room.message': textForMessageEvent,
'm.call.invite': textForCallInviteEvent,
'm.call.answer': textForCallAnswerEvent,
'm.call.hangup': textForCallHangupEvent,
'm.call.reject': textForCallRejectEvent,
};
const stateHandlers: IHandlers = {

View file

@ -25,7 +25,6 @@ import { PillCompletion } from './Components';
import { ICompletion, ISelectionRange } from './Autocompleter';
import { uniq, sortBy } from 'lodash';
import SettingsStore from "../settings/SettingsStore";
import { shortcodeToUnicode } from '../HtmlUtils';
import { EMOJI, IEmoji } from '../emoji';
import EMOTICON_REGEX from 'emojibase-regex/emoticon';
@ -36,20 +35,18 @@ const LIMIT = 20;
// anchored to only match from the start of parts otherwise it'll show emoji suggestions whilst typing matrix IDs
const EMOJI_REGEX = new RegExp('(' + EMOTICON_REGEX.source + '|(?:^|\\s):[+-\\w]*:?)$', 'g');
interface IEmojiShort {
interface ISortedEmoji {
emoji: IEmoji;
shortname: string;
_orderBy: number;
}
const EMOJI_SHORTNAMES: IEmojiShort[] = EMOJI.sort((a, b) => {
const SORTED_EMOJI: ISortedEmoji[] = EMOJI.sort((a, b) => {
if (a.group === b.group) {
return a.order - b.order;
}
return a.group - b.group;
}).map((emoji, index) => ({
emoji,
shortname: `:${emoji.shortcodes[0]}:`,
// Include the index so that we can preserve the original order
_orderBy: index,
}));
@ -64,20 +61,18 @@ function score(query, space) {
}
export default class EmojiProvider extends AutocompleteProvider {
matcher: QueryMatcher<IEmojiShort>;
nameMatcher: QueryMatcher<IEmojiShort>;
matcher: QueryMatcher<ISortedEmoji>;
nameMatcher: QueryMatcher<ISortedEmoji>;
constructor() {
super(EMOJI_REGEX);
this.matcher = new QueryMatcher<IEmojiShort>(EMOJI_SHORTNAMES, {
keys: ['emoji.emoticon', 'shortname'],
funcs: [
(o) => o.emoji.shortcodes.length > 1 ? o.emoji.shortcodes.slice(1).map(s => `:${s}:`).join(" ") : "", // aliases
],
this.matcher = new QueryMatcher<ISortedEmoji>(SORTED_EMOJI, {
keys: ['emoji.emoticon'],
funcs: [o => o.emoji.shortcodes.map(s => `:${s}:`)],
// For matching against ascii equivalents
shouldMatchWordsOnly: false,
});
this.nameMatcher = new QueryMatcher(EMOJI_SHORTNAMES, {
this.nameMatcher = new QueryMatcher(SORTED_EMOJI, {
keys: ['emoji.annotation'],
// For removing punctuation
shouldMatchWordsOnly: true,
@ -105,34 +100,33 @@ export default class EmojiProvider extends AutocompleteProvider {
const sorters = [];
// make sure that emoticons come first
sorters.push((c) => score(matchedString, c.emoji.emoticon || ""));
sorters.push(c => score(matchedString, c.emoji.emoticon || ""));
// then sort by score (Infinity if matchedString not in shortname)
sorters.push((c) => score(matchedString, c.shortname));
// then sort by score (Infinity if matchedString not in shortcode)
sorters.push(c => score(matchedString, c.emoji.shortcodes[0]));
// then sort by max score of all shortcodes, trim off the `:`
sorters.push((c) => Math.min(...c.emoji.shortcodes.map(s => score(matchedString.substring(1), s))));
// If the matchedString is not empty, sort by length of shortname. Example:
sorters.push(c => Math.min(
...c.emoji.shortcodes.map(s => score(matchedString.substring(1), s)),
));
// If the matchedString is not empty, sort by length of shortcode. Example:
// matchedString = ":bookmark"
// completions = [":bookmark:", ":bookmark_tabs:", ...]
if (matchedString.length > 1) {
sorters.push((c) => c.shortname.length);
sorters.push(c => c.emoji.shortcodes[0].length);
}
// Finally, sort by original ordering
sorters.push((c) => c._orderBy);
sorters.push(c => c._orderBy);
completions = sortBy(uniq(completions), sorters);
completions = completions.map(({ shortname }) => {
const unicode = shortcodeToUnicode(shortname);
return {
completion: unicode,
completions = completions.map(c => ({
completion: c.emoji.unicode,
component: (
<PillCompletion title={shortname} aria-label={unicode}>
<span>{ unicode }</span>
<PillCompletion title={`:${c.emoji.shortcodes[0]}:`} aria-label={c.emoji.unicode}>
<span>{ c.emoji.unicode }</span>
</PillCompletion>
),
range,
};
}).slice(0, LIMIT);
})).slice(0, LIMIT);
}
return completions;
}

View file

@ -109,7 +109,7 @@ export default class UserProvider extends AutocompleteProvider {
limit = -1,
): Promise<ICompletion[]> {
// lazy-load user list into matcher
if (!this.users) this._makeUsers();
if (!this.users) this.makeUsers();
let completions = [];
const { command, range } = this.getCurrentCommand(rawQuery, selection, force);
@ -147,7 +147,7 @@ export default class UserProvider extends AutocompleteProvider {
return _t('Users');
}
_makeUsers() {
private makeUsers() {
const events = this.room.getLiveTimeline().getEvents();
const lastSpoken = {};

View file

@ -0,0 +1,145 @@
/*
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 { EventType } from "matrix-js-sdk/src/@types/event";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { CallEvent, CallState, CallType, MatrixCall } from "matrix-js-sdk/src/webrtc/call";
import CallHandler, { CallHandlerEvent } from '../../CallHandler';
import { EventEmitter } from 'events';
import { MatrixClientPeg } from "../../MatrixClientPeg";
import defaultDispatcher from "../../dispatcher/dispatcher";
export enum CallEventGrouperEvent {
StateChanged = "state_changed",
SilencedChanged = "silenced_changed",
}
const SUPPORTED_STATES = [
CallState.Connected,
CallState.Connecting,
CallState.Ringing,
];
export enum CustomCallState {
Missed = "missed",
}
export default class CallEventGrouper extends EventEmitter {
private events: Set<MatrixEvent> = new Set<MatrixEvent>();
private call: MatrixCall;
public state: CallState | CustomCallState;
constructor() {
super();
CallHandler.sharedInstance().addListener(CallHandlerEvent.CallsChanged, this.setCall);
CallHandler.sharedInstance().addListener(CallHandlerEvent.SilencedCallsChanged, this.onSilencedCallsChanged);
}
private get invite(): MatrixEvent {
return [...this.events].find((event) => event.getType() === EventType.CallInvite);
}
private get hangup(): MatrixEvent {
return [...this.events].find((event) => event.getType() === EventType.CallHangup);
}
private get reject(): MatrixEvent {
return [...this.events].find((event) => event.getType() === EventType.CallReject);
}
public get isVoice(): boolean {
const invite = this.invite;
if (!invite) return;
// FIXME: Find a better way to determine this from the event?
if (invite.getContent()?.offer?.sdp?.indexOf('m=video') !== -1) return false;
return true;
}
public get hangupReason(): string | null {
return this.hangup?.getContent()?.reason;
}
/**
* Returns true if there are only events from the other side - we missed the call
*/
private get callWasMissed(): boolean {
return ![...this.events].some((event) => event.sender?.userId === MatrixClientPeg.get().getUserId());
}
private get callId(): string {
return [...this.events][0].getContent().call_id;
}
private onSilencedCallsChanged = () => {
const newState = CallHandler.sharedInstance().isCallSilenced(this.callId);
this.emit(CallEventGrouperEvent.SilencedChanged, newState);
};
public answerCall = () => {
this.call?.answer();
};
public rejectCall = () => {
this.call?.reject();
};
public callBack = () => {
defaultDispatcher.dispatch({
action: 'place_call',
type: this.isVoice ? CallType.Voice : CallType.Video,
room_id: [...this.events][0]?.getRoomId(),
});
};
public toggleSilenced = () => {
const silenced = CallHandler.sharedInstance().isCallSilenced(this.callId);
silenced ?
CallHandler.sharedInstance().unSilenceCall(this.callId) :
CallHandler.sharedInstance().silenceCall(this.callId);
};
private setCallListeners() {
if (!this.call) return;
this.call.addListener(CallEvent.State, this.setState);
}
private setState = () => {
if (SUPPORTED_STATES.includes(this.call?.state)) {
this.state = this.call.state;
} else {
if (this.callWasMissed) this.state = CustomCallState.Missed;
else if (this.reject) this.state = CallState.Ended;
else if (this.hangup) this.state = CallState.Ended;
else if (this.invite && this.call) this.state = CallState.Connecting;
}
this.emit(CallEventGrouperEvent.StateChanged, this.state);
};
private setCall = () => {
if (this.call) return;
this.call = CallHandler.sharedInstance().getCallById(this.callId);
this.setCallListeners();
this.setState();
};
public add(event: MatrixEvent) {
this.events.add(event);
this.setCall();
}
}

View file

@ -17,8 +17,8 @@ limitations under the License.
*/
import * as React from 'react';
import * as PropTypes from 'prop-types';
import { MatrixClient } from 'matrix-js-sdk/src/client';
import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
import { Key } from '../../Keyboard';
import PageTypes from '../../PageTypes';
@ -79,6 +79,8 @@ function canElementReceiveInput(el) {
interface IProps {
matrixClient: MatrixClient;
// Called with the credentials of a registered user (if they were a ROU that
// transitioned to PWLU)
onRegistered: (credentials: IMatrixClientCreds) => Promise<MatrixClient>;
hideToSRUsers: boolean;
resizeNotifier: ResizeNotifier;
@ -140,18 +142,6 @@ interface IState {
class LoggedInView extends React.Component<IProps, IState> {
static displayName = 'LoggedInView';
static propTypes = {
matrixClient: PropTypes.instanceOf(MatrixClient).isRequired,
page_type: PropTypes.string.isRequired,
onRoomCreated: PropTypes.func,
// Called with the credentials of a registered user (if they were a ROU that
// transitioned to PWLU)
onRegistered: PropTypes.func,
// and lots and lots of other stuff.
};
protected readonly _matrixClient: MatrixClient;
protected readonly _roomView: React.RefObject<any>;
protected readonly _resizeContainer: React.RefObject<ResizeHandle>;
@ -181,10 +171,10 @@ class LoggedInView extends React.Component<IProps, IState> {
}
componentDidMount() {
document.addEventListener('keydown', this._onNativeKeyDown, false);
document.addEventListener('keydown', this.onNativeKeyDown, false);
CallHandler.sharedInstance().addListener(CallHandlerEvent.CallsChanged, this.onCallsChanged);
this._updateServerNoticeEvents();
this.updateServerNoticeEvents();
this._matrixClient.on("accountData", this.onAccountData);
this._matrixClient.on("sync", this.onSync);
@ -200,13 +190,13 @@ class LoggedInView extends React.Component<IProps, IState> {
"useCompactLayout", null, this.onCompactLayoutChanged,
);
this.resizer = this._createResizer();
this.resizer = this.createResizer();
this.resizer.attach();
this._loadResizerPreferences();
this.loadResizerPreferences();
}
componentWillUnmount() {
document.removeEventListener('keydown', this._onNativeKeyDown, false);
document.removeEventListener('keydown', this.onNativeKeyDown, false);
CallHandler.sharedInstance().removeListener(CallHandlerEvent.CallsChanged, this.onCallsChanged);
this._matrixClient.removeListener("accountData", this.onAccountData);
this._matrixClient.removeListener("sync", this.onSync);
@ -221,37 +211,37 @@ class LoggedInView extends React.Component<IProps, IState> {
});
};
canResetTimelineInRoom = (roomId) => {
public canResetTimelineInRoom = (roomId: string) => {
if (!this._roomView.current) {
return true;
}
return this._roomView.current.canResetTimeline();
};
_createResizer() {
let size;
let collapsed;
private createResizer() {
let panelSize;
let panelCollapsed;
const collapseConfig: ICollapseConfig = {
// TODO decrease this once Spaces launches as it'll no longer need to include the 56px Community Panel
toggleSize: 206 - 50,
onCollapsed: (_collapsed) => {
collapsed = _collapsed;
if (_collapsed) {
onCollapsed: (collapsed) => {
panelCollapsed = collapsed;
if (collapsed) {
dis.dispatch({ action: "hide_left_panel" });
window.localStorage.setItem("mx_lhs_size", '0');
} else {
dis.dispatch({ action: "show_left_panel" });
}
},
onResized: (_size) => {
size = _size;
onResized: (size) => {
panelSize = size;
this.props.resizeNotifier.notifyLeftHandleResized();
},
onResizeStart: () => {
this.props.resizeNotifier.startResizing();
},
onResizeStop: () => {
if (!collapsed) window.localStorage.setItem("mx_lhs_size", '' + size);
if (!panelCollapsed) window.localStorage.setItem("mx_lhs_size", '' + panelSize);
this.props.resizeNotifier.stopResizing();
},
isItemCollapsed: domNode => {
@ -267,7 +257,7 @@ class LoggedInView extends React.Component<IProps, IState> {
return resizer;
}
_loadResizerPreferences() {
private loadResizerPreferences() {
let lhsSize = parseInt(window.localStorage.getItem("mx_lhs_size"), 10);
if (isNaN(lhsSize)) {
lhsSize = 350;
@ -275,7 +265,7 @@ class LoggedInView extends React.Component<IProps, IState> {
this.resizer.forHandleAt(0).resize(lhsSize);
}
onAccountData = (event) => {
private onAccountData = (event: MatrixEvent) => {
if (event.getType() === "m.ignored_user_list") {
dis.dispatch({ action: "ignore_state_changed" });
}
@ -307,16 +297,16 @@ class LoggedInView extends React.Component<IProps, IState> {
}
if (oldSyncState === 'PREPARED' && syncState === 'SYNCING') {
this._updateServerNoticeEvents();
this.updateServerNoticeEvents();
} else {
this._calculateServerLimitToast(this.state.syncErrorData, this.state.usageLimitEventContent);
this.calculateServerLimitToast(this.state.syncErrorData, this.state.usageLimitEventContent);
}
};
onRoomStateEvents = (ev, state) => {
const serverNoticeList = RoomListStore.instance.orderedLists[DefaultTagID.ServerNotice];
if (serverNoticeList && serverNoticeList.some(r => r.roomId === ev.getRoomId())) {
this._updateServerNoticeEvents();
this.updateServerNoticeEvents();
}
};
@ -326,7 +316,7 @@ class LoggedInView extends React.Component<IProps, IState> {
});
};
_calculateServerLimitToast(syncError: IState["syncErrorData"], usageLimitEventContent?: IUsageLimit) {
private calculateServerLimitToast(syncError: IState["syncErrorData"], usageLimitEventContent?: IUsageLimit) {
const error = syncError && syncError.error && syncError.error.errcode === "M_RESOURCE_LIMIT_EXCEEDED";
if (error) {
usageLimitEventContent = syncError.error.data;
@ -346,7 +336,7 @@ class LoggedInView extends React.Component<IProps, IState> {
}
}
_updateServerNoticeEvents = async () => {
private updateServerNoticeEvents = async () => {
const serverNoticeList = RoomListStore.instance.orderedLists[DefaultTagID.ServerNotice];
if (!serverNoticeList) return [];
@ -378,7 +368,7 @@ class LoggedInView extends React.Component<IProps, IState> {
);
});
const usageLimitEventContent = usageLimitEvent && usageLimitEvent.getContent();
this._calculateServerLimitToast(this.state.syncErrorData, usageLimitEventContent);
this.calculateServerLimitToast(this.state.syncErrorData, usageLimitEventContent);
this.setState({
usageLimitEventContent,
usageLimitEventTs: pinnedEventTs,
@ -387,7 +377,7 @@ class LoggedInView extends React.Component<IProps, IState> {
});
};
_onPaste = (ev) => {
private onPaste = (ev) => {
let canReceiveInput = false;
let element = ev.target;
// test for all parents because the target can be a child of a contenteditable element
@ -425,22 +415,22 @@ class LoggedInView extends React.Component<IProps, IState> {
We also listen with a native listener on the document to get keydown events when no element is focused.
Bubbling is irrelevant here as the target is the body element.
*/
_onReactKeyDown = (ev) => {
private onReactKeyDown = (ev) => {
// events caught while bubbling up on the root element
// of this component, so something must be focused.
this._onKeyDown(ev);
this.onKeyDown(ev);
};
_onNativeKeyDown = (ev) => {
private onNativeKeyDown = (ev) => {
// only pass this if there is no focused element.
// if there is, _onKeyDown will be called by the
// if there is, onKeyDown will be called by the
// react keydown handler that respects the react bubbling order.
if (ev.target === document.body) {
this._onKeyDown(ev);
this.onKeyDown(ev);
}
};
_onKeyDown = (ev) => {
private onKeyDown = (ev) => {
let handled = false;
const roomAction = getKeyBindingsManager().getRoomAction(ev);
@ -450,7 +440,7 @@ class LoggedInView extends React.Component<IProps, IState> {
case RoomAction.JumpToFirstMessage:
case RoomAction.JumpToLatestMessage:
// pass the event down to the scroll panel
this._onScrollKeyPressed(ev);
this.onScrollKeyPressed(ev);
handled = true;
break;
case RoomAction.FocusSearch:
@ -565,7 +555,7 @@ class LoggedInView extends React.Component<IProps, IState> {
* dispatch a page-up/page-down/etc to the appropriate component
* @param {Object} ev The key event
*/
_onScrollKeyPressed = (ev) => {
private onScrollKeyPressed = (ev) => {
if (this._roomView.current) {
this._roomView.current.handleScrollKey(ev);
}
@ -625,8 +615,8 @@ class LoggedInView extends React.Component<IProps, IState> {
return (
<MatrixClientContext.Provider value={this._matrixClient}>
<div
onPaste={this._onPaste}
onKeyDown={this._onReactKeyDown}
onPaste={this.onPaste}
onKeyDown={this.onReactKeyDown}
className='mx_MatrixChat_wrapper'
aria-hidden={this.props.hideToSRUsers}
>

View file

@ -431,7 +431,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
}
// TODO: [REACT-WARNING] Replace with appropriate lifecycle stage
// eslint-disable-next-line camelcase
// eslint-disable-next-line
UNSAFE_componentWillUpdate(props, state) {
if (this.shouldTrackPageChange(this.state, state)) {
this.startPageChangeTimer();
@ -1864,13 +1864,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
dis.dispatch({ action: 'timeline_resize' });
}
onRoomCreated(roomId: string) {
dis.dispatch({
action: "view_room",
room_id: roomId,
});
}
onRegisterClick = () => {
this.showScreen("register");
};
@ -2043,7 +2036,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
{...this.state}
ref={this.loggedInView}
matrixClient={MatrixClientPeg.get()}
onRoomCreated={this.onRoomCreated}
onRegistered={this.onRegistered}
currentRoomId={this.state.currentRoomId}
/>

View file

@ -36,6 +36,7 @@ import DMRoomMap from "../../utils/DMRoomMap";
import NewRoomIntro from "../views/rooms/NewRoomIntro";
import { replaceableComponent } from "../../utils/replaceableComponent";
import defaultDispatcher from '../../dispatcher/dispatcher';
import CallEventGrouper from "./CallEventGrouper";
import WhoIsTypingTile from '../views/rooms/WhoIsTypingTile';
import ScrollPanel, { IScrollState } from "./ScrollPanel";
import EventListSummary from '../views/elements/EventListSummary';
@ -232,6 +233,9 @@ export default class MessagePanel extends React.Component<IProps, IState> {
private readonly showTypingNotificationsWatcherRef: string;
private eventNodes: Record<string, HTMLElement>;
// A map of <callId, CallEventGrouper>
private callEventGroupers = new Map<string, CallEventGrouper>();
constructor(props, context) {
super(props, context);
@ -576,6 +580,20 @@ export default class MessagePanel extends React.Component<IProps, IState> {
const last = (mxEv === lastShownEvent);
const { nextEvent, nextTile } = this.getNextEventInfo(this.props.events, i);
if (
mxEv.getType().indexOf("m.call.") === 0 ||
mxEv.getType().indexOf("org.matrix.call.") === 0
) {
const callId = mxEv.getContent().call_id;
if (this.callEventGroupers.has(callId)) {
this.callEventGroupers.get(callId).add(mxEv);
} else {
const callEventGrouper = new CallEventGrouper();
callEventGrouper.add(mxEv);
this.callEventGroupers.set(callId, callEventGrouper);
}
}
if (grouper) {
if (grouper.shouldGroup(mxEv)) {
grouper.add(mxEv, this.showHiddenEvents);
@ -653,8 +671,10 @@ export default class MessagePanel extends React.Component<IProps, IState> {
}
let willWantDateSeparator = false;
let lastInSection = true;
if (nextEvent) {
willWantDateSeparator = this.wantsDateSeparator(mxEv, nextEvent.getDate() || new Date());
lastInSection = willWantDateSeparator || mxEv.getSender() !== nextEvent.getSender();
}
// is this a continuation of the previous message?
@ -690,6 +710,8 @@ export default class MessagePanel extends React.Component<IProps, IState> {
// it's successful: we received it.
isLastSuccessful = isLastSuccessful && mxEv.getSender() === MatrixClientPeg.get().getUserId();
const callEventGrouper = this.callEventGroupers.get(mxEv.getContent().call_id);
// use txnId as key if available so that we don't remount during sending
ret.push(
<TileErrorBoundary key={mxEv.getTxnId() || eventId} mxEvent={mxEv}>
@ -712,7 +734,7 @@ export default class MessagePanel extends React.Component<IProps, IState> {
isTwelveHour={this.props.isTwelveHour}
permalinkCreator={this.props.permalinkCreator}
last={last}
lastInSection={willWantDateSeparator}
lastInSection={lastInSection}
lastSuccessful={isLastSuccessful}
isSelectedEvent={highlight}
getRelationsForEvent={this.props.getRelationsForEvent}
@ -720,6 +742,8 @@ export default class MessagePanel extends React.Component<IProps, IState> {
layout={this.props.layout}
enableFlair={this.props.enableFlair}
showReadReceipts={this.props.showReadReceipts}
callEventGrouper={callEventGrouper}
hideSender={this.props.room.getMembers().length <= 2 && this.props.layout === Layout.Bubble}
/>
</TileErrorBoundary>,
);

View file

@ -17,6 +17,7 @@ limitations under the License.
import React from 'react';
import { Room } from "matrix-js-sdk/src/models/room";
import { RoomState } from "matrix-js-sdk/src/models/room-state";
import { User } from "matrix-js-sdk/src/models/user";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
@ -152,7 +153,7 @@ export default class RightPanel extends React.Component<IProps, IState> {
}
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
UNSAFE_componentWillReceiveProps(newProps) { // eslint-disable-line camelcase
UNSAFE_componentWillReceiveProps(newProps) { // eslint-disable-line
if (newProps.groupId !== this.props.groupId) {
this.unregisterGroupStore();
this.initGroupStore(newProps.groupId);
@ -174,7 +175,7 @@ export default class RightPanel extends React.Component<IProps, IState> {
});
};
private onRoomStateMember = (ev: MatrixEvent, _, member: RoomMember) => {
private onRoomStateMember = (ev: MatrixEvent, state: RoomState, member: RoomMember) => {
if (!this.props.room || member.roomId !== this.props.room.roomId) {
return;
}

View file

@ -814,7 +814,7 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
}) : _t("Explore rooms");
return (
<BaseDialog
className={'mx_RoomDirectory_dialog'}
className="mx_RoomDirectory_dialog"
hasCancel={true}
onFinished={this.onFinished}
title={title}

View file

@ -458,7 +458,7 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => {
const numFields = 3;
const placeholders = [_t("General"), _t("Random"), _t("Support")];
const [roomNames, setRoomName] = useStateArray(numFields, [_t("General"), _t("Random"), ""]);
const fields = new Array(numFields).fill(0).map((_, i) => {
const fields = new Array(numFields).fill(0).map((x, i) => {
const name = "roomName" + i;
return <Field
key={name}
@ -625,7 +625,7 @@ const SpaceSetupPrivateInvite = ({ space, onFinished }) => {
const numFields = 3;
const fieldRefs: RefObject<Field>[] = [useRef(), useRef(), useRef()];
const [emailAddresses, setEmailAddress] = useStateArray(numFields, "");
const fields = new Array(numFields).fill(0).map((_, i) => {
const fields = new Array(numFields).fill(0).map((x, i) => {
const name = "emailAddress" + i;
return <Field
key={name}

View file

@ -74,7 +74,7 @@ export default class TabbedView extends React.Component<IProps, IState> {
tabLocation: TabLocation.LEFT,
};
private _getActiveTabIndex() {
private getActiveTabIndex() {
if (!this.state || !this.state.activeTabIndex) return 0;
return this.state.activeTabIndex;
}
@ -84,7 +84,7 @@ export default class TabbedView extends React.Component<IProps, IState> {
* @param {Tab} tab the tab to show
* @private
*/
private _setActiveTab(tab: Tab) {
private setActiveTab(tab: Tab) {
const idx = this.props.tabs.indexOf(tab);
if (idx !== -1) {
if (this.props.onChange) this.props.onChange(tab.id);
@ -94,18 +94,18 @@ export default class TabbedView extends React.Component<IProps, IState> {
}
}
private _renderTabLabel(tab: Tab) {
private renderTabLabel(tab: Tab) {
let classes = "mx_TabbedView_tabLabel ";
const idx = this.props.tabs.indexOf(tab);
if (idx === this._getActiveTabIndex()) classes += "mx_TabbedView_tabLabel_active";
if (idx === this.getActiveTabIndex()) classes += "mx_TabbedView_tabLabel_active";
let tabIcon = null;
if (tab.icon) {
tabIcon = <span className={`mx_TabbedView_maskedIcon ${tab.icon}`} />;
}
const onClickHandler = () => this._setActiveTab(tab);
const onClickHandler = () => this.setActiveTab(tab);
const label = _t(tab.label);
return (
@ -118,7 +118,7 @@ export default class TabbedView extends React.Component<IProps, IState> {
);
}
private _renderTabPanel(tab: Tab): React.ReactNode {
private renderTabPanel(tab: Tab): React.ReactNode {
return (
<div className="mx_TabbedView_tabPanel" key={"mx_tabpanel_" + tab.label}>
<AutoHideScrollbar className='mx_TabbedView_tabPanelContent'>
@ -129,8 +129,8 @@ export default class TabbedView extends React.Component<IProps, IState> {
}
public render(): React.ReactNode {
const labels = this.props.tabs.map(tab => this._renderTabLabel(tab));
const panel = this._renderTabPanel(this.props.tabs[this._getActiveTabIndex()]);
const labels = this.props.tabs.map(tab => this.renderTabLabel(tab));
const panel = this.renderTabPanel(this.props.tabs[this.getActiveTabIndex()]);
const tabbedViewClasses = classNames({
'mx_TabbedView': true,

View file

@ -277,7 +277,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
}
// TODO: [REACT-WARNING] Move into constructor
// eslint-disable-next-line camelcase
// eslint-disable-next-line
UNSAFE_componentWillMount() {
if (this.props.manageReadReceipts) {
this.updateReadReceiptOnUserActivity();
@ -290,7 +290,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
}
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
// eslint-disable-next-line camelcase
// eslint-disable-next-line
UNSAFE_componentWillReceiveProps(newProps) {
if (newProps.timelineSet !== this.props.timelineSet) {
// throw new Error("changing timelineSet on a TimelinePanel is not supported");

View file

@ -37,14 +37,14 @@ export default class ToastContainer extends React.Component<{}, IState> {
// toasts may dismiss themselves in their didMount if they find
// they're already irrelevant by the time they're mounted, and
// our own componentDidMount is too late.
ToastStore.sharedInstance().on('update', this._onToastStoreUpdate);
ToastStore.sharedInstance().on('update', this.onToastStoreUpdate);
}
componentWillUnmount() {
ToastStore.sharedInstance().removeListener('update', this._onToastStoreUpdate);
ToastStore.sharedInstance().removeListener('update', this.onToastStoreUpdate);
}
_onToastStoreUpdate = () => {
private onToastStoreUpdate = () => {
this.setState({
toasts: ToastStore.sharedInstance().getToasts(),
countSeen: ToastStore.sharedInstance().getCountSeen(),

View file

@ -101,7 +101,7 @@ export default class ForgotPassword extends React.Component<IProps, IState> {
}
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
// eslint-disable-next-line camelcase
// eslint-disable-next-line
public UNSAFE_componentWillReceiveProps(newProps: IProps): void {
if (newProps.serverConfig.hsUrl === this.props.serverConfig.hsUrl &&
newProps.serverConfig.isUrl === this.props.serverConfig.isUrl) return;

View file

@ -144,7 +144,7 @@ export default class LoginComponent extends React.PureComponent<IProps, IState>
}
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
// eslint-disable-next-line camelcase
// eslint-disable-next-line
UNSAFE_componentWillMount() {
this.initLoginLogic(this.props.serverConfig);
}
@ -154,7 +154,7 @@ export default class LoginComponent extends React.PureComponent<IProps, IState>
}
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
// eslint-disable-next-line camelcase
// eslint-disable-next-line
UNSAFE_componentWillReceiveProps(newProps) {
if (newProps.serverConfig.hsUrl === this.props.serverConfig.hsUrl &&
newProps.serverConfig.isUrl === this.props.serverConfig.isUrl) return;

View file

@ -141,7 +141,7 @@ export default class Registration extends React.Component<IProps, IState> {
}
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
// eslint-disable-next-line camelcase
// eslint-disable-next-line
UNSAFE_componentWillReceiveProps(newProps) {
if (newProps.serverConfig.hsUrl === this.props.serverConfig.hsUrl &&
newProps.serverConfig.isUrl === this.props.serverConfig.isUrl) return;

View file

@ -21,59 +21,68 @@ import { replaceableComponent } from "../../../utils/replaceableComponent";
const DIV_ID = 'mx_recaptcha';
interface IProps {
sitePublicKey?: string;
interface ICaptchaFormProps {
sitePublicKey: string;
onCaptchaResponse: (response: string) => void;
}
interface IState {
errorText: string;
interface ICaptchaFormState {
errorText?: string;
}
/**
* A pure UI component which displays a captcha form.
*/
@replaceableComponent("views.auth.CaptchaForm")
export default class CaptchaForm extends React.Component<IProps, IState> {
private captchaWidgetId: string;
private recaptchaContainer = createRef<HTMLDivElement>();
export default class CaptchaForm extends React.Component<ICaptchaFormProps, ICaptchaFormState> {
static defaultProps = {
onCaptchaResponse: () => {},
};
constructor(props) {
private captchaWidgetId?: string;
private recaptchaContainer = createRef<HTMLDivElement>();
constructor(props: ICaptchaFormProps) {
super(props);
this.state = {
errorText: null,
errorText: undefined,
};
CountlyAnalytics.instance.track("onboarding_grecaptcha_begin");
}
public componentDidMount(): void {
componentDidMount() {
// Just putting a script tag into the returned jsx doesn't work, annoyingly,
// so we do this instead.
if (window.grecaptcha) { // TODO: Properly find the type of `grecaptcha`
if (this.isRecaptchaReady()) {
// already loaded
this.onCaptchaLoaded();
} else {
console.log("Loading recaptcha script...");
window.mx_on_recaptcha_loaded = () => {this.onCaptchaLoaded();};
window.mxOnRecaptchaLoaded = () => { this.onCaptchaLoaded(); };
const scriptTag = document.createElement('script');
scriptTag.setAttribute(
'src', `https://www.recaptcha.net/recaptcha/api.js?onload=mx_on_recaptcha_loaded&render=explicit`,
'src', `https://www.recaptcha.net/recaptcha/api.js?onload=mxOnRecaptchaLoaded&render=explicit`,
);
this.recaptchaContainer.current.appendChild(scriptTag);
}
}
public componentWillUnmount(): void {
componentWillUnmount() {
this.resetRecaptcha();
}
private renderRecaptcha(divId): void {
if (!window.grecaptcha) {
// Borrowed directly from: https://github.com/codeep/react-recaptcha-google/commit/e118fa5670fa268426969323b2e7fe77698376ba
private isRecaptchaReady(): boolean {
return typeof window !== "undefined" &&
typeof global.grecaptcha !== "undefined" &&
typeof global.grecaptcha.render === 'function';
}
private renderRecaptcha(divId: string) {
if (!this.isRecaptchaReady()) {
console.error("grecaptcha not loaded!");
throw new Error("Recaptcha did not load successfully");
}
@ -87,19 +96,19 @@ export default class CaptchaForm extends React.Component<IProps, IState> {
}
console.info("Rendering to %s", divId);
this.captchaWidgetId = window.grecaptcha.render(divId, {
this.captchaWidgetId = global.grecaptcha.render(divId, {
sitekey: publicKey,
callback: this.props.onCaptchaResponse,
});
}
private resetRecaptcha(): void {
private resetRecaptcha() {
if (this.captchaWidgetId !== null) {
window.grecaptcha.reset(this.captchaWidgetId);
global.grecaptcha.reset(this.captchaWidgetId);
}
}
private onCaptchaLoaded(): void {
private onCaptchaLoaded() {
console.log("Loaded recaptcha script.");
try {
this.renderRecaptcha(DIV_ID);
@ -116,7 +125,7 @@ export default class CaptchaForm extends React.Component<IProps, IState> {
}
}
public render(): React.ReactNode {
render() {
let error = null;
if (this.state.errorText) {
error = (

View file

@ -43,11 +43,15 @@ export function canCancel(eventStatus: EventStatus): boolean {
return eventStatus === EventStatus.QUEUED || eventStatus === EventStatus.NOT_SENT;
}
interface IEventTileOps {
export interface IEventTileOps {
isWidgetHidden(): boolean;
unhideWidget(): void;
}
export interface IOperableEventTile {
getEventTileOps(): IEventTileOps;
}
interface IProps {
/* the MatrixEvent associated with the context menu */
mxEvent: MatrixEvent;

View file

@ -60,8 +60,8 @@ export default class AskInviteAnywayDialog extends React.Component<IProps> {
contentId='mx_Dialog_content'
>
<div id='mx_Dialog_content'>
{/* eslint-disable-next-line */}
<p>{_t("Unable to find profiles for the Matrix IDs listed below - would you like to invite them anyway?")}</p>
<p>{ _t("Unable to find profiles for the Matrix IDs listed below - " +
"would you like to invite them anyway?") }</p>
<ul>
{ errorList }
</ul>

View file

@ -187,7 +187,7 @@ export default class CommunityPrototypeInviteDialog extends React.PureComponent<
emailAddresses.push((
<Field
key={emailAddresses.length}
value={""}
value=""
onChange={(e) => this.onAddressChange(e, emailAddresses.length)}
label={emailAddresses.length > 0 ? _t("Add another email") : _t("Email address")}
placeholder={emailAddresses.length > 0 ? _t("Add another email") : _t("Email address")}

View file

@ -102,7 +102,7 @@ export default class CreateGroupDialog extends React.Component<IProps, IState> {
});
};
_onCancel = () => {
private onCancel = () => {
this.props.onFinished(false);
};
@ -167,7 +167,7 @@ export default class CreateGroupDialog extends React.Component<IProps, IState> {
</div>
<div className="mx_Dialog_buttons">
<input type="submit" value={_t('Create')} className="mx_Dialog_primary" />
<button onClick={this._onCancel}>
<button onClick={this.onCancel}>
{ _t("Cancel") }
</button>
</div>

View file

@ -337,7 +337,7 @@ class FilteredList extends React.PureComponent<IFilteredListProps, IFilteredList
}
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
UNSAFE_componentWillReceiveProps(nextProps) { // eslint-disable-line camelcase
UNSAFE_componentWillReceiveProps(nextProps) { // eslint-disable-line
if (this.props.children === nextProps.children && this.props.query === nextProps.query) return;
this.setState({
filteredChildren: FilteredList.filterChildren(nextProps.children, nextProps.query),

View file

@ -40,7 +40,7 @@ interface IState {
busy: boolean;
err?: string;
// If we know it, the nature of the abuse, as specified by MSC3215.
nature?: EXTENDED_NATURE;
nature?: ExtendedNature;
}
const MODERATED_BY_STATE_EVENT_TYPE = [
@ -55,22 +55,22 @@ const MODERATED_BY_STATE_EVENT_TYPE = [
const ABUSE_EVENT_TYPE = "org.matrix.msc3215.abuse.report";
// Standard abuse natures.
enum NATURE {
DISAGREEMENT = "org.matrix.msc3215.abuse.nature.disagreement",
TOXIC = "org.matrix.msc3215.abuse.nature.toxic",
ILLEGAL = "org.matrix.msc3215.abuse.nature.illegal",
SPAM = "org.matrix.msc3215.abuse.nature.spam",
OTHER = "org.matrix.msc3215.abuse.nature.other",
enum Nature {
Disagreement = "org.matrix.msc3215.abuse.nature.disagreement",
Toxic = "org.matrix.msc3215.abuse.nature.toxic",
Illegal = "org.matrix.msc3215.abuse.nature.illegal",
Spam = "org.matrix.msc3215.abuse.nature.spam",
Other = "org.matrix.msc3215.abuse.nature.other",
}
enum NON_STANDARD_NATURE {
enum NonStandardValue {
// Non-standard abuse nature.
// It should never leave the client - we use it to fallback to
// server-wide abuse reporting.
ADMIN = "non-standard.abuse.nature.admin"
Admin = "non-standard.abuse.nature.admin"
}
type EXTENDED_NATURE = NATURE | NON_STANDARD_NATURE;
type ExtendedNature = Nature | NonStandardValue;
type Moderation = {
// The id of the moderation room.
@ -170,7 +170,7 @@ export default class ReportEventDialog extends React.Component<IProps, IState> {
// The user has clicked on a nature.
private onNatureChosen = (e: React.FormEvent<HTMLInputElement>): void => {
this.setState({ nature: e.currentTarget.value as EXTENDED_NATURE });
this.setState({ nature: e.currentTarget.value as ExtendedNature });
};
// The user has clicked "cancel".
@ -187,7 +187,7 @@ export default class ReportEventDialog extends React.Component<IProps, IState> {
// We need a nature.
// If the nature is `NATURE.OTHER` or `NON_STANDARD_NATURE.ADMIN`, we also need a `reason`.
if (!this.state.nature ||
((this.state.nature == NATURE.OTHER || this.state.nature == NON_STANDARD_NATURE.ADMIN)
((this.state.nature == Nature.Other || this.state.nature == NonStandardValue.Admin)
&& !reason)
) {
this.setState({
@ -214,8 +214,8 @@ export default class ReportEventDialog extends React.Component<IProps, IState> {
try {
const client = MatrixClientPeg.get();
const ev = this.props.mxEvent;
if (this.moderation && this.state.nature != NON_STANDARD_NATURE.ADMIN) {
const nature: NATURE = this.state.nature;
if (this.moderation && this.state.nature != NonStandardValue.Admin) {
const nature: Nature = this.state.nature;
// Report to moderators through to the dedicated bot,
// as configured in the room's state events.
@ -274,27 +274,27 @@ export default class ReportEventDialog extends React.Component<IProps, IState> {
const homeServerName = SdkConfig.get()["validated_server_config"].hsName;
let subtitle;
switch (this.state.nature) {
case NATURE.DISAGREEMENT:
case Nature.Disagreement:
subtitle = _t("What this user is writing is wrong.\n" +
"This will be reported to the room moderators.");
break;
case NATURE.TOXIC:
case Nature.Toxic:
subtitle = _t("This user is displaying toxic behaviour, " +
"for instance by insulting other users or sharing " +
" adult-only content in a family-friendly room " +
" or otherwise violating the rules of this room.\n" +
"This will be reported to the room moderators.");
break;
case NATURE.ILLEGAL:
case Nature.Illegal:
subtitle = _t("This user is displaying illegal behaviour, " +
"for instance by doxing people or threatening violence.\n" +
"This will be reported to the room moderators who may escalate this to legal authorities.");
break;
case NATURE.SPAM:
case Nature.Spam:
subtitle = _t("This user is spamming the room with ads, links to ads or to propaganda.\n" +
"This will be reported to the room moderators.");
break;
case NON_STANDARD_NATURE.ADMIN:
case NonStandardValue.Admin:
if (client.isRoomEncrypted(this.props.mxEvent.getRoomId())) {
subtitle = _t("This room is dedicated to illegal or toxic content " +
"or the moderators fail to moderate illegal or toxic content.\n" +
@ -308,7 +308,7 @@ export default class ReportEventDialog extends React.Component<IProps, IState> {
{ homeserver: homeServerName });
}
break;
case NATURE.OTHER:
case Nature.Other:
subtitle = _t("Any other reason. Please describe the problem.\n" +
"This will be reported to the room moderators.");
break;
@ -327,48 +327,48 @@ export default class ReportEventDialog extends React.Component<IProps, IState> {
<div>
<StyledRadioButton
name="nature"
value = { NATURE.DISAGREEMENT }
checked = { this.state.nature == NATURE.DISAGREEMENT }
value={Nature.Disagreement}
checked={this.state.nature == Nature.Disagreement}
onChange={this.onNatureChosen}
>
{ _t('Disagree') }
</StyledRadioButton>
<StyledRadioButton
name="nature"
value = { NATURE.TOXIC }
checked = { this.state.nature == NATURE.TOXIC }
value={Nature.Toxic}
checked={this.state.nature == Nature.Toxic}
onChange={this.onNatureChosen}
>
{ _t('Toxic Behaviour') }
</StyledRadioButton>
<StyledRadioButton
name="nature"
value = { NATURE.ILLEGAL }
checked = { this.state.nature == NATURE.ILLEGAL }
value={Nature.Illegal}
checked={this.state.nature == Nature.Illegal}
onChange={this.onNatureChosen}
>
{ _t('Illegal Content') }
</StyledRadioButton>
<StyledRadioButton
name="nature"
value = { NATURE.SPAM }
checked = { this.state.nature == NATURE.SPAM }
value={Nature.Spam}
checked={this.state.nature == Nature.Spam}
onChange={this.onNatureChosen}
>
{ _t('Spam or propaganda') }
</StyledRadioButton>
<StyledRadioButton
name="nature"
value = { NON_STANDARD_NATURE.ADMIN }
checked = { this.state.nature == NON_STANDARD_NATURE.ADMIN }
value={NonStandardValue.Admin}
checked={this.state.nature == NonStandardValue.Admin}
onChange={this.onNatureChosen}
>
{ _t('Report the entire room') }
</StyledRadioButton>
<StyledRadioButton
name="nature"
value = { NATURE.OTHER }
checked = { this.state.nature == NATURE.OTHER }
value={Nature.Other}
checked={this.state.nature == Nature.Other}
onChange={this.onNatureChosen}
>
{ _t('Other') }

View file

@ -81,7 +81,7 @@ export default class UserSettingsDialog extends React.Component<IProps, IState>
this.setState({ mjolnirEnabled: newValue });
};
_getTabs() {
private getTabs() {
const tabs = [];
tabs.push(new Tab(
@ -170,7 +170,7 @@ export default class UserSettingsDialog extends React.Component<IProps, IState>
title={_t("Settings")}
>
<div className='mx_SettingsDialog_content'>
<TabbedView tabs={this._getTabs()} initialTabId={this.props.initialTabId} />
<TabbedView tabs={this.getTabs()} initialTabId={this.props.initialTabId} />
</div>
</BaseDialog>
);

View file

@ -453,13 +453,13 @@ export default class AppTile extends React.Component {
title={_t('Popout widget')}
onClick={this._onPopoutWidgetClick}
/> }
{ <ContextMenuButton
<ContextMenuButton
className="mx_AppTileMenuBar_iconButton mx_AppTileMenuBar_iconButton_menu"
label={_t("Options")}
isExpanded={this.state.menuDisplayed}
inputRef={this._contextMenuButton}
onClick={this._onContextMenuClick}
/> }
/>
</span>
</div> }
{ appTileBody }

View file

@ -63,7 +63,7 @@ const EventListSummary: React.FC<IProps> = ({
// If we are only given few events then just pass them through
if (events.length < threshold) {
return (
<li className="mx_EventListSummary" data-scroll-tokens={eventIds}>
<li className="mx_EventListSummary" data-scroll-tokens={eventIds} data-expanded={true}>
{ children }
</li>
);
@ -92,7 +92,7 @@ const EventListSummary: React.FC<IProps> = ({
}
return (
<li className="mx_EventListSummary" data-scroll-tokens={eventIds}>
<li className="mx_EventListSummary" data-scroll-tokens={eventIds} data-expanded={expanded + ""}>
<AccessibleButton className="mx_EventListSummary_toggle" onClick={toggleExpanded} aria-expanded={expanded}>
{ expanded ? _t('collapse') : _t('expand') }
</AccessibleButton>
@ -101,4 +101,8 @@ const EventListSummary: React.FC<IProps> = ({
);
};
EventListSummary.defaultProps = {
startExpanded: false,
};
export default EventListSummary;

View file

@ -22,9 +22,16 @@ import Tooltip, { Alignment } from './Tooltip';
import { _t } from "../../../languageHandler";
import { replaceableComponent } from "../../../utils/replaceableComponent";
export enum InfoTooltipKind {
Info = "info",
Warning = "warning",
}
interface ITooltipProps {
tooltip?: React.ReactNode;
className?: string;
tooltipClassName?: string;
kind?: InfoTooltipKind;
}
interface IState {
@ -53,8 +60,12 @@ export default class InfoTooltip extends React.PureComponent<ITooltipProps, ISta
};
render() {
const { tooltip, children, tooltipClassName } = this.props;
const { tooltip, children, tooltipClassName, className, kind } = this.props;
const title = _t("Information");
const iconClassName = (
(kind !== InfoTooltipKind.Warning) ?
"mx_InfoTooltip_icon_info" : "mx_InfoTooltip_icon_warning"
);
// Tooltip are forced on the right for a more natural feel to them on info icons
const tip = this.state.hover ? <Tooltip
@ -64,8 +75,12 @@ export default class InfoTooltip extends React.PureComponent<ITooltipProps, ISta
alignment={Alignment.Right}
/> : <div />;
return (
<div onMouseOver={this.onMouseOver} onMouseLeave={this.onMouseLeave} className="mx_InfoTooltip">
<span className="mx_InfoTooltip_icon" aria-label={title} />
<div
onMouseOver={this.onMouseOver}
onMouseLeave={this.onMouseLeave}
className={classNames("mx_InfoTooltip", className)}
>
<span className={classNames("mx_InfoTooltip_icon", iconClassName)} aria-label={title} />
{ children }
{ tip }
</div>

View file

@ -45,7 +45,7 @@ export default class SpellCheckLanguagesDropdown extends React.Component<SpellCh
SpellCheckLanguagesDropdownIState> {
constructor(props) {
super(props);
this._onSearchChange = this._onSearchChange.bind(this);
this.onSearchChange = this.onSearchChange.bind(this);
this.state = {
searchQuery: '',
@ -76,10 +76,8 @@ export default class SpellCheckLanguagesDropdown extends React.Component<SpellCh
}
}
_onSearchChange(search) {
this.setState({
searchQuery: search,
});
private onSearchChange(searchQuery: string) {
this.setState({ searchQuery });
}
render() {
@ -117,7 +115,7 @@ export default class SpellCheckLanguagesDropdown extends React.Component<SpellCh
id="mx_LanguageDropdown"
className={this.props.className}
onOptionChange={this.props.onOptionChange}
onSearchChange={this._onSearchChange}
onSearchChange={this.onSearchChange}
searchEnabled={true}
value={value}
label={_t("Language Dropdown")}>

View file

@ -56,7 +56,7 @@ export default class TextWithTooltip extends React.Component {
{...tooltipProps}
label={tooltip}
tooltipClassName={tooltipClass}
className={"mx_TextWithTooltip_tooltip"}
className="mx_TextWithTooltip_tooltip"
/> }
</span>
);

View file

@ -32,6 +32,8 @@ export const CATEGORY_HEADER_HEIGHT = 22;
export const EMOJI_HEIGHT = 37;
export const EMOJIS_PER_ROW = 8;
const ZERO_WIDTH_JOINER = "\u200D";
interface IProps {
selectedEmojis?: Set<string>;
showQuickReactions?: boolean;
@ -180,7 +182,7 @@ class EmojiPicker extends React.Component<IProps, IState> {
} else {
emojis = cat.id === "recent" ? this.recentlyUsed : DATA_BY_CATEGORY[cat.id];
}
emojis = emojis.filter(emoji => emoji.filterString.includes(filter));
emojis = emojis.filter(emoji => this.emojiMatchesFilter(emoji, filter));
this.memoizedDataByCategory[cat.id] = emojis;
cat.enabled = emojis.length > 0;
// The setState below doesn't re-render the header and we already have the refs for updateVisibility, so...
@ -192,6 +194,10 @@ class EmojiPicker extends React.Component<IProps, IState> {
setTimeout(this.updateVisibility, 0);
};
private emojiMatchesFilter = (emoji: IEmoji, filter: string): boolean =>
[emoji.annotation, ...emoji.shortcodes, emoji.emoticon, ...emoji.unicode.split(ZERO_WIDTH_JOINER)]
.some(x => x?.includes(filter));
private onEnterFilter = () => {
const btn = this.bodyRef.current.querySelector<HTMLButtonElement>(".mx_EmojiPicker_item");
if (btn) {

View file

@ -27,11 +27,7 @@ interface IProps {
@replaceableComponent("views.emojipicker.Preview")
class Preview extends React.PureComponent<IProps> {
render() {
const {
unicode = "",
annotation = "",
shortcodes: [shortcode = ""],
} = this.props.emoji || {};
const { unicode, annotation, shortcodes: [shortcode] } = this.props.emoji;
return (
<div className="mx_EmojiPicker_footer mx_EmojiPicker_preview">

View file

@ -0,0 +1,218 @@
/*
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 React from 'react';
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { _t, _td } from '../../../languageHandler';
import MemberAvatar from '../avatars/MemberAvatar';
import CallEventGrouper, { CallEventGrouperEvent, CustomCallState } from '../../structures/CallEventGrouper';
import AccessibleButton from '../elements/AccessibleButton';
import { CallErrorCode, CallState } from 'matrix-js-sdk/src/webrtc/call';
import InfoTooltip, { InfoTooltipKind } from '../elements/InfoTooltip';
import classNames from 'classnames';
import AccessibleTooltipButton from '../elements/AccessibleTooltipButton';
interface IProps {
mxEvent: MatrixEvent;
callEventGrouper: CallEventGrouper;
}
interface IState {
callState: CallState | CustomCallState;
silenced: boolean;
}
const TEXTUAL_STATES: Map<CallState | CustomCallState, string> = new Map([
[CallState.Connected, _td("Connected")],
[CallState.Connecting, _td("Connecting")],
]);
export default class CallEvent extends React.Component<IProps, IState> {
constructor(props: IProps) {
super(props);
this.state = {
callState: this.props.callEventGrouper.state,
silenced: false,
};
}
componentDidMount() {
this.props.callEventGrouper.addListener(CallEventGrouperEvent.StateChanged, this.onStateChanged);
this.props.callEventGrouper.addListener(CallEventGrouperEvent.SilencedChanged, this.onSilencedChanged);
}
componentWillUnmount() {
this.props.callEventGrouper.removeListener(CallEventGrouperEvent.StateChanged, this.onStateChanged);
this.props.callEventGrouper.removeListener(CallEventGrouperEvent.SilencedChanged, this.onSilencedChanged);
}
private onSilencedChanged = (newState) => {
this.setState({ silenced: newState });
};
private onStateChanged = (newState: CallState) => {
this.setState({ callState: newState });
};
private renderContent(state: CallState | CustomCallState): JSX.Element {
if (state === CallState.Ringing) {
const silenceClass = classNames({
"mx_CallEvent_iconButton": true,
"mx_CallEvent_unSilence": this.state.silenced,
"mx_CallEvent_silence": !this.state.silenced,
});
return (
<div className="mx_CallEvent_content">
<AccessibleTooltipButton
className={silenceClass}
onClick={this.props.callEventGrouper.toggleSilenced}
title={this.state.silenced ? _t("Sound on"): _t("Silence call")}
/>
<AccessibleButton
className="mx_CallEvent_content_button mx_CallEvent_content_button_reject"
onClick={this.props.callEventGrouper.rejectCall}
kind="danger"
>
<span> { _t("Decline") } </span>
</AccessibleButton>
<AccessibleButton
className="mx_CallEvent_content_button mx_CallEvent_content_button_answer"
onClick={this.props.callEventGrouper.answerCall}
kind="primary"
>
<span> { _t("Accept") } </span>
</AccessibleButton>
</div>
);
}
if (state === CallState.Ended) {
const hangupReason = this.props.callEventGrouper.hangupReason;
if ([CallErrorCode.UserHangup, "user hangup"].includes(hangupReason) || !hangupReason) {
// workaround for https://github.com/vector-im/element-web/issues/5178
// it seems Android randomly sets a reason of "user hangup" which is
// interpreted as an error code :(
// https://github.com/vector-im/riot-android/issues/2623
// Also the correct hangup code as of VoIP v1 (with underscore)
// Also, if we don't have a reason
return (
<div className="mx_CallEvent_content">
{ _t("This call has ended") }
</div>
);
}
let reason;
if (hangupReason === CallErrorCode.IceFailed) {
// We couldn't establish a connection at all
reason = _t("Could not connect media");
} else if (hangupReason === "ice_timeout") {
// We established a connection but it died
reason = _t("Connection failed");
} else if (hangupReason === CallErrorCode.NoUserMedia) {
// The other side couldn't open capture devices
reason = _t("Their device couldn't start the camera or microphone");
} else if (hangupReason === "unknown_error") {
// An error code the other side doesn't have a way to express
// (as opposed to an error code they gave but we don't know about,
// in which case we show the error code)
reason = _t("An unknown error occurred");
} else if (hangupReason === CallErrorCode.InviteTimeout) {
reason = _t("No answer");
} else if (hangupReason === CallErrorCode.UserBusy) {
reason = _t("The user you called is busy.");
} else {
reason = _t('Unknown failure: %(reason)s)', { reason: hangupReason });
}
return (
<div className="mx_CallEvent_content">
<InfoTooltip
tooltip={reason}
className="mx_CallEvent_content_tooltip"
kind={InfoTooltipKind.Warning}
/>
{ _t("This call has failed") }
</div>
);
}
if (Array.from(TEXTUAL_STATES.keys()).includes(state)) {
return (
<div className="mx_CallEvent_content">
{ TEXTUAL_STATES.get(state) }
</div>
);
}
if (state === CustomCallState.Missed) {
return (
<div className="mx_CallEvent_content">
{ _t("You missed this call") }
<AccessibleButton
className="mx_CallEvent_content_button mx_CallEvent_content_button_callBack"
onClick={this.props.callEventGrouper.callBack}
kind="primary"
>
<span> { _t("Call back") } </span>
</AccessibleButton>
</div>
);
}
return (
<div className="mx_CallEvent_content">
{ _t("The call is in an unknown state!") }
</div>
);
}
render() {
const event = this.props.mxEvent;
const sender = event.sender ? event.sender.name : event.getSender();
const isVoice = this.props.callEventGrouper.isVoice;
const callType = isVoice ? _t("Voice call") : _t("Video call");
const content = this.renderContent(this.state.callState);
const className = classNames({
mx_CallEvent: true,
mx_CallEvent_voice: isVoice,
mx_CallEvent_video: !isVoice,
});
return (
<div className={className}>
<div className="mx_CallEvent_info">
<MemberAvatar
member={event.sender}
width={32}
height={32}
/>
<div className="mx_CallEvent_info_basic">
<div className="mx_CallEvent_sender">
{ sender }
</div>
<div className="mx_CallEvent_type">
<div className="mx_CallEvent_type_icon"></div>
{ callType }
</div>
</div>
</div>
{ content }
</div>
);
}
}

View file

@ -0,0 +1,109 @@
/*
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 { MatrixEvent } from "matrix-js-sdk/src";
import { MediaEventHelper } from "../../../utils/MediaEventHelper";
import React, { createRef } from "react";
import { RovingAccessibleTooltipButton } from "../../../accessibility/RovingTabIndex";
import Spinner from "../elements/Spinner";
import classNames from "classnames";
import { _t } from "../../../languageHandler";
import { replaceableComponent } from "../../../utils/replaceableComponent";
interface IProps {
mxEvent: MatrixEvent;
// XXX: It can take a cycle or two for the MessageActionBar to have all the props/setup
// required to get us a MediaEventHelper, so we use a getter function instead to prod for
// one.
mediaEventHelperGet: () => MediaEventHelper;
}
interface IState {
loading: boolean;
blob?: Blob;
}
@replaceableComponent("views.messages.DownloadActionButton")
export default class DownloadActionButton extends React.PureComponent<IProps, IState> {
private iframe: React.RefObject<HTMLIFrameElement> = createRef();
public constructor(props: IProps) {
super(props);
this.state = {
loading: false,
};
}
private onDownloadClick = async () => {
if (this.state.loading) return;
this.setState({ loading: true });
if (this.state.blob) {
// Cheat and trigger a download, again.
return this.onFrameLoad();
}
const blob = await this.props.mediaEventHelperGet().sourceBlob.value;
this.setState({ blob });
};
private onFrameLoad = () => {
this.setState({ loading: false });
// we aren't showing the iframe, so we can send over the bare minimum styles and such.
this.iframe.current.contentWindow.postMessage({
imgSrc: "", // no image
imgStyle: null,
style: "",
blob: this.state.blob,
download: this.props.mediaEventHelperGet().fileName,
textContent: "",
auto: true, // autodownload
}, '*');
};
public render() {
let spinner: JSX.Element;
if (this.state.loading) {
spinner = <Spinner w={18} h={18} />;
}
const classes = classNames({
'mx_MessageActionBar_maskButton': true,
'mx_MessageActionBar_downloadButton': true,
'mx_MessageActionBar_downloadSpinnerButton': !!spinner,
});
return <RovingAccessibleTooltipButton
className={classes}
title={spinner ? _t("Downloading") : _t("Download")}
onClick={this.onDownloadClick}
disabled={!!spinner}
>
{ spinner }
{ this.state.blob && <iframe
src="usercontent/" // XXX: Like MFileBody, this should come from the skin
ref={this.iframe}
onLoad={this.onFrameLoad}
sandbox="allow-scripts allow-downloads allow-downloads-without-user-activation"
style={{ display: "none" }}
/> }
</RovingAccessibleTooltipButton>;
}
}

View file

@ -0,0 +1,43 @@
/*
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 { MatrixEvent } from "matrix-js-sdk/src";
import { TileShape } from "../rooms/EventTile";
import { MediaEventHelper } from "../../../utils/MediaEventHelper";
import EditorStateTransfer from "../../../utils/EditorStateTransfer";
import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
export interface IBodyProps {
mxEvent: MatrixEvent;
/* a list of words to highlight */
highlights: string[];
/* link URL for the highlights */
highlightLink: string;
/* callback called when dynamic content in events are loaded */
onHeightChanged: () => void;
showUrlPreview?: boolean;
tileShape: TileShape;
maxImageHeight?: number;
replacingEventId?: string;
editState?: EditorStateTransfer;
onMessageAllowed: () => void; // TODO: Docs
permalinkCreator: RoomPermalinkCreator;
mediaEventHelper: MediaEventHelper;
}

View file

@ -0,0 +1,21 @@
/*
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 { MediaEventHelper } from "../../../utils/MediaEventHelper";
export interface IMediaBody {
getMediaHelper(): MediaEventHelper;
}

View file

@ -15,30 +15,23 @@ limitations under the License.
*/
import React from "react";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { Playback } from "../../../voice/Playback";
import MFileBody from "./MFileBody";
import InlineSpinner from '../elements/InlineSpinner';
import { _t } from "../../../languageHandler";
import { mediaFromContent } from "../../../customisations/Media";
import { decryptFile } from "../../../utils/DecryptFile";
import { IMediaEventContent } from "../../../customisations/models/IMediaEventContent";
import AudioPlayer from "../audio_messages/AudioPlayer";
interface IProps {
mxEvent: MatrixEvent;
}
import { IMediaEventContent } from "../../../customisations/models/IMediaEventContent";
import MFileBody from "./MFileBody";
import { IBodyProps } from "./IBodyProps";
interface IState {
error?: Error;
playback?: Playback;
decryptedBlob?: Blob;
}
@replaceableComponent("views.messages.MAudioBody")
export default class MAudioBody extends React.PureComponent<IProps, IState> {
constructor(props: IProps) {
export default class MAudioBody extends React.PureComponent<IBodyProps, IState> {
constructor(props: IBodyProps) {
super(props);
this.state = {};
@ -46,33 +39,34 @@ export default class MAudioBody extends React.PureComponent<IProps, IState> {
public async componentDidMount() {
let buffer: ArrayBuffer;
const content: IMediaEventContent = this.props.mxEvent.getContent();
const media = mediaFromContent(content);
if (media.isEncrypted) {
try {
const blob = await decryptFile(content.file);
try {
const blob = await this.props.mediaEventHelper.sourceBlob.value;
buffer = await blob.arrayBuffer();
this.setState({ decryptedBlob: blob });
} catch (e) {
this.setState({ error: e });
console.warn("Unable to decrypt audio message", e);
return; // stop processing the audio file
}
} else {
try {
buffer = await media.downloadSource().then(r => r.blob()).then(r => r.arrayBuffer());
} catch (e) {
this.setState({ error: e });
console.warn("Unable to download audio message", e);
console.warn("Unable to decrypt/download audio message", e);
return; // stop processing the audio file
}
}
// We should have a buffer to work with now: let's set it up
const playback = new Playback(buffer);
// Note: we don't actually need a waveform to render an audio event, but voice messages do.
const content = this.props.mxEvent.getContent<IMediaEventContent>();
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
const playback = new Playback(buffer, waveform);
playback.clockInfo.populatePlaceholdersFrom(this.props.mxEvent);
this.setState({ playback });
// Note: the RecordingPlayback component will handle preparing the Playback class for us.
// Note: the components later on will handle preparing the Playback class for us.
}
public componentWillUnmount() {
@ -103,7 +97,7 @@ export default class MAudioBody extends React.PureComponent<IProps, IState> {
return (
<span className="mx_MAudioBody">
<AudioPlayer playback={this.state.playback} mediaName={this.props.mxEvent.getContent().body} />
<MFileBody {...this.props} decryptedBlob={this.state.decryptedBlob} showGenericPlaceholder={false} />
{ this.props.tileShape && <MFileBody {...this.props} showGenericPlaceholder={false} /> }
</span>
);
}

View file

@ -15,26 +15,29 @@ limitations under the License.
*/
import React, { createRef } from 'react';
import PropTypes from 'prop-types';
import filesize from 'filesize';
import { _t } from '../../../languageHandler';
import { decryptFile } from '../../../utils/DecryptFile';
import Modal from '../../../Modal';
import AccessibleButton from "../elements/AccessibleButton";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { mediaFromContent } from "../../../customisations/Media";
import ErrorDialog from "../dialogs/ErrorDialog";
import { TileShape } from "../rooms/EventTile";
import { IContent } from "matrix-js-sdk/src";
import { IMediaEventContent } from "../../../customisations/models/IMediaEventContent";
import { IBodyProps } from "./IBodyProps";
let downloadIconUrl; // cached copy of the download.svg asset for the sandboxed iframe later on
export let DOWNLOAD_ICON_URL; // cached copy of the download.svg asset for the sandboxed iframe later on
async function cacheDownloadIcon() {
if (downloadIconUrl) return; // cached already
if (DOWNLOAD_ICON_URL) return; // cached already
// eslint-disable-next-line @typescript-eslint/no-var-requires
const svg = await fetch(require("../../../../res/img/download.svg")).then(r => r.text());
downloadIconUrl = "data:image/svg+xml;base64," + window.btoa(svg);
DOWNLOAD_ICON_URL = "data:image/svg+xml;base64," + window.btoa(svg);
}
// Cache the asset immediately
// noinspection JSIgnoredPromiseFromCall
cacheDownloadIcon();
// User supplied content can contain scripts, we have to be careful that
@ -72,7 +75,7 @@ cacheDownloadIcon();
* @param {HTMLElement} element The element to get the current style of.
* @return {string} The CSS style encoded as a string.
*/
function computedStyle(element) {
export function computedStyle(element: HTMLElement) {
if (!element) {
return "";
}
@ -98,7 +101,7 @@ function computedStyle(element) {
* @param {boolean} withSize Whether to include size information. Default true.
* @return {string} the human readable link text for the attachment.
*/
export function presentableTextForFile(content, withSize = true) {
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
@ -119,53 +122,48 @@ export function presentableTextForFile(content, withSize = true) {
return linkText;
}
@replaceableComponent("views.messages.MFileBody")
export default class MFileBody extends React.Component {
static propTypes = {
/* the MatrixEvent to show */
mxEvent: PropTypes.object.isRequired,
/* already decrypted blob */
decryptedBlob: PropTypes.object,
/* called when the download link iframe is shown */
onHeightChanged: PropTypes.func,
/* the shape of the tile, used */
tileShape: PropTypes.string,
interface IProps extends IBodyProps {
/* whether or not to show the default placeholder for the file. Defaults to true. */
showGenericPlaceholder: PropTypes.bool,
};
showGenericPlaceholder: boolean;
}
interface IState {
decryptedBlob?: Blob;
}
@replaceableComponent("views.messages.MFileBody")
export default class MFileBody extends React.Component<IProps, IState> {
static defaultProps = {
showGenericPlaceholder: true,
};
constructor(props) {
private iframe: React.RefObject<HTMLIFrameElement> = createRef();
private dummyLink: React.RefObject<HTMLAnchorElement> = createRef();
private userDidClick = false;
public constructor(props: IProps) {
super(props);
this.state = {
decryptedBlob: (this.props.decryptedBlob ? this.props.decryptedBlob : null),
};
this._iframe = createRef();
this._dummyLink = createRef();
this.state = {};
}
_getContentUrl() {
private getContentUrl(): string {
const media = mediaFromContent(this.props.mxEvent.getContent());
return media.srcHttp;
}
componentDidUpdate(prevProps, prevState) {
public componentDidUpdate(prevProps, prevState) {
if (this.props.onHeightChanged && !prevState.decryptedBlob && this.state.decryptedBlob) {
this.props.onHeightChanged();
}
}
render() {
const content = this.props.mxEvent.getContent();
public render() {
const content = this.props.mxEvent.getContent<IMediaEventContent>();
const text = presentableTextForFile(content);
const isEncrypted = content.file !== undefined;
const isEncrypted = this.props.mediaEventHelper.media.isEncrypted;
const fileName = content.body && content.body.length > 0 ? content.body : _t("Attachment");
const contentUrl = this._getContentUrl();
const contentUrl = this.getContentUrl();
const fileSize = content.info ? content.info.size : null;
const fileType = content.info ? content.info.mimetype : "application/octet-stream";
@ -181,30 +179,26 @@ export default class MFileBody extends React.Component {
);
}
const showDownloadLink = this.props.tileShape || !this.props.showGenericPlaceholder;
if (isEncrypted) {
if (this.state.decryptedBlob === null) {
if (!this.state.decryptedBlob) {
// Need to decrypt the attachment
// Wait for the user to click on the link before downloading
// and decrypting the attachment.
let decrypting = false;
const decrypt = (e) => {
if (decrypting) {
return false;
}
decrypting = true;
decryptFile(content.file).then((blob) => {
const decrypt = async () => {
try {
this.userDidClick = true;
this.setState({
decryptedBlob: blob,
decryptedBlob: await this.props.mediaEventHelper.sourceBlob.value,
});
}).catch((err) => {
} catch (err) {
console.warn("Unable to decrypt attachment: ", err);
Modal.createTrackedDialog('Error decrypting attachment', '', ErrorDialog, {
title: _t("Error"),
description: _t("Error decrypting attachment"),
});
}).finally(() => {
decrypting = false;
});
}
};
// This button should actually Download because usercontent/ will try to click itself
@ -212,11 +206,11 @@ export default class MFileBody extends React.Component {
return (
<span className="mx_MFileBody">
{ placeholder }
<div className="mx_MFileBody_download">
{ showDownloadLink && <div className="mx_MFileBody_download">
<AccessibleButton onClick={decrypt}>
{ _t("Decrypt %(text)s", { text: text }) }
</AccessibleButton>
</div>
</div> }
</span>
);
}
@ -224,9 +218,9 @@ export default class MFileBody extends React.Component {
// When the iframe loads we tell it to render a download link
const onIframeLoad = (ev) => {
ev.target.contentWindow.postMessage({
imgSrc: downloadIconUrl,
imgSrc: DOWNLOAD_ICON_URL,
imgStyle: null, // it handles this internally for us. Useful if a downstream changes the icon.
style: computedStyle(this._dummyLink.current),
style: computedStyle(this.dummyLink.current),
blob: this.state.decryptedBlob,
// Set a download attribute for encrypted files so that the file
// will have the correct name when the user tries to download it.
@ -234,7 +228,7 @@ export default class MFileBody extends React.Component {
download: fileName,
textContent: _t("Download %(text)s", { text: text }),
// only auto-download if a user triggered this iframe explicitly
auto: !this.props.decryptedBlob,
auto: this.userDidClick,
}, "*");
};
@ -244,21 +238,21 @@ export default class MFileBody extends React.Component {
return (
<span className="mx_MFileBody">
{ placeholder }
<div className="mx_MFileBody_download">
{ showDownloadLink && <div className="mx_MFileBody_download">
<div style={{ display: "none" }}>
{ /*
* Add dummy copy of the "a" tag
* We'll use it to learn how the download link
* would have been styled if it was rendered inline.
*/ }
<a ref={this._dummyLink} />
<a ref={this.dummyLink} />
</div>
<iframe
src={url}
onLoad={onIframeLoad}
ref={this._iframe}
ref={this.iframe}
sandbox="allow-scripts allow-downloads allow-downloads-without-user-activation" />
</div>
</div> }
</span>
);
} else if (contentUrl) {
@ -289,7 +283,7 @@ export default class MFileBody extends React.Component {
// Start a fetch for the download
// Based upon https://stackoverflow.com/a/49500465
fetch(contentUrl).then((response) => response.blob()).then((blob) => {
this.props.mediaEventHelper.sourceBlob.value.then((blob) => {
const blobUrl = URL.createObjectURL(blob);
// We have to create an anchor to download the file
@ -306,36 +300,20 @@ export default class MFileBody extends React.Component {
downloadProps["download"] = fileName;
}
// If the attachment is not encrypted then we check whether we
// are being displayed in the room timeline or in a list of
// files in the right hand side of the screen.
if (this.props.tileShape === TileShape.FileGrid) {
return (
<span className="mx_MFileBody">
{ placeholder }
<div className="mx_MFileBody_download">
<a className="mx_MFileBody_downloadLink" {...downloadProps}>
{ fileName }
</a>
<div className="mx_MImageBody_size">
{ content.info && content.info.size ? filesize(content.info.size) : "" }
</div>
</div>
</span>
);
} else {
return (
<span className="mx_MFileBody">
{placeholder}
<div className="mx_MFileBody_download">
{ showDownloadLink && <div className="mx_MFileBody_download">
<a {...downloadProps}>
<span className="mx_MFileBody_download_icon" />
{ _t("Download %(text)s", { text: text }) }
</a>
</div>
{ this.props.tileShape === TileShape.FileGrid && <div className="mx_MImageBody_size">
{ content.info && content.info.size ? filesize(content.info.size) : "" }
</div> }
</div> }
</span>
);
}
} else {
const extra = text ? (': ' + text) : '';
return <span className="mx_MFileBody">

View file

@ -1,6 +1,5 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2018 New Vector Ltd
Copyright 2015 - 2021 The Matrix.org Foundation C.I.C.
Copyright 2018, 2019 Michael Telatynski <7t3chguy@gmail.com>
Licensed under the Apache License, Version 2.0 (the "License");
@ -21,7 +20,6 @@ import { Blurhash } from "react-blurhash";
import MFileBody from './MFileBody';
import Modal from '../../../Modal';
import { decryptFile } from '../../../utils/DecryptFile';
import { _t } from '../../../languageHandler';
import SettingsStore from "../../../settings/SettingsStore";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
@ -29,24 +27,10 @@ import InlineSpinner from '../elements/InlineSpinner';
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { mediaFromContent } from "../../../customisations/Media";
import { BLURHASH_FIELD } from "../../../ContentMessages";
import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
import { RoomPermalinkCreator } from '../../../utils/permalinks/Permalinks';
import { IMediaEventContent } from '../../../customisations/models/IMediaEventContent';
import ImageView from '../elements/ImageView';
import { SyncState } from 'matrix-js-sdk/src/sync.api';
export interface IProps {
/* the MatrixEvent to show */
mxEvent: MatrixEvent;
/* called when the image has loaded */
onHeightChanged(): void;
/* the maximum image height to use */
maxImageHeight?: number;
/* the permalinkCreator */
permalinkCreator?: RoomPermalinkCreator;
}
import { IBodyProps } from "./IBodyProps";
interface IState {
decryptedUrl?: string;
@ -64,12 +48,12 @@ interface IState {
}
@replaceableComponent("views.messages.MImageBody")
export default class MImageBody extends React.Component<IProps, IState> {
export default class MImageBody extends React.Component<IBodyProps, IState> {
static contextType = MatrixClientContext;
private unmounted = true;
private image = createRef<HTMLImageElement>();
constructor(props: IProps) {
constructor(props: IBodyProps) {
super(props);
this.state = {
@ -257,38 +241,23 @@ export default class MImageBody extends React.Component<IProps, IState> {
}
}
private downloadImage(): void {
const content = this.props.mxEvent.getContent();
if (content.file !== undefined && this.state.decryptedUrl === null) {
let thumbnailPromise = Promise.resolve(null);
if (content.info && content.info.thumbnail_file) {
thumbnailPromise = decryptFile(
content.info.thumbnail_file,
).then(function(blob) {
return URL.createObjectURL(blob);
});
}
let decryptedBlob;
thumbnailPromise.then((thumbnailUrl) => {
return decryptFile(content.file).then(function(blob) {
decryptedBlob = blob;
return URL.createObjectURL(blob);
}).then((contentUrl) => {
if (this.unmounted) return;
private async downloadImage() {
if (this.props.mediaEventHelper.media.isEncrypted && this.state.decryptedUrl === null) {
try {
const thumbnailUrl = await this.props.mediaEventHelper.thumbnailUrl.value;
this.setState({
decryptedUrl: contentUrl,
decryptedUrl: await this.props.mediaEventHelper.sourceUrl.value,
decryptedThumbnailUrl: thumbnailUrl,
decryptedBlob: decryptedBlob,
decryptedBlob: await this.props.mediaEventHelper.sourceBlob.value,
});
});
}).catch((err) => {
} catch (err) {
if (this.unmounted) return;
console.warn("Unable to decrypt attachment: ", err);
// Set a placeholder image when we can't decrypt the image.
this.setState({
error: err,
});
});
}
}
}
@ -300,29 +269,15 @@ export default class MImageBody extends React.Component<IProps, IState> {
localStorage.getItem("mx_ShowImage_" + this.props.mxEvent.getId()) === "true";
if (showImage) {
// Don't download anything becaue we don't want to display anything.
// noinspection JSIgnoredPromiseFromCall
this.downloadImage();
this.setState({ showImage: true });
}
this._afterComponentDidMount();
}
// To be overridden by subclasses (e.g. MStickerBody) for further
// initialisation after componentDidMount
_afterComponentDidMount() {
} // else don't download anything because we don't want to display anything.
}
componentWillUnmount() {
this.unmounted = true;
this.context.removeListener('sync', this.onClientSync);
if (this.state.decryptedUrl) {
URL.revokeObjectURL(this.state.decryptedUrl);
}
if (this.state.decryptedThumbnailUrl) {
URL.revokeObjectURL(this.state.decryptedThumbnailUrl);
}
}
protected messageContent(
@ -440,9 +395,9 @@ export default class MImageBody extends React.Component<IProps, IState> {
protected getPlaceholder(width: number, height: number): JSX.Element {
const blurhash = this.props.mxEvent.getContent().info[BLURHASH_FIELD];
if (blurhash) return <Blurhash hash={blurhash} width={width} height={height} />;
return <div className="mx_MImageBody_thumbnail_spinner">
return (
<InlineSpinner w={32} h={32} />
</div>;
);
}
// Overidden by MStickerBody
@ -452,7 +407,10 @@ export default class MImageBody extends React.Component<IProps, IState> {
// Overidden by MStickerBody
protected getFileBody(): JSX.Element {
return <MFileBody {...this.props} decryptedBlob={this.state.decryptedBlob} showGenericPlaceholder={false} />;
// We only ever need the download bar if we're appearing outside of the timeline
if (this.props.tileShape) {
return <MFileBody {...this.props} showGenericPlaceholder={false} />;
}
}
render() {

View file

@ -33,7 +33,7 @@ export default class MImageReplyBody extends MImageBody {
// Don't show "Download this_file.png ..."
public getFileBody(): JSX.Element {
return presentableTextForFile(this.props.mxEvent.getContent());
return <>{ presentableTextForFile(this.props.mxEvent.getContent()) }</>;
}
render() {

View file

@ -1,6 +1,5 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2015 - 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -18,21 +17,15 @@ limitations under the License.
import React from 'react';
import { decode } from "blurhash";
import MFileBody from './MFileBody';
import { decryptFile } from '../../../utils/DecryptFile';
import { _t } from '../../../languageHandler';
import SettingsStore from "../../../settings/SettingsStore";
import InlineSpinner from '../elements/InlineSpinner';
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { mediaFromContent } from "../../../customisations/Media";
import { BLURHASH_FIELD } from "../../../ContentMessages";
interface IProps {
/* the MatrixEvent to show */
mxEvent: any;
/* called when the video has loaded */
onHeightChanged: () => void;
}
import { IMediaEventContent } from "../../../customisations/models/IMediaEventContent";
import { IBodyProps } from "./IBodyProps";
import MFileBody from "./MFileBody";
interface IState {
decryptedUrl?: string;
@ -45,11 +38,12 @@ interface IState {
}
@replaceableComponent("views.messages.MVideoBody")
export default class MVideoBody extends React.PureComponent<IProps, IState> {
export default class MVideoBody extends React.PureComponent<IBodyProps, IState> {
private videoRef = React.createRef<HTMLVideoElement>();
constructor(props) {
super(props);
this.state = {
fetchingData: false,
decryptedUrl: null,
@ -97,7 +91,7 @@ export default class MVideoBody extends React.PureComponent<IProps, IState> {
}
private getThumbUrl(): string|null {
const content = this.props.mxEvent.getContent();
const content = this.props.mxEvent.getContent<IMediaEventContent>();
const media = mediaFromContent(content);
if (media.isEncrypted && this.state.decryptedThumbnailUrl) {
@ -139,7 +133,7 @@ export default class MVideoBody extends React.PureComponent<IProps, IState> {
posterLoading: true,
});
const content = this.props.mxEvent.getContent();
const content = this.props.mxEvent.getContent<IMediaEventContent>();
const media = mediaFromContent(content);
if (media.hasThumbnail) {
const image = new Image();
@ -152,30 +146,22 @@ export default class MVideoBody extends React.PureComponent<IProps, IState> {
async componentDidMount() {
const autoplay = SettingsStore.getValue("autoplayGifsAndVideos") as boolean;
const content = this.props.mxEvent.getContent();
this.loadBlurhash();
if (content.file !== undefined && this.state.decryptedUrl === null) {
let thumbnailPromise = Promise.resolve(null);
if (content?.info?.thumbnail_file) {
thumbnailPromise = decryptFile(content.info.thumbnail_file)
.then(blob => URL.createObjectURL(blob));
}
if (this.props.mediaEventHelper.media.isEncrypted && this.state.decryptedUrl === null) {
try {
const thumbnailUrl = await thumbnailPromise;
const thumbnailUrl = await this.props.mediaEventHelper.thumbnailUrl.value;
if (autoplay) {
console.log("Preloading video");
const decryptedBlob = await decryptFile(content.file);
const contentUrl = URL.createObjectURL(decryptedBlob);
this.setState({
decryptedUrl: contentUrl,
decryptedUrl: await this.props.mediaEventHelper.sourceUrl.value,
decryptedThumbnailUrl: thumbnailUrl,
decryptedBlob: decryptedBlob,
decryptedBlob: await this.props.mediaEventHelper.sourceBlob.value,
});
this.props.onHeightChanged();
} else {
console.log("NOT preloading video");
const content = this.props.mxEvent.getContent<IMediaEventContent>();
this.setState({
// For Chrome and Electron, we need to set some non-empty `src` to
// enable the play button. Firefox does not seem to care either
@ -195,15 +181,6 @@ export default class MVideoBody extends React.PureComponent<IProps, IState> {
}
}
componentWillUnmount() {
if (this.state.decryptedUrl) {
URL.revokeObjectURL(this.state.decryptedUrl);
}
if (this.state.decryptedThumbnailUrl) {
URL.revokeObjectURL(this.state.decryptedThumbnailUrl);
}
}
private videoOnPlay = async () => {
if (this.hasContentUrl() || this.state.fetchingData || this.state.error) {
// We have the file, we are fetching the file, or there is an error.
@ -213,18 +190,15 @@ export default class MVideoBody extends React.PureComponent<IProps, IState> {
// To stop subsequent download attempts
fetchingData: true,
});
const content = this.props.mxEvent.getContent();
if (!content.file) {
if (!this.props.mediaEventHelper.media.isEncrypted) {
this.setState({
error: "No file given in content",
});
return;
}
const decryptedBlob = await decryptFile(content.file);
const contentUrl = URL.createObjectURL(decryptedBlob);
this.setState({
decryptedUrl: contentUrl,
decryptedBlob: decryptedBlob,
decryptedUrl: await this.props.mediaEventHelper.sourceUrl.value,
decryptedBlob: await this.props.mediaEventHelper.sourceBlob.value,
fetchingData: false,
}, () => {
if (!this.videoRef.current) return;
@ -295,7 +269,7 @@ export default class MVideoBody extends React.PureComponent<IProps, IState> {
onPlay={this.videoOnPlay}
>
</video>
<MFileBody {...this.props} decryptedBlob={this.state.decryptedBlob} showGenericPlaceholder={false} />
{ this.props.tileShape && <MFileBody {...this.props} showGenericPlaceholder={false} /> }
</span>
);
}

View file

@ -15,73 +15,16 @@ limitations under the License.
*/
import React from "react";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { Playback } from "../../../voice/Playback";
import MFileBody from "./MFileBody";
import InlineSpinner from '../elements/InlineSpinner';
import { _t } from "../../../languageHandler";
import { mediaFromContent } from "../../../customisations/Media";
import { decryptFile } from "../../../utils/DecryptFile";
import RecordingPlayback from "../audio_messages/RecordingPlayback";
import { IMediaEventContent } from "../../../customisations/models/IMediaEventContent";
import { TileShape } from "../rooms/EventTile";
interface IProps {
mxEvent: MatrixEvent;
tileShape?: TileShape;
}
interface IState {
error?: Error;
playback?: Playback;
decryptedBlob?: Blob;
}
import MAudioBody from "./MAudioBody";
import MFileBody from "./MFileBody";
@replaceableComponent("views.messages.MVoiceMessageBody")
export default class MVoiceMessageBody extends React.PureComponent<IProps, IState> {
constructor(props: IProps) {
super(props);
this.state = {};
}
public async componentDidMount() {
let buffer: ArrayBuffer;
const content: IMediaEventContent = this.props.mxEvent.getContent();
const media = mediaFromContent(content);
if (media.isEncrypted) {
try {
const blob = await decryptFile(content.file);
buffer = await blob.arrayBuffer();
this.setState({ decryptedBlob: blob });
} catch (e) {
this.setState({ error: e });
console.warn("Unable to decrypt voice message", e);
return; // stop processing the audio file
}
} else {
try {
buffer = await media.downloadSource().then(r => r.blob()).then(r => r.arrayBuffer());
} catch (e) {
this.setState({ error: e });
console.warn("Unable to download voice message", e);
return; // stop processing the audio file
}
}
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
const playback = new Playback(buffer, waveform);
this.setState({ playback });
// Note: the RecordingPlayback component will handle preparing the Playback class for us.
}
public componentWillUnmount() {
this.state.playback?.destroy();
}
export default class MVoiceMessageBody extends MAudioBody {
// A voice message is an audio file but rendered in a special way.
public render() {
if (this.state.error) {
// TODO: @@TR: Verify error state
@ -106,7 +49,7 @@ export default class MVoiceMessageBody extends React.PureComponent<IProps, IStat
return (
<span className="mx_MVoiceMessageBody">
<RecordingPlayback playback={this.state.playback} tileShape={this.props.tileShape} />
<MFileBody {...this.props} decryptedBlob={this.state.decryptedBlob} showGenericPlaceholder={false} />
{ this.props.tileShape && <MFileBody {...this.props} showGenericPlaceholder={false} /> }
</span>
);
}

View file

@ -15,18 +15,14 @@ limitations under the License.
*/
import React from "react";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import MAudioBody from "./MAudioBody";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import SettingsStore from "../../../settings/SettingsStore";
import MVoiceMessageBody from "./MVoiceMessageBody";
interface IProps {
mxEvent: MatrixEvent;
}
import { IBodyProps } from "./IBodyProps";
@replaceableComponent("views.messages.MVoiceOrAudioBody")
export default class MVoiceOrAudioBody extends React.PureComponent<IProps> {
export default class MVoiceOrAudioBody extends React.PureComponent<IBodyProps> {
public render() {
// MSC2516 is a legacy identifier. See https://github.com/matrix-org/matrix-doc/pull/3245
const isVoiceMessage = !!this.props.mxEvent.getContent()['org.matrix.msc2516.voice']

View file

@ -32,6 +32,8 @@ import { replaceableComponent } from "../../../utils/replaceableComponent";
import { canCancel } from "../context_menus/MessageContextMenu";
import Resend from "../../../Resend";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { MediaEventHelper } from "../../../utils/MediaEventHelper";
import DownloadActionButton from "./DownloadActionButton";
const OptionsButton = ({ mxEvent, getTile, getReplyThread, permalinkCreator, onFocusChange }) => {
const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu();
@ -267,6 +269,15 @@ export default class MessageActionBar extends React.PureComponent {
key="react"
/>);
}
// XXX: Assuming that the underlying tile will be a media event if it is eligible media.
if (MediaEventHelper.isEligible(this.props.mxEvent)) {
toolbarOpts.splice(0, 0, <DownloadActionButton
mxEvent={this.props.mxEvent}
mediaEventHelperGet={() => this.props.getTile?.().getMediaHelper?.()}
key="download"
/>);
}
}
if (allowCancel) {

View file

@ -1,5 +1,5 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2015 - 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -15,90 +15,98 @@ limitations under the License.
*/
import React, { createRef } from 'react';
import PropTypes from 'prop-types';
import * as sdk from '../../../index';
import SettingsStore from "../../../settings/SettingsStore";
import { Mjolnir } from "../../../mjolnir/Mjolnir";
import RedactedBody from "./RedactedBody";
import UnknownBody from "./UnknownBody";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { IMediaBody } from "./IMediaBody";
import { IOperableEventTile } from "../context_menus/MessageContextMenu";
import { MediaEventHelper } from "../../../utils/MediaEventHelper";
import { ReactAnyComponent } from "../../../@types/common";
import { EventType, MsgType } from "matrix-js-sdk/src/@types/event";
import { IBodyProps } from "./IBodyProps";
@replaceableComponent("views.messages.MessageEvent")
export default class MessageEvent extends React.Component {
static propTypes = {
/* the MatrixEvent to show */
mxEvent: PropTypes.object.isRequired,
/* a list of words to highlight */
highlights: PropTypes.array,
/* link URL for the highlights */
highlightLink: PropTypes.string,
/* should show URL previews for this event */
showUrlPreview: PropTypes.bool,
/* callback called when dynamic content in events are loaded */
onHeightChanged: PropTypes.func,
/* the shape of the tile, used */
tileShape: PropTypes.string, // TODO: Use TileShape enum
/* the maximum image height to use, if the event is an image */
maxImageHeight: PropTypes.number,
// onMessageAllowed is handled internally
interface IProps extends Omit<IBodyProps, "onMessageAllowed"> {
/* overrides for the msgtype-specific components, used by ReplyTile to override file rendering */
overrideBodyTypes: PropTypes.object,
overrideEventTypes: PropTypes.object,
/* the permalinkCreator */
permalinkCreator: PropTypes.object,
};
constructor(props) {
super(props);
this._body = createRef();
overrideBodyTypes?: Record<string, React.Component>;
overrideEventTypes?: Record<string, React.Component>;
}
getEventTileOps = () => {
return this._body.current && this._body.current.getEventTileOps ? this._body.current.getEventTileOps() : null;
};
@replaceableComponent("views.messages.MessageEvent")
export default class MessageEvent extends React.Component<IProps> implements IMediaBody, IOperableEventTile {
private body: React.RefObject<React.Component | IOperableEventTile> = createRef();
private mediaHelper: MediaEventHelper;
onTileUpdate = () => {
this.forceUpdate();
};
public constructor(props: IProps) {
super(props);
render() {
const bodyTypes = {
'm.text': sdk.getComponent('messages.TextualBody'),
'm.notice': sdk.getComponent('messages.TextualBody'),
'm.emote': sdk.getComponent('messages.TextualBody'),
'm.image': sdk.getComponent('messages.MImageBody'),
'm.file': sdk.getComponent('messages.MFileBody'),
'm.audio': sdk.getComponent('messages.MVoiceOrAudioBody'),
'm.video': sdk.getComponent('messages.MVideoBody'),
if (MediaEventHelper.isEligible(this.props.mxEvent)) {
this.mediaHelper = new MediaEventHelper(this.props.mxEvent);
}
}
public componentWillUnmount() {
this.mediaHelper?.destroy();
}
public componentDidUpdate(prevProps: Readonly<IProps>) {
if (this.props.mxEvent !== prevProps.mxEvent && MediaEventHelper.isEligible(this.props.mxEvent)) {
this.mediaHelper?.destroy();
this.mediaHelper = new MediaEventHelper(this.props.mxEvent);
}
}
private get bodyTypes(): Record<string, React.Component> {
return {
[MsgType.Text]: sdk.getComponent('messages.TextualBody'),
[MsgType.Notice]: sdk.getComponent('messages.TextualBody'),
[MsgType.Emote]: sdk.getComponent('messages.TextualBody'),
[MsgType.Image]: sdk.getComponent('messages.MImageBody'),
[MsgType.File]: sdk.getComponent('messages.MFileBody'),
[MsgType.Audio]: sdk.getComponent('messages.MVoiceOrAudioBody'),
[MsgType.Video]: sdk.getComponent('messages.MVideoBody'),
...(this.props.overrideBodyTypes || {}),
};
const evTypes = {
'm.sticker': sdk.getComponent('messages.MStickerBody'),
}
private get evTypes(): Record<string, React.Component> {
return {
[EventType.Sticker]: sdk.getComponent('messages.MStickerBody'),
...(this.props.overrideEventTypes || {}),
};
}
public getEventTileOps = () => {
return (this.body.current as IOperableEventTile)?.getEventTileOps?.() || null;
};
public getMediaHelper() {
return this.mediaHelper;
}
private onTileUpdate = () => {
this.forceUpdate();
};
public render() {
const content = this.props.mxEvent.getContent();
const type = this.props.mxEvent.getType();
const msgtype = content.msgtype;
let BodyType = RedactedBody;
let BodyType: ReactAnyComponent = RedactedBody;
if (!this.props.mxEvent.isRedacted()) {
// only resolve BodyType if event is not redacted
if (type && evTypes[type]) {
BodyType = evTypes[type];
} else if (msgtype && bodyTypes[msgtype]) {
BodyType = bodyTypes[msgtype];
if (type && this.evTypes[type]) {
BodyType = this.evTypes[type];
} else if (msgtype && this.bodyTypes[msgtype]) {
BodyType = this.bodyTypes[msgtype];
} else if (content.url) {
// Fallback to MFileBody if there's a content URL
BodyType = bodyTypes['m.file'];
BodyType = this.bodyTypes[MsgType.File];
} else {
// Fallback to UnknownBody otherwise if not redacted
BodyType = UnknownBody;
@ -120,8 +128,9 @@ export default class MessageEvent extends React.Component {
}
}
// @ts-ignore - this is a dynamic react component
return BodyType ? <BodyType
ref={this._body}
ref={this.body}
mxEvent={this.props.mxEvent}
highlights={this.props.highlights}
highlightLink={this.props.highlightLink}
@ -133,6 +142,7 @@ export default class MessageEvent extends React.Component {
onHeightChanged={this.props.onHeightChanged}
onMessageAllowed={this.onTileUpdate}
permalinkCreator={this.props.permalinkCreator}
mediaEventHelper={this.mediaHelper}
/> : null;
}
}

View file

@ -1,5 +1,5 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Copyright 2020 - 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -16,17 +16,13 @@ limitations under the License.
import React, { useContext } from "react";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { _t } from "../../../languageHandler";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { formatFullDate } from "../../../DateUtils";
import SettingsStore from "../../../settings/SettingsStore";
import { IBodyProps } from "./IBodyProps";
interface IProps {
mxEvent: MatrixEvent;
}
const RedactedBody = React.forwardRef<any, IProps>(({ mxEvent }, ref) => {
const RedactedBody = React.forwardRef<any, IBodyProps>(({ mxEvent }, ref) => {
const cli: MatrixClient = useContext(MatrixClientContext);
let text = _t("Message deleted");

View file

@ -17,7 +17,6 @@ limitations under the License.
import React, { createRef, SyntheticEvent } from 'react';
import ReactDOM from 'react-dom';
import highlight from 'highlight.js';
import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
import { MsgType } from "matrix-js-sdk/src/@types/event";
import * as HtmlUtils from '../../../HtmlUtils';
@ -38,37 +37,13 @@ import { replaceableComponent } from "../../../utils/replaceableComponent";
import UIStore from "../../../stores/UIStore";
import { ComposerInsertPayload } from "../../../dispatcher/payloads/ComposerInsertPayload";
import { Action } from "../../../dispatcher/actions";
import { TileShape } from '../rooms/EventTile';
import EditorStateTransfer from "../../../utils/EditorStateTransfer";
import GenericTextContextMenu from "../context_menus/GenericTextContextMenu";
import Spoiler from "../elements/Spoiler";
import QuestionDialog from "../dialogs/QuestionDialog";
import MessageEditHistoryDialog from "../dialogs/MessageEditHistoryDialog";
import EditMessageComposer from '../rooms/EditMessageComposer';
import LinkPreviewGroup from '../rooms/LinkPreviewGroup';
interface IProps {
/* the MatrixEvent to show */
mxEvent: MatrixEvent;
/* a list of words to highlight */
highlights?: string[];
/* link URL for the highlights */
highlightLink?: string;
/* should show URL previews for this event */
showUrlPreview?: boolean;
/* the shape of the tile, used */
tileShape?: TileShape;
editState?: EditorStateTransfer;
replacingEventId?: string;
/* callback for when our widget has loaded */
onHeightChanged(): void;
}
import { IBodyProps } from "./IBodyProps";
interface IState {
// the URLs (if any) to be previewed with a LinkPreviewWidget inside this TextualBody.
@ -79,7 +54,7 @@ interface IState {
}
@replaceableComponent("views.messages.TextualBody")
export default class TextualBody extends React.Component<IProps, IState> {
export default class TextualBody extends React.Component<IBodyProps, IState> {
private readonly contentRef = createRef<HTMLSpanElement>();
private unmounted = false;

View file

@ -16,12 +16,19 @@ limitations under the License.
*/
import React, { forwardRef } from "react";
import { MatrixEvent } from "matrix-js-sdk/src";
export default forwardRef(({ mxEvent }, ref) => {
interface IProps {
mxEvent: MatrixEvent;
children?: React.ReactNode;
}
export default forwardRef(({ mxEvent, children }: IProps, ref: React.RefObject<HTMLSpanElement>) => {
const text = mxEvent.getContent().body;
return (
<span className="mx_UnknownBody" ref={ref}>
{ text }
{ children }
</span>
);
});

View file

@ -385,7 +385,7 @@ const UserOptionsSection: React.FC<{
}
insertPillButton = (
<AccessibleButton onClick={onInsertPillButton} className={"mx_UserInfo_field"}>
<AccessibleButton onClick={onInsertPillButton} className="mx_UserInfo_field">
{ _t('Mention') }
</AccessibleButton>
);

View file

@ -106,7 +106,7 @@ export default class RelatedGroupSettings extends React.Component {
<EditableItemList
id="relatedGroups"
items={this.state.newGroupsList}
className={"mx_RelatedGroupSettings"}
className="mx_RelatedGroupSettings"
newItem={this.state.newGroupId}
canRemove={this.props.canSetRelatedGroups}
canEdit={this.props.canSetRelatedGroups}

View file

@ -44,6 +44,7 @@ import EditorStateTransfer from "../../../utils/EditorStateTransfer";
import { RoomPermalinkCreator } from '../../../utils/permalinks/Permalinks';
import { StaticNotificationState } from "../../../stores/notifications/StaticNotificationState";
import NotificationBadge from "./NotificationBadge";
import CallEventGrouper from "../../structures/CallEventGrouper";
import { ComposerInsertPayload } from "../../../dispatcher/payloads/ComposerInsertPayload";
import { Action } from '../../../dispatcher/actions';
import MemberAvatar from '../avatars/MemberAvatar';
@ -60,10 +61,7 @@ const eventTileTypes = {
[EventType.Sticker]: 'messages.MessageEvent',
[EventType.KeyVerificationCancel]: 'messages.MKeyVerificationConclusion',
[EventType.KeyVerificationDone]: 'messages.MKeyVerificationConclusion',
[EventType.CallInvite]: 'messages.TextualEvent',
[EventType.CallAnswer]: 'messages.TextualEvent',
[EventType.CallHangup]: 'messages.TextualEvent',
[EventType.CallReject]: 'messages.TextualEvent',
[EventType.CallInvite]: 'messages.CallEvent',
};
const stateEventTileTypes = {
@ -170,8 +168,6 @@ export function getHandlerTile(ev) {
return eventTileTypes[type];
}
const MAX_READ_AVATARS = 5;
// Our component structure for EventTiles on the timeline is:
//
// .-EventTile------------------------------------------------.
@ -292,11 +288,17 @@ interface IProps {
// Helper to build permalinks for the room
permalinkCreator?: RoomPermalinkCreator;
// CallEventGrouper for this event
callEventGrouper?: CallEventGrouper;
// Symbol of the root node
as?: string;
// whether or not to always show timestamps
alwaysShowTimestamps?: boolean;
// whether or not to display the sender
hideSender?: boolean;
}
interface IState {
@ -430,7 +432,7 @@ export default class EventTile extends React.Component<IProps, IState> {
}
// TODO: [REACT-WARNING] Move into constructor
// eslint-disable-next-line camelcase
// eslint-disable-next-line
UNSAFE_componentWillMount() {
this.verifyEvent(this.props.mxEvent);
}
@ -452,7 +454,7 @@ export default class EventTile extends React.Component<IProps, IState> {
}
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
// eslint-disable-next-line camelcase
// eslint-disable-next-line
UNSAFE_componentWillReceiveProps(nextProps) {
// re-check the sender verification as outgoing events progress through
// the send process.
@ -656,6 +658,10 @@ export default class EventTile extends React.Component<IProps, IState> {
return <SentReceipt messageState={this.props.mxEvent.getAssociatedStatus()} />;
}
const MAX_READ_AVATARS = this.props.layout == Layout.Bubble
? 2
: 5;
// return early if there are no read receipts
if (!this.props.readReceipts || this.props.readReceipts.length === 0) {
// We currently must include `mx_EventTile_readAvatars` in the DOM
@ -951,7 +957,7 @@ export default class EventTile extends React.Component<IProps, IState> {
);
}
if (needsSenderProfile) {
if (needsSenderProfile && this.props.hideSender !== true) {
if (!this.props.tileShape) {
sender = <SenderProfile onClick={this.onSenderProfileClick}
mxEvent={this.props.mxEvent}
@ -971,8 +977,12 @@ export default class EventTile extends React.Component<IProps, IState> {
onFocusChange={this.onActionBarFocusChange}
/> : undefined;
const showTimestamp = this.props.mxEvent.getTs() &&
(this.props.alwaysShowTimestamps || this.props.last || this.state.hover || this.state.actionBarFocused);
const showTimestamp = this.props.mxEvent.getTs()
&& (this.props.alwaysShowTimestamps
|| this.props.last
|| this.state.hover
|| this.state.actionBarFocused);
const timestamp = showTimestamp ?
<MessageTimestamp showTwelveHour={this.props.isTwelveHour} ts={this.props.mxEvent.getTs()} /> : null;
@ -1112,6 +1122,8 @@ export default class EventTile extends React.Component<IProps, IState> {
this.props.alwaysShowTimestamps || this.state.hover,
);
const isOwnEvent = this.props.mxEvent?.sender?.userId === MatrixClientPeg.get().getUserId();
// tab-index=-1 to allow it to be focusable but do not add tab stop for it, primarily for screen readers
return (
React.createElement(this.props.as || "li", {
@ -1121,6 +1133,9 @@ export default class EventTile extends React.Component<IProps, IState> {
"aria-live": ariaLive,
"aria-atomic": "true",
"data-scroll-tokens": scrollToken,
"data-layout": this.props.layout,
"data-self": isOwnEvent,
"data-has-reply": !!thread,
"onMouseEnter": () => this.setState({ hover: true }),
"onMouseLeave": () => this.setState({ hover: false }),
}, <>
@ -1140,11 +1155,12 @@ export default class EventTile extends React.Component<IProps, IState> {
showUrlPreview={this.props.showUrlPreview}
permalinkCreator={this.props.permalinkCreator}
onHeightChanged={this.props.onHeightChanged}
callEventGrouper={this.props.callEventGrouper}
/>
{ keyRequestInfo }
{ reactionsRow }
{ actionBar }
</div>
{ reactionsRow }
{ msgOption }
{ avatar }
</>)

View file

@ -93,7 +93,7 @@ export default class MemberList extends React.Component<IProps, IState> {
this.showPresence = enablePresenceByHsUrl?.[hsUrl] ?? true;
}
// eslint-disable-next-line camelcase
// eslint-disable-next-line
UNSAFE_componentWillMount() {
const cli = MatrixClientPeg.get();
this.mounted = true;

View file

@ -85,6 +85,7 @@ export default class PinnedEventTile extends React.Component<IProps> {
<div className="mx_PinnedEventTile_message">
<MessageEvent
mxEvent={this.props.event}
// @ts-ignore - complaining that className is invalid when it's not
className="mx_PinnedEventTile_body"
maxImageHeight={150}
onHeightChanged={() => {}} // we need to give this, apparently

View file

@ -441,7 +441,7 @@ export default class SendMessageComposer extends React.Component<IProps> {
}
// TODO: [REACT-WARNING] Move this to constructor
UNSAFE_componentWillMount() { // eslint-disable-line camelcase
UNSAFE_componentWillMount() { // eslint-disable-line
const partCreator = new CommandPartCreator(this.props.room, this.context);
const parts = this.restoreStoredEditorState(partCreator) || [];
this.model = new EditorModel(parts, partCreator);

View file

@ -64,8 +64,8 @@ export default class WhoIsTypingTile extends React.Component<IProps, IState> {
}
componentDidUpdate(_, prevState) {
const wasVisible = this._isVisible(prevState);
const isVisible = this._isVisible(this.state);
const wasVisible = WhoIsTypingTile.isVisible(prevState);
const isVisible = WhoIsTypingTile.isVisible(this.state);
if (this.props.onShown && !wasVisible && isVisible) {
this.props.onShown();
} else if (this.props.onHidden && wasVisible && !isVisible) {
@ -83,12 +83,12 @@ export default class WhoIsTypingTile extends React.Component<IProps, IState> {
Object.values(this.state.delayedStopTypingTimers).forEach((t) => (t as Timer).abort());
}
private _isVisible(state: IState): boolean {
private static isVisible(state: IState): boolean {
return state.usersTyping.length !== 0 || Object.keys(state.delayedStopTypingTimers).length !== 0;
}
public isVisible = (): boolean => {
return this._isVisible(this.state);
return WhoIsTypingTile.isVisible(this.state);
};
private onRoomTimeline = (event: MatrixEvent, room: Room): void => {

View file

@ -35,7 +35,7 @@ interface SpellCheckLanguagesIState {
}
export class ExistingSpellCheckLanguage extends React.Component<ExistingSpellCheckLanguageIProps> {
_onRemove = (e) => {
private onRemove = (e) => {
e.stopPropagation();
e.preventDefault();
@ -46,7 +46,7 @@ export class ExistingSpellCheckLanguage extends React.Component<ExistingSpellChe
return (
<div className="mx_ExistingSpellCheckLanguage">
<span className="mx_ExistingSpellCheckLanguage_language">{ this.props.language }</span>
<AccessibleButton onClick={this._onRemove} kind="danger_sm">
<AccessibleButton onClick={this.onRemove} kind="danger_sm">
{ _t("Remove") }
</AccessibleButton>
</div>
@ -63,12 +63,12 @@ export default class SpellCheckLanguages extends React.Component<SpellCheckLangu
};
}
_onRemoved = (language) => {
private onRemoved = (language: string) => {
const languages = this.props.languages.filter((e) => e !== language);
this.props.onLanguagesChange(languages);
};
_onAddClick = (e) => {
private onAddClick = (e) => {
e.stopPropagation();
e.preventDefault();
@ -81,18 +81,18 @@ export default class SpellCheckLanguages extends React.Component<SpellCheckLangu
this.props.onLanguagesChange(this.props.languages);
};
_onNewLanguageChange = (language: string) => {
private onNewLanguageChange = (language: string) => {
if (this.state.newLanguage === language) return;
this.setState({ newLanguage: language });
};
render() {
const existingSpellCheckLanguages = this.props.languages.map((e) => {
return <ExistingSpellCheckLanguage language={e} onRemoved={this._onRemoved} key={e} />;
return <ExistingSpellCheckLanguage language={e} onRemoved={this.onRemoved} key={e} />;
});
const addButton = (
<AccessibleButton onClick={this._onAddClick} kind="primary">
<AccessibleButton onClick={this.onAddClick} kind="primary">
{ _t("Add") }
</AccessibleButton>
);
@ -100,11 +100,11 @@ export default class SpellCheckLanguages extends React.Component<SpellCheckLangu
return (
<div className="mx_SpellCheckLanguages">
{ existingSpellCheckLanguages }
<form onSubmit={this._onAddClick} noValidate={true}>
<form onSubmit={this.onAddClick} noValidate={true}>
<SpellCheckLanguagesDropdown
className="mx_GeneralUserSettingsTab_spellCheckLanguageInput"
value={this.state.newLanguage}
onOptionChange={this._onNewLanguageChange} />
onOptionChange={this.onNewLanguageChange} />
{ addButton }
</form>
</div>

View file

@ -78,7 +78,7 @@ export default class SecurityRoomSettingsTab extends React.Component<IProps, ISt
}
// TODO: [REACT-WARNING] Move this to constructor
async UNSAFE_componentWillMount() { // eslint-disable-line camelcase
async UNSAFE_componentWillMount() { // eslint-disable-line
MatrixClientPeg.get().on("RoomState.events", this.onStateEvent);
const room = MatrixClientPeg.get().getRoom(this.props.roomId);

View file

@ -37,6 +37,8 @@ import StyledRadioGroup from "../../../elements/StyledRadioGroup";
import { SettingLevel } from "../../../../../settings/SettingLevel";
import { UIFeature } from "../../../../../settings/UIFeature";
import { Layout } from "../../../../../settings/Layout";
import classNames from 'classnames';
import StyledRadioButton from '../../../elements/StyledRadioButton';
import { replaceableComponent } from "../../../../../utils/replaceableComponent";
import { compare } from "../../../../../utils/strings";
@ -241,6 +243,19 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I
this.setState({ customThemeUrl: e.target.value });
};
private onLayoutChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
let layout;
switch (e.target.value) {
case "irc": layout = Layout.IRC; break;
case "group": layout = Layout.Group; break;
case "bubble": layout = Layout.Bubble; break;
}
this.setState({ layout: layout });
SettingsStore.setValue("layout", null, SettingLevel.DEVICE, layout);
};
private onIRCLayoutChange = (enabled: boolean) => {
if (enabled) {
this.setState({ layout: Layout.IRC });
@ -373,6 +388,77 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I
</div>;
}
private renderLayoutSection = () => {
return <div className="mx_SettingsTab_section mx_AppearanceUserSettingsTab_Layout">
<span className="mx_SettingsTab_subheading">{ _t("Message layout") }</span>
<div className="mx_AppearanceUserSettingsTab_Layout_RadioButtons">
<div className={classNames("mx_AppearanceUserSettingsTab_Layout_RadioButton", {
mx_AppearanceUserSettingsTab_Layout_RadioButton_selected: this.state.layout == Layout.IRC,
})}>
<EventTilePreview
className="mx_AppearanceUserSettingsTab_Layout_RadioButton_preview"
message={this.MESSAGE_PREVIEW_TEXT}
layout={Layout.IRC}
userId={this.state.userId}
displayName={this.state.displayName}
avatarUrl={this.state.avatarUrl}
/>
<StyledRadioButton
name="layout"
value="irc"
checked={this.state.layout === Layout.IRC}
onChange={this.onLayoutChange}
>
{ _t("IRC") }
</StyledRadioButton>
</div>
<div className="mx_AppearanceUserSettingsTab_spacer" />
<div className={classNames("mx_AppearanceUserSettingsTab_Layout_RadioButton", {
mx_AppearanceUserSettingsTab_Layout_RadioButton_selected: this.state.layout == Layout.Group,
})}>
<EventTilePreview
className="mx_AppearanceUserSettingsTab_Layout_RadioButton_preview"
message={this.MESSAGE_PREVIEW_TEXT}
layout={Layout.Group}
userId={this.state.userId}
displayName={this.state.displayName}
avatarUrl={this.state.avatarUrl}
/>
<StyledRadioButton
name="layout"
value="group"
checked={this.state.layout == Layout.Group}
onChange={this.onLayoutChange}
>
{ _t("Modern") }
</StyledRadioButton>
</div>
<div className="mx_AppearanceUserSettingsTab_spacer" />
<div className={classNames("mx_AppearanceUserSettingsTab_Layout_RadioButton", {
mx_AppearanceUserSettingsTab_Layout_RadioButton_selected: this.state.layout === Layout.Bubble,
})}>
<EventTilePreview
className="mx_AppearanceUserSettingsTab_Layout_RadioButton_preview"
message={this.MESSAGE_PREVIEW_TEXT}
layout={Layout.Bubble}
userId={this.state.userId}
displayName={this.state.displayName}
avatarUrl={this.state.avatarUrl}
/>
<StyledRadioButton
name="layout"
value="bubble"
checked={this.state.layout == Layout.Bubble}
onChange={this.onLayoutChange}
>
{ _t("Message bubbles") }
</StyledRadioButton>
</div>
</div>
</div>;
};
private renderAdvancedSection() {
if (!SettingsStore.getValue(UIFeature.AdvancedSettings)) return null;
@ -396,14 +482,17 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I
name="useCompactLayout"
level={SettingLevel.DEVICE}
useCheckbox={true}
disabled={this.state.layout == Layout.IRC}
disabled={this.state.layout !== Layout.Group}
/>
{ !SettingsStore.getValue("feature_new_layout_switcher") ?
<StyledCheckbox
checked={this.state.layout == Layout.IRC}
onChange={(ev) => this.onIRCLayoutChange(ev.target.checked)}
>
{ _t("Enable experimental, compact IRC style layout") }
</StyledCheckbox>
</StyledCheckbox> : null
}
<SettingsFlag
name="useSystemFont"
@ -444,6 +533,7 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I
{ _t("Appearance Settings only affect this %(brand)s session.", { brand }) }
</div>
{ this.renderThemeSection() }
{ SettingsStore.getValue("feature_new_layout_switcher") ? this.renderLayoutSection() : null }
{ this.renderFontSection() }
{ this.renderAdvancedSection() }
</div>

View file

@ -60,14 +60,14 @@ export default class VerificationRequestToast extends React.PureComponent<IProps
this.setState({ counter });
}, 1000);
}
request.on("change", this._checkRequestIsPending);
request.on("change", this.checkRequestIsPending);
// We should probably have a separate class managing the active verification toasts,
// rather than monitoring this in the toast component itself, since we'll get problems
// like the toasdt not going away when the verification is cancelled unless it's the
// one on the top (ie. the one that's mounted).
// As a quick & dirty fix, check the toast is still relevant when it mounts (this prevents
// a toast hanging around after logging in if you did a verification as part of login).
this._checkRequestIsPending();
this.checkRequestIsPending();
if (request.isSelfVerification) {
const cli = MatrixClientPeg.get();
@ -83,10 +83,10 @@ export default class VerificationRequestToast extends React.PureComponent<IProps
componentWillUnmount() {
clearInterval(this.intervalHandle);
const { request } = this.props;
request.off("change", this._checkRequestIsPending);
request.off("change", this.checkRequestIsPending);
}
_checkRequestIsPending = () => {
private checkRequestIsPending = () => {
const { request } = this.props;
if (!request.canAccept) {
ToastStore.sharedInstance().dismissToast(this.props.toastKey);

View file

@ -513,7 +513,9 @@ export default class CallView extends React.Component<IProps, IState> {
transferee: transfereeName,
},
{
a: sub => <AccessibleButton kind="link" onClick={this.onTransferClick}>{sub}</AccessibleButton>,
a: sub => <AccessibleButton kind="link" onClick={this.onTransferClick}>
{ sub }
</AccessibleButton>,
},
) }
</div>;

View file

@ -21,7 +21,7 @@ import { MatrixClientPeg } from '../../../MatrixClientPeg';
import dis from '../../../dispatcher/dispatcher';
import { _t } from '../../../languageHandler';
import { ActionPayload } from '../../../dispatcher/payloads';
import CallHandler, { AudioID } from '../../../CallHandler';
import CallHandler, { CallHandlerEvent } from '../../../CallHandler';
import RoomAvatar from '../avatars/RoomAvatar';
import AccessibleButton from '../elements/AccessibleButton';
import { CallState } from 'matrix-js-sdk/src/webrtc/call';
@ -51,8 +51,13 @@ export default class IncomingCallBox extends React.Component<IProps, IState> {
};
}
componentDidMount = () => {
CallHandler.sharedInstance().addListener(CallHandlerEvent.SilencedCallsChanged, this.onSilencedCallsChanged);
};
public componentWillUnmount() {
dis.unregister(this.dispatcherRef);
CallHandler.sharedInstance().removeListener(CallHandlerEvent.SilencedCallsChanged, this.onSilencedCallsChanged);
}
private onAction = (payload: ActionPayload) => {
@ -73,6 +78,12 @@ export default class IncomingCallBox extends React.Component<IProps, IState> {
}
};
private onSilencedCallsChanged = () => {
const callId = this.state.incomingCall?.callId;
if (!callId) return;
this.setState({ silenced: CallHandler.sharedInstance().isCallSilenced(callId) });
};
private onAnswerClick: React.MouseEventHandler = (e) => {
e.stopPropagation();
dis.dispatch({
@ -91,9 +102,10 @@ export default class IncomingCallBox extends React.Component<IProps, IState> {
private onSilenceClick: React.MouseEventHandler = (e) => {
e.stopPropagation();
const newState = !this.state.silenced;
this.setState({ silenced: newState });
newState ? CallHandler.sharedInstance().pause(AudioID.Ring) : CallHandler.sharedInstance().play(AudioID.Ring);
const callId = this.state.incomingCall.callId;
this.state.silenced ?
CallHandler.sharedInstance().unSilenceCall(callId):
CallHandler.sharedInstance().silenceCall(callId);
};
public render() {
@ -144,7 +156,7 @@ export default class IncomingCallBox extends React.Component<IProps, IState> {
</div>
<div className="mx_IncomingCallBox_buttons">
<AccessibleButton
className={"mx_IncomingCallBox_decline"}
className="mx_IncomingCallBox_decline"
onClick={this.onRejectClick}
kind="danger"
>
@ -152,7 +164,7 @@ export default class IncomingCallBox extends React.Component<IProps, IState> {
</AccessibleButton>
<div className="mx_IncomingCallBox_spacer" />
<AccessibleButton
className={"mx_IncomingCallBox_accept"}
className="mx_IncomingCallBox_accept"
onClick={this.onAnswerClick}
kind="primary"
>

View file

@ -17,6 +17,7 @@ limitations under the License.
import { MatrixClient } from "matrix-js-sdk/src/client";
import { Room } from "matrix-js-sdk/src/models/room";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { EventType } from "matrix-js-sdk/src/@types/event";
import { MatrixClientPeg } from './MatrixClientPeg';
@ -247,11 +248,11 @@ export function findDMForUser(client: MatrixClient, userId: string): Room {
* NOTE: this assumes you've just created the room and there's not been an opportunity
* for other code to run, so we shouldn't miss RoomState.newMember when it comes by.
*/
export async function _waitForMember(client: MatrixClient, roomId: string, userId: string, opts = { timeout: 1500 }) {
export async function waitForMember(client: MatrixClient, roomId: string, userId: string, opts = { timeout: 1500 }) {
const { timeout } = opts;
let handler;
return new Promise((resolve) => {
handler = function(_event, _roomstate, member) {
handler = function(_, __, member: RoomMember) { // eslint-disable-line @typescript-eslint/naming-convention
if (member.userId !== userId) return;
if (member.roomId !== roomId) return;
resolve(true);
@ -324,7 +325,7 @@ export async function ensureDMExists(client: MatrixClient, userId: string): Prom
}
roomId = await createRoom({ encryption, dmUserId: userId, spinner: false, andView: false });
await _waitForMember(client, roomId, userId);
await waitForMember(client, roomId, userId);
}
return roomId;
}

View file

@ -274,7 +274,7 @@ abstract class PillPart extends BasePart implements IPillPart {
}
// helper method for subclasses
_setAvatarVars(node: HTMLElement, avatarUrl: string, initialLetter: string) {
protected setAvatarVars(node: HTMLElement, avatarUrl: string, initialLetter: string) {
const avatarBackground = `url('${avatarUrl}')`;
const avatarLetter = `'${initialLetter}'`;
// check if the value is changing,
@ -354,7 +354,7 @@ class RoomPillPart extends PillPart {
initialLetter = Avatar.getInitialLetter(this.room ? this.room.name : this.resourceId);
avatarUrl = Avatar.defaultAvatarUrlForString(this.room ? this.room.roomId : this.resourceId);
}
this._setAvatarVars(node, avatarUrl, initialLetter);
this.setAvatarVars(node, avatarUrl, initialLetter);
}
get type(): IPillPart["type"] {
@ -399,7 +399,7 @@ class UserPillPart extends PillPart {
if (avatarUrl === defaultAvatarUrl) {
initialLetter = Avatar.getInitialLetter(name);
}
this._setAvatarVars(node, avatarUrl, initialLetter);
this.setAvatarVars(node, avatarUrl, initialLetter);
}
get type(): IPillPart["type"] {

View file

@ -15,26 +15,23 @@ limitations under the License.
*/
import EMOJIBASE from 'emojibase-data/en/compact.json';
import SHORTCODES from 'emojibase-data/en/shortcodes/iamcal.json';
export interface IEmoji {
annotation: string;
group: number;
group?: number;
hexcode: string;
order: number;
order?: number;
shortcodes: string[];
tags: string[];
tags?: string[];
unicode: string;
skins?: Omit<IEmoji, "shortcodes" | "tags">[]; // Currently unused
emoticon?: string;
}
interface IEmojiWithFilterString extends IEmoji {
filterString?: string;
}
// The unicode is stored without the variant selector
const UNICODE_TO_EMOJI = new Map<string, IEmojiWithFilterString>(); // not exported as gets for it are handled by getEmojiFromUnicode
export const EMOTICON_TO_EMOJI = new Map<string, IEmojiWithFilterString>();
export const SHORTCODE_TO_EMOJI = new Map<string, IEmojiWithFilterString>();
const UNICODE_TO_EMOJI = new Map<string, IEmoji>(); // not exported as gets for it are handled by getEmojiFromUnicode
export const EMOTICON_TO_EMOJI = new Map<string, IEmoji>();
export const getEmojiFromUnicode = unicode => UNICODE_TO_EMOJI.get(stripVariation(unicode));
@ -62,17 +59,23 @@ export const DATA_BY_CATEGORY = {
"flags": [],
};
const ZERO_WIDTH_JOINER = "\u200D";
// Store various mappings from unicode/emoticon/shortcode to the Emoji objects
EMOJIBASE.forEach((emoji: IEmojiWithFilterString) => {
export const EMOJI: IEmoji[] = EMOJIBASE.map((emojiData: Omit<IEmoji, "shortcodes">) => {
// If there's ever a gap in shortcode coverage, we fudge it by
// filling it in with the emoji's CLDR annotation
const shortcodeData = SHORTCODES[emojiData.hexcode] ??
[emojiData.annotation.toLowerCase().replace(/ /g, "_")];
const emoji: IEmoji = {
...emojiData,
// Homogenize shortcodes by ensuring that everything is an array
shortcodes: typeof shortcodeData === "string" ? [shortcodeData] : shortcodeData,
};
const categoryId = EMOJIBASE_GROUP_ID_TO_CATEGORY[emoji.group];
if (DATA_BY_CATEGORY.hasOwnProperty(categoryId)) {
DATA_BY_CATEGORY[categoryId].push(emoji);
}
// This is used as the string to match the query against when filtering emojis
emoji.filterString = (`${emoji.annotation}\n${emoji.shortcodes.join('\n')}}\n${emoji.emoticon || ''}\n` +
`${emoji.unicode.split(ZERO_WIDTH_JOINER).join("\n")}`).toLowerCase();
// Add mapping from unicode to Emoji object
// The 'unicode' field that we use in emojibase has either
@ -88,12 +91,7 @@ EMOJIBASE.forEach((emoji: IEmojiWithFilterString) => {
EMOTICON_TO_EMOJI.set(emoji.emoticon, emoji);
}
if (emoji.shortcodes) {
// Add mapping from each shortcode to Emoji object
emoji.shortcodes.forEach(shortcode => {
SHORTCODE_TO_EMOJI.set(shortcode, emoji);
});
}
return emoji;
});
/**
@ -107,5 +105,3 @@ EMOJIBASE.forEach((emoji: IEmojiWithFilterString) => {
function stripVariation(str) {
return str.replace(/[\uFE00-\uFE0F]$/, "");
}
export const EMOJI: IEmoji[] = EMOJIBASE;

View file

@ -541,22 +541,8 @@
"%(senderName)s changed the alternative addresses for this room.": "%(senderName)s changed the alternative addresses for this room.",
"%(senderName)s changed the main and alternative addresses for this room.": "%(senderName)s changed the main and alternative addresses for this room.",
"%(senderName)s changed the addresses for this room.": "%(senderName)s changed the addresses for this room.",
"Someone": "Someone",
"(not supported by this browser)": "(not supported by this browser)",
"%(senderName)s answered the call.": "%(senderName)s answered the call.",
"(could not connect media)": "(could not connect media)",
"(connection failed)": "(connection failed)",
"(their device couldn't start the camera / microphone)": "(their device couldn't start the camera / microphone)",
"(an error occurred)": "(an error occurred)",
"(no answer)": "(no answer)",
"(unknown failure: %(reason)s)": "(unknown failure: %(reason)s)",
"%(senderName)s ended the call.": "%(senderName)s ended the call.",
"%(senderName)s declined the call.": "%(senderName)s declined the call.",
"%(senderName)s placed a voice call.": "%(senderName)s placed a voice call.",
"%(senderName)s placed a voice call. (not supported by this browser)": "%(senderName)s placed a voice call. (not supported by this browser)",
"%(senderName)s placed a video call.": "%(senderName)s placed a video call.",
"%(senderName)s placed a video call. (not supported by this browser)": "%(senderName)s placed a video call. (not supported by this browser)",
"%(senderName)s revoked the invitation for %(targetDisplayName)s to join the room.": "%(senderName)s revoked the invitation for %(targetDisplayName)s to join the room.",
"Someone": "Someone",
"%(senderName)s sent an invitation to %(targetDisplayName)s to join the room.": "%(senderName)s sent an invitation to %(targetDisplayName)s to join the room.",
"%(senderName)s made future room history visible to all room members, from the point they are invited.": "%(senderName)s made future room history visible to all room members, from the point they are invited.",
"%(senderName)s made future room history visible to all room members, from the point they joined.": "%(senderName)s made future room history visible to all room members, from the point they joined.",
@ -823,6 +809,7 @@
"Offline encrypted messaging using dehydrated devices": "Offline encrypted messaging using dehydrated devices",
"Enable advanced debugging for the room list": "Enable advanced debugging for the room list",
"Show info about bridges in room settings": "Show info about bridges in room settings",
"New layout switcher (with message bubbles)": "New layout switcher (with message bubbles)",
"Font size": "Font size",
"Use custom size": "Use custom size",
"Enable Emoji suggestions while typing": "Enable Emoji suggestions while typing",
@ -1245,6 +1232,10 @@
"Custom theme URL": "Custom theme URL",
"Add theme": "Add theme",
"Theme": "Theme",
"Message layout": "Message layout",
"IRC": "IRC",
"Modern": "Modern",
"Message bubbles": "Message bubbles",
"Set the name of a font installed on your system & %(brand)s will attempt to use it.": "Set the name of a font installed on your system & %(brand)s will attempt to use it.",
"Enable experimental, compact IRC style layout": "Enable experimental, compact IRC style layout",
"Customise your appearance": "Customise your appearance",
@ -1851,6 +1842,18 @@
"You cancelled verification.": "You cancelled verification.",
"Verification cancelled": "Verification cancelled",
"Compare emoji": "Compare emoji",
"Connected": "Connected",
"This call has ended": "This call has ended",
"Could not connect media": "Could not connect media",
"Connection failed": "Connection failed",
"Their device couldn't start the camera or microphone": "Their device couldn't start the camera or microphone",
"An unknown error occurred": "An unknown error occurred",
"No answer": "No answer",
"Unknown failure: %(reason)s)": "Unknown failure: %(reason)s)",
"This call has failed": "This call has failed",
"You missed this call": "You missed this call",
"Call back": "Call back",
"The call is in an unknown state!": "The call is in an unknown state!",
"Sunday": "Sunday",
"Monday": "Monday",
"Tuesday": "Tuesday",
@ -1860,6 +1863,8 @@
"Saturday": "Saturday",
"Today": "Today",
"Yesterday": "Yesterday",
"Downloading": "Downloading",
"Download": "Download",
"View Source": "View Source",
"Messages here are end-to-end encrypted. Verify %(displayName)s in their profile - tap on their avatar.": "Messages here are end-to-end encrypted. Verify %(displayName)s in their profile - tap on their avatar.",
"Messages in this room are end-to-end encrypted. When people join, you can verify them in their profile, just tap on their avatar.": "Messages in this room are end-to-end encrypted. When people join, you can verify them in their profile, just tap on their avatar.",
@ -1992,7 +1997,6 @@
"Zoom in": "Zoom in",
"Rotate Left": "Rotate Left",
"Rotate Right": "Rotate Right",
"Download": "Download",
"Information": "Information",
"Language Dropdown": "Language Dropdown",
"%(nameList)s %(transitionList)s": "%(nameList)s %(transitionList)s",

View file

@ -67,7 +67,7 @@ export function getUserLanguage(): string {
// Function which only purpose is to mark that a string is translatable
// Does not actually do anything. It's helpful for automatic extraction of translatable strings
export function _td(s: string): string {
export function _td(s: string): string { // eslint-disable-line @typescript-eslint/naming-convention
return s;
}
@ -132,6 +132,8 @@ export type TranslatedString = string | React.ReactNode;
*
* @return a React <span> component if any non-strings were used in substitutions, otherwise a string
*/
// eslint-next-line @typescript-eslint/naming-convention
// eslint-nexline @typescript-eslint/naming-convention
export function _t(text: string, variables?: IVariables): string;
export function _t(text: string, variables: IVariables, tags: Tags): React.ReactNode;
export function _t(text: string, variables?: IVariables, tags?: Tags): TranslatedString {

View file

@ -14,6 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { MatrixClientPeg } from "../MatrixClientPeg";
import { ALL_RULE_TYPES, BanList } from "./BanList";
import SettingsStore from "../settings/SettingsStore";
@ -21,19 +22,17 @@ import { _t } from "../languageHandler";
import dis from "../dispatcher/dispatcher";
import { SettingLevel } from "../settings/SettingLevel";
import { Preset } from "matrix-js-sdk/src/@types/partials";
import { ActionPayload } from "../dispatcher/payloads";
// TODO: Move this and related files to the js-sdk or something once finalized.
export class Mjolnir {
static _instance: Mjolnir = null;
private static instance: Mjolnir = null;
_lists: BanList[] = [];
_roomIds: string[] = [];
_mjolnirWatchRef = null;
_dispatcherRef = null;
constructor() {
}
private _lists: BanList[] = []; // eslint-disable-line @typescript-eslint/naming-convention
private _roomIds: string[] = []; // eslint-disable-line @typescript-eslint/naming-convention
private mjolnirWatchRef: string = null;
private dispatcherRef: string = null;
get roomIds(): string[] {
return this._roomIds;
@ -44,16 +43,16 @@ export class Mjolnir {
}
start() {
this._mjolnirWatchRef = SettingsStore.watchSetting("mjolnirRooms", null, this._onListsChanged.bind(this));
this.mjolnirWatchRef = SettingsStore.watchSetting("mjolnirRooms", null, this.onListsChanged.bind(this));
this._dispatcherRef = dis.register(this._onAction);
this.dispatcherRef = dis.register(this.onAction);
dis.dispatch({
action: 'do_after_sync_prepared',
deferred_action: { action: 'setup_mjolnir' },
});
}
_onAction = (payload) => {
private onAction = (payload: ActionPayload) => {
if (payload['action'] === 'setup_mjolnir') {
console.log("Setting up Mjolnir: after sync");
this.setup();
@ -62,23 +61,23 @@ export class Mjolnir {
setup() {
if (!MatrixClientPeg.get()) return;
this._updateLists(SettingsStore.getValue("mjolnirRooms"));
MatrixClientPeg.get().on("RoomState.events", this._onEvent);
this.updateLists(SettingsStore.getValue("mjolnirRooms"));
MatrixClientPeg.get().on("RoomState.events", this.onEvent);
}
stop() {
if (this._mjolnirWatchRef) {
SettingsStore.unwatchSetting(this._mjolnirWatchRef);
this._mjolnirWatchRef = null;
if (this.mjolnirWatchRef) {
SettingsStore.unwatchSetting(this.mjolnirWatchRef);
this.mjolnirWatchRef = null;
}
if (this._dispatcherRef) {
dis.unregister(this._dispatcherRef);
this._dispatcherRef = null;
if (this.dispatcherRef) {
dis.unregister(this.dispatcherRef);
this.dispatcherRef = null;
}
if (!MatrixClientPeg.get()) return;
MatrixClientPeg.get().removeListener("RoomState.events", this._onEvent);
MatrixClientPeg.get().removeListener("RoomState.events", this.onEvent);
}
async getOrCreatePersonalList(): Promise<BanList> {
@ -132,20 +131,20 @@ export class Mjolnir {
this._lists = this._lists.filter(b => b.roomId !== roomId);
}
_onEvent = (event) => {
private onEvent = (event: MatrixEvent) => {
if (!MatrixClientPeg.get()) return;
if (!this._roomIds.includes(event.getRoomId())) return;
if (!ALL_RULE_TYPES.includes(event.getType())) return;
this._updateLists(this._roomIds);
this.updateLists(this._roomIds);
};
_onListsChanged(settingName, roomId, atLevel, newValue) {
private onListsChanged(settingName: string, roomId: string, atLevel: SettingLevel, newValue: string[]) {
// We know that ban lists are only recorded at one level so we don't need to re-eval them
this._updateLists(newValue);
this.updateLists(newValue);
}
_updateLists(listRoomIds: string[]) {
private updateLists(listRoomIds: string[]) {
if (!MatrixClientPeg.get()) return;
console.log("Updating Mjolnir ban lists to: " + listRoomIds);
@ -182,10 +181,10 @@ export class Mjolnir {
}
static sharedInstance(): Mjolnir {
if (!Mjolnir._instance) {
Mjolnir._instance = new Mjolnir();
if (!Mjolnir.instance) {
Mjolnir.instance = new Mjolnir();
}
return Mjolnir._instance;
return Mjolnir.instance;
}
}

View file

@ -203,7 +203,7 @@ export default async function sendBugReport(bugReportEndpoint: string, opts: IOp
const body = await collectBugReport(opts);
progressCallback(_t("Uploading logs"));
await _submitReport(bugReportEndpoint, body, progressCallback);
await submitReport(bugReportEndpoint, body, progressCallback);
}
/**
@ -289,10 +289,10 @@ export async function submitFeedback(
body.append(k, extraData[k]);
}
await _submitReport(SdkConfig.get().bug_report_endpoint_url, body, () => {});
await submitReport(SdkConfig.get().bug_report_endpoint_url, body, () => {});
}
function _submitReport(endpoint: string, body: FormData, progressCallback: (string) => void) {
function submitReport(endpoint: string, body: FormData, progressCallback: (str: string) => void) {
return new Promise<void>((resolve, reject) => {
const req = new XMLHttpRequest();
req.open("POST", endpoint);

View file

@ -1,5 +1,6 @@
/*
Copyright 2021 Šimon Brandner <simon.bra.ag@gmail.com>
Copyright 2021 Quirin Götz <codeworks@supercable.onl>
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -19,7 +20,8 @@ import PropTypes from 'prop-types';
/* TODO: This should be later reworked into something more generic */
export enum Layout {
IRC = "irc",
Group = "group"
Group = "group",
Bubble = "bubble",
}
/* We need this because multiple components are still using JavaScript */

View file

@ -41,6 +41,7 @@ import { Layout } from "./Layout";
import ReducedMotionController from './controllers/ReducedMotionController';
import IncompatibleController from "./controllers/IncompatibleController";
import SdkConfig from "../SdkConfig";
import NewLayoutSwitcherController from './controllers/NewLayoutSwitcherController';
// These are just a bunch of helper arrays to avoid copy/pasting a bunch of times
const LEVELS_ROOM_SETTINGS = [
@ -321,6 +322,13 @@ export const SETTINGS: {[setting: string]: ISetting} = {
displayName: _td("Show info about bridges in room settings"),
default: false,
},
"feature_new_layout_switcher": {
isFeature: true,
supportedLevels: LEVELS_FEATURE,
displayName: _td("New layout switcher (with message bubbles)"),
default: false,
controller: new NewLayoutSwitcherController(),
},
"RoomList.backgroundImage": {
supportedLevels: LEVELS_ACCOUNT_SETTINGS,
default: null,

View file

@ -0,0 +1,26 @@
/*
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 SettingController from "./SettingController";
import { SettingLevel } from "../SettingLevel";
import SettingsStore from "../SettingsStore";
import { Layout } from "../Layout";
export default class NewLayoutSwitcherController extends SettingController {
public onChange(level: SettingLevel, roomId: string, newValue: any) {
// On disabling switch back to Layout.Group if Layout.Bubble
if (!newValue && SettingsStore.getValue("layout") == Layout.Bubble) {
SettingsStore.setValue("layout", null, SettingLevel.DEVICE, Layout.Group);
}
}
}

View file

@ -49,7 +49,7 @@ class GroupFilterOrderStore extends Store {
this.__emitChange();
}
__onDispatch(payload) {
__onDispatch(payload) { // eslint-disable-line @typescript-eslint/naming-convention
switch (payload.action) {
// Initialise state after initial sync
case 'view_room': {

View file

@ -44,7 +44,7 @@ class LifecycleStore extends Store<ActionPayload> {
this.__emitChange();
}
protected __onDispatch(payload: ActionPayload) {
protected __onDispatch(payload: ActionPayload) { // eslint-disable-line @typescript-eslint/naming-convention
switch (payload.action) {
case 'do_after_sync_prepared':
this.setState({

View file

@ -144,7 +144,7 @@ export default class RightPanelStore extends Store<ActionPayload> {
this.__emitChange();
}
__onDispatch(payload: ActionPayload) {
__onDispatch(payload: ActionPayload) { // eslint-disable-line @typescript-eslint/naming-convention
switch (payload.action) {
case 'view_room':
if (payload.room_id === this.lastRoomId) break; // skip this transition, probably a permalink

View file

@ -96,7 +96,7 @@ class RoomViewStore extends Store<ActionPayload> {
this.__emitChange();
}
__onDispatch(payload) {
__onDispatch(payload) { // eslint-disable-line @typescript-eslint/naming-convention
switch (payload.action) {
// view_room:
// - room_alias: '#somealias:matrix.org'

View file

@ -63,7 +63,7 @@ const PREVIEWS = {
const MAX_EVENTS_BACKWARDS = 50;
// type merging ftw
type TAG_ANY = "im.vector.any";
type TAG_ANY = "im.vector.any"; // eslint-disable-line @typescript-eslint/naming-convention
const TAG_ANY: TAG_ANY = "im.vector.any";
interface IState {

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