Merge remote-tracking branch 'origin/develop' into travis/voice-messages/download

This commit is contained in:
Travis Ralston 2021-07-20 08:55:53 -06:00
commit 5994111e5d
325 changed files with 5224 additions and 4075 deletions

View file

@ -1,3 +1,152 @@
Changes in [3.26.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.26.0) (2021-07-19)
=====================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.26.0-rc.1...v3.26.0)
* Fix 'User' type import
[\#6376](https://github.com/matrix-org/matrix-react-sdk/pull/6376)
Changes in [3.26.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.26.0-rc.1) (2021-07-14)
===============================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.25.0...v3.26.0-rc.1)
* Fix voice messages in right panels
[\#6370](https://github.com/matrix-org/matrix-react-sdk/pull/6370)
* Use TileShape enum more universally
[\#6369](https://github.com/matrix-org/matrix-react-sdk/pull/6369)
* Translations update from Weblate
[\#6373](https://github.com/matrix-org/matrix-react-sdk/pull/6373)
* Hide world readable history option in encrypted rooms
[\#5947](https://github.com/matrix-org/matrix-react-sdk/pull/5947)
* Make the Image View buttons easier to hit
[\#6372](https://github.com/matrix-org/matrix-react-sdk/pull/6372)
* Reorder buttons in the Image View
[\#6368](https://github.com/matrix-org/matrix-react-sdk/pull/6368)
* Add VS Code to gitignore
[\#6367](https://github.com/matrix-org/matrix-react-sdk/pull/6367)
* Fix inviter exploding due to member being null
[\#6362](https://github.com/matrix-org/matrix-react-sdk/pull/6362)
* Increase sample count in voice message thumbnail
[\#6359](https://github.com/matrix-org/matrix-react-sdk/pull/6359)
* Improve arraySeed utility
[\#6360](https://github.com/matrix-org/matrix-react-sdk/pull/6360)
* Convert FontManager to TS and stub it out for tests
[\#6358](https://github.com/matrix-org/matrix-react-sdk/pull/6358)
* Adjust recording waveform behaviour for voice messages
[\#6357](https://github.com/matrix-org/matrix-react-sdk/pull/6357)
* Do not honor string power levels
[\#6245](https://github.com/matrix-org/matrix-react-sdk/pull/6245)
* Add alias and directory customisation points
[\#6343](https://github.com/matrix-org/matrix-react-sdk/pull/6343)
* Fix multiinviter user already in room and clean up code
[\#6354](https://github.com/matrix-org/matrix-react-sdk/pull/6354)
* Fix right panel not closing user info when changing rooms
[\#6341](https://github.com/matrix-org/matrix-react-sdk/pull/6341)
* Quit sticker picker on m.sticker
[\#5679](https://github.com/matrix-org/matrix-react-sdk/pull/5679)
* Don't autodetect language in inline code blocks
[\#6350](https://github.com/matrix-org/matrix-react-sdk/pull/6350)
* Make ghost button background transparent
[\#6331](https://github.com/matrix-org/matrix-react-sdk/pull/6331)
* only consider valid & loaded url previews for show N more prompt
[\#6346](https://github.com/matrix-org/matrix-react-sdk/pull/6346)
* Extract MXCs from _matrix/media/r0/ URLs for inline images in messages
[\#6335](https://github.com/matrix-org/matrix-react-sdk/pull/6335)
* Fix small visual regression with the site name on url previews
[\#6342](https://github.com/matrix-org/matrix-react-sdk/pull/6342)
* Make PIP CallView draggable/movable
[\#5952](https://github.com/matrix-org/matrix-react-sdk/pull/5952)
* Convert VoiceUserSettingsTab to TS
[\#6340](https://github.com/matrix-org/matrix-react-sdk/pull/6340)
* Simplify typescript definition for Modernizr
[\#6339](https://github.com/matrix-org/matrix-react-sdk/pull/6339)
* Remember the last used server for room directory searches
[\#6322](https://github.com/matrix-org/matrix-react-sdk/pull/6322)
* Focus composer after reacting
[\#6332](https://github.com/matrix-org/matrix-react-sdk/pull/6332)
* Fix bug which prevented more than one event getting pinned
[\#6336](https://github.com/matrix-org/matrix-react-sdk/pull/6336)
* Make DeviceListener also update on megolm key in SSSS
[\#6337](https://github.com/matrix-org/matrix-react-sdk/pull/6337)
* Improve URL previews
[\#6326](https://github.com/matrix-org/matrix-react-sdk/pull/6326)
* Don't close settings dialog when opening spaces feedback prompt
[\#6334](https://github.com/matrix-org/matrix-react-sdk/pull/6334)
* Update import location for types
[\#6330](https://github.com/matrix-org/matrix-react-sdk/pull/6330)
* Improve blurhash rendering performance
[\#6329](https://github.com/matrix-org/matrix-react-sdk/pull/6329)
* Use a proper color scheme for codeblocks
[\#6320](https://github.com/matrix-org/matrix-react-sdk/pull/6320)
* Burn `sdk.getComponent()` with 🔥
[\#6308](https://github.com/matrix-org/matrix-react-sdk/pull/6308)
* Fix instances of the Edit Message Composer's save button being wrongly
disabled
[\#6307](https://github.com/matrix-org/matrix-react-sdk/pull/6307)
* Do not generate a lockfile when running in CI
[\#6327](https://github.com/matrix-org/matrix-react-sdk/pull/6327)
* Update lockfile with correct dependencies
[\#6324](https://github.com/matrix-org/matrix-react-sdk/pull/6324)
* Clarify the keys we use when submitting rageshakes
[\#6321](https://github.com/matrix-org/matrix-react-sdk/pull/6321)
* Fix ImageView context menu
[\#6318](https://github.com/matrix-org/matrix-react-sdk/pull/6318)
* TypeScript migration
[\#6315](https://github.com/matrix-org/matrix-react-sdk/pull/6315)
* Move animation to compositor
[\#6310](https://github.com/matrix-org/matrix-react-sdk/pull/6310)
* Reorganize preferences
[\#5742](https://github.com/matrix-org/matrix-react-sdk/pull/5742)
* Fix being able to un-rotate images
[\#6313](https://github.com/matrix-org/matrix-react-sdk/pull/6313)
* Fix icon size in passphrase prompt
[\#6312](https://github.com/matrix-org/matrix-react-sdk/pull/6312)
* Use sleep & defer from js-sdk instead of duplicating it
[\#6305](https://github.com/matrix-org/matrix-react-sdk/pull/6305)
* Convert EventTimeline, EventTimelineSet and TimelineWindow to TS
[\#6295](https://github.com/matrix-org/matrix-react-sdk/pull/6295)
* Comply with new member-delimiter-style rule
[\#6306](https://github.com/matrix-org/matrix-react-sdk/pull/6306)
* Fix Test Linting
[\#6304](https://github.com/matrix-org/matrix-react-sdk/pull/6304)
* Convert Markdown to TypeScript
[\#6303](https://github.com/matrix-org/matrix-react-sdk/pull/6303)
* Convert RoomHeader to TS
[\#6302](https://github.com/matrix-org/matrix-react-sdk/pull/6302)
* Prevent RoomDirectory from exploding when filterString is wrongly nulled
[\#6296](https://github.com/matrix-org/matrix-react-sdk/pull/6296)
* Add support for blurhash (MSC2448)
[\#5099](https://github.com/matrix-org/matrix-react-sdk/pull/5099)
* Remove rateLimitedFunc
[\#6300](https://github.com/matrix-org/matrix-react-sdk/pull/6300)
* Convert some Key Verification classes to TypeScript
[\#6299](https://github.com/matrix-org/matrix-react-sdk/pull/6299)
* Typescript conversion of Composer components and more
[\#6292](https://github.com/matrix-org/matrix-react-sdk/pull/6292)
* Upgrade browserlist target versions
[\#6298](https://github.com/matrix-org/matrix-react-sdk/pull/6298)
* Fix browser crashing when searching for a malformed HTML tag
[\#6297](https://github.com/matrix-org/matrix-react-sdk/pull/6297)
* Add custom audio player
[\#6264](https://github.com/matrix-org/matrix-react-sdk/pull/6264)
* Lint MXC APIs to centralise access
[\#6293](https://github.com/matrix-org/matrix-react-sdk/pull/6293)
* Remove reminescent references to the tinter
[\#6290](https://github.com/matrix-org/matrix-react-sdk/pull/6290)
* More js-sdk type consolidation
[\#6263](https://github.com/matrix-org/matrix-react-sdk/pull/6263)
* Convert MessagePanel, TimelinePanel, ScrollPanel, and more to Typescript
[\#6243](https://github.com/matrix-org/matrix-react-sdk/pull/6243)
* Migrate to `eslint-plugin-matrix-org`
[\#6285](https://github.com/matrix-org/matrix-react-sdk/pull/6285)
* Avoid cyclic dependencies by moving watchers out of constructor
[\#6287](https://github.com/matrix-org/matrix-react-sdk/pull/6287)
* Add spacing between toast buttons with cross browser support in mind
[\#6284](https://github.com/matrix-org/matrix-react-sdk/pull/6284)
* Deprecate Tinter and TintableSVG
[\#6279](https://github.com/matrix-org/matrix-react-sdk/pull/6279)
* Migrate FilePanel to TypeScript
[\#6283](https://github.com/matrix-org/matrix-react-sdk/pull/6283)
Changes in [3.25.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.25.0) (2021-07-05)
=====================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.25.0-rc.1...v3.25.0)

View file

@ -1,6 +1,6 @@
{
"name": "matrix-react-sdk",
"version": "3.25.0",
"version": "3.26.0",
"description": "SDK for matrix.org using React",
"author": "matrix.org",
"repository": {
@ -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",
@ -79,7 +80,7 @@
"katex": "^0.12.0",
"linkifyjs": "^2.1.9",
"lodash": "^4.17.20",
"matrix-js-sdk": "12.0.1",
"matrix-js-sdk": "12.1.0",
"matrix-widget-api": "^0.1.0-beta.15",
"minimist": "^1.2.5",
"opus-recorder": "^8.0.3",

View file

@ -149,6 +149,7 @@
@import "./views/elements/_StyledCheckbox.scss";
@import "./views/elements/_StyledRadioButton.scss";
@import "./views/elements/_SyntaxHighlight.scss";
@import "./views/elements/_TagComposer.scss";
@import "./views/elements/_TextWithTooltip.scss";
@import "./views/elements/_ToggleSwitch.scss";
@import "./views/elements/_Tooltip.scss";
@ -161,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";
@ -200,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";
@ -263,9 +266,9 @@
@import "./views/toasts/_NonUrgentEchoFailureToast.scss";
@import "./views/verification/_VerificationShowSas.scss";
@import "./views/voip/_CallContainer.scss";
@import "./views/voip/_CallPreview.scss";
@import "./views/voip/_CallView.scss";
@import "./views/voip/_CallViewForRoom.scss";
@import "./views/voip/_CallPreview.scss";
@import "./views/voip/_DialPad.scss";
@import "./views/voip/_DialPadContextMenu.scss";
@import "./views/voip/_DialPadModal.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

@ -16,19 +16,16 @@ limitations under the License.
.mx_ReplyThread {
margin-top: 0;
}
.mx_ReplyThread_show {
cursor: pointer;
}
blockquote.mx_ReplyThread {
margin-left: 0;
margin-right: 0;
margin-bottom: 8px;
padding-left: 10px;
border-left: 4px solid $button-bg-color;
.mx_ReplyThread_show {
cursor: pointer;
}
&.mx_ReplyThread_color1 {
border-left-color: $username-variant1-color;
}

View file

@ -46,7 +46,7 @@ limitations under the License.
width: $font-16px;
}
> input[type=radio] {
input[type=radio] {
// Remove the OS's representation
margin: 0;
padding: 0;
@ -112,6 +112,12 @@ limitations under the License.
}
}
}
.mx_RadioButton_innerLabel {
display: flex;
position: relative;
top: 4px;
}
}
.mx_RadioButton_outlined {

View file

@ -0,0 +1,77 @@
/*
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_TagComposer {
.mx_TagComposer_input {
display: flex;
.mx_Field {
flex: 1;
margin: 0; // override from field styles
}
.mx_AccessibleButton {
min-width: 70px;
padding: 0; // override from button styles
margin-left: 16px; // distance from <Field>
}
.mx_Field, .mx_Field input, .mx_AccessibleButton {
// So they look related to each other by feeling the same
border-radius: 8px;
}
}
.mx_TagComposer_tags {
display: flex;
flex-wrap: wrap;
margin-top: 12px; // this plus 12px from the tags makes 24px from the input
.mx_TagComposer_tag {
padding: 6px 8px 8px 12px;
position: relative;
margin-right: 12px;
margin-top: 12px;
// Cheaty way to get an opacified variable colour background
&::before {
content: '';
border-radius: 20px;
background-color: $tertiary-fg-color;
opacity: 0.15;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
// Pass through the pointer otherwise we have effectively put a whole div
// on top of the component, which makes it hard to interact with buttons.
pointer-events: none;
}
}
.mx_AccessibleButton {
background-image: url('$(res)/img/subtract.svg');
width: 16px;
height: 16px;
margin-left: 8px;
display: inline-block;
vertical-align: middle;
cursor: pointer;
}
}
}

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

@ -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

@ -22,11 +22,9 @@ limitations under the License.
max-height: 50vh;
overflow: auto;
box-shadow: 0px -16px 32px $composer-shadow-color;
}
.mx_ReplyPreview_section {
border-bottom: 1px solid $primary-hairline-color;
}
.mx_ReplyPreview_header {
margin: 8px;
@ -52,3 +50,6 @@ limitations under the License.
.mx_ReplyPreview_clear {
clear: both;
}
}
}

View file

@ -15,10 +15,9 @@ limitations under the License.
*/
.mx_ReplyTile {
padding-top: 2px;
padding-bottom: 2px;
font-size: $font-14px;
position: relative;
padding: 2px 0;
font-size: $font-14px;
line-height: $font-16px;
&.mx_ReplyTile_audio .mx_MFileBody_info_icon::before {
@ -38,16 +37,15 @@ limitations under the License.
display: none;
}
}
}
.mx_ReplyTile > a {
> a {
display: flex;
flex-direction: column;
text-decoration: none;
color: $primary-fg-color;
}
.mx_ReplyTile .mx_RedactedBody {
.mx_RedactedBody {
padding: 4px 0 2px 20px;
&::before {
@ -58,7 +56,7 @@ limitations under the License.
}
// We do reply size limiting with CSS to avoid duplicating the TextualBody component.
.mx_ReplyTile .mx_EventTile_content {
.mx_EventTile_content {
$reply-lines: 2;
$line-height: $font-22px;
@ -72,8 +70,7 @@ limitations under the License.
.mx_EventTile_body.mx_EventTile_bigEmoji {
line-height: $line-height !important;
// Override the big emoji override
font-size: $font-14px !important;
font-size: $font-14px !important; // Override the big emoji override
}
// Hide line numbers
@ -102,22 +99,21 @@ limitations under the License.
}
}
.mx_ReplyTile.mx_ReplyTile_info {
&.mx_ReplyTile_info {
padding-top: 0;
}
.mx_ReplyTile .mx_SenderProfile {
color: $primary-fg-color;
.mx_SenderProfile {
font-size: $font-14px;
display: inline-block; /* anti-zalgo, with overflow hidden */
overflow: hidden;
cursor: pointer;
padding-left: 0; /* left gutter */
padding-bottom: 0;
padding-top: 0;
margin: 0;
line-height: $font-17px;
/* the next three lines, along with overflow hidden, truncate long display names */
display: inline-block; // anti-zalgo, with overflow hidden
padding: 0;
margin: 0;
// truncate long display names
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}

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.
@ -14,82 +14,79 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
.mx_UserNotifSettings_tableRow {
display: table-row;
}
.mx_UserNotifSettings_inputCell {
display: table-cell;
padding-bottom: 8px;
padding-right: 8px;
width: 16px;
}
.mx_UserNotifSettings_labelCell {
padding-bottom: 8px;
width: 400px;
display: table-cell;
}
.mx_UserNotifSettings_pushRulesTableWrapper {
padding-bottom: 8px;
}
.mx_UserNotifSettings {
color: $primary-fg-color; // override from default settings page styles
.mx_UserNotifSettings_pushRulesTable {
width: 100%;
width: calc(100% + 12px); // +12px to line up center of 'Noisy' column with toggle switches
table-layout: fixed;
border-collapse: collapse;
border-spacing: 0;
margin-top: 40px;
tr > th {
font-weight: $font-semi-bold;
}
.mx_UserNotifSettings_pushRulesTable thead {
font-weight: bold;
}
.mx_UserNotifSettings_pushRulesTable tbody th {
font-weight: 400;
}
.mx_UserNotifSettings_pushRulesTable tbody th:first-child {
tr > th:first-child {
text-align: left;
font-size: $font-18px;
}
.mx_UserNotifSettings_keywords {
cursor: pointer;
color: $accent-color;
tr > th:nth-child(n + 2) {
color: $secondary-fg-color;
font-size: $font-12px;
vertical-align: middle;
width: 66px;
}
.mx_UserNotifSettings_devicesTable td {
padding-left: 20px;
padding-right: 20px;
tr > td:nth-child(n + 2) {
text-align: center;
}
.mx_UserNotifSettings_notifTable {
display: table;
position: relative;
tr > td {
padding-top: 8px;
}
.mx_UserNotifSettings_notifTable .mx_Spinner {
position: absolute;
}
// Override StyledRadioButton default styles
.mx_RadioButton {
justify-content: center;
.mx_NotificationSound_soundUpload {
.mx_RadioButton_content {
display: none;
}
.mx_NotificationSound_browse {
color: $accent-color;
border: 1px solid $accent-color;
background-color: transparent;
.mx_RadioButton_spacer {
display: none;
}
}
}
.mx_NotificationSound_save {
margin-left: 5px;
color: white;
background-color: $accent-color;
.mx_UserNotifSettings_floatingSection {
margin-top: 40px;
& > div:first-child { // section header
font-size: $font-18px;
font-weight: $font-semi-bold;
}
.mx_NotificationSound_resetSound {
margin-top: 5px;
color: white;
border: $warning-color;
background-color: $warning-color;
> table {
border-collapse: collapse;
border-spacing: 0;
margin-top: 8px;
tr > td:first-child {
// Just for a bit of spacing
padding-right: 8px;
}
}
}
.mx_UserNotifSettings_clearNotifsButton {
margin-top: 8px;
}
.mx_TagComposer {
margin-top: 35px; // lots of distance from the last line of the table
}
}

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

3
res/img/subtract.svg Normal file
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.58167 12.4183 0 8 0C3.58173 0 0 3.58167 0 8C0 12.4183 3.58173 16 8 16ZM3.96967 5.0304L6.93933 8L3.96967 10.9697L5.03033 12.0304L8 9.06067L10.9697 12.0304L12.0303 10.9697L9.06067 8L12.0303 5.0304L10.9697 3.96973L8 6.93945L5.03033 3.96973L3.96967 5.0304Z" fill="#8D97A5"/>
</svg>

After

Width:  |  Height:  |  Size: 461 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

@ -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>;
@ -186,3 +188,5 @@ declare global {
}
);
}
/* eslint-enable @typescript-eslint/naming-convention */

View file

@ -15,6 +15,7 @@ limitations under the License.
*/
import RoomViewStore from './stores/RoomViewStore';
import { EventSubscription } from 'fbemitter';
type Listener = (isActive: boolean) => void;
@ -30,7 +31,7 @@ type Listener = (isActive: boolean) => void;
export class ActiveRoomObserver {
private listeners: {[key: string]: Listener[]} = {};
private _activeRoomId = RoomViewStore.getRoomId();
private readonly roomStoreToken: string;
private readonly roomStoreToken: EventSubscription;
constructor() {
// TODO: We could self-destruct when the last listener goes away, or at least stop listening.

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

@ -18,6 +18,7 @@ import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { User } from "matrix-js-sdk/src/models/user";
import { Room } from "matrix-js-sdk/src/models/room";
import { ResizeMethod } from "matrix-js-sdk/src/@types/partials";
import { split } from "lodash";
import DMRoomMap from './utils/DMRoomMap';
import { mediaFromMxc } from "./customisations/Media";
@ -122,27 +123,13 @@ export function getInitialLetter(name: string): string {
return undefined;
}
let idx = 0;
const initial = name[0];
if ((initial === '@' || initial === '#' || initial === '+') && name[1]) {
idx++;
name = name.substring(1);
}
// string.codePointAt(0) would do this, but that isn't supported by
// some browsers (notably PhantomJS).
let chars = 1;
const first = name.charCodeAt(idx);
// check if its the start of a surrogate pair
if (first >= 0xD800 && first <= 0xDBFF && name[idx+1]) {
const second = name.charCodeAt(idx+1);
if (second >= 0xDC00 && second <= 0xDFFF) {
chars++;
}
}
const firstChar = name.substring(idx, idx+chars);
return firstChar.toUpperCase();
// rely on the grapheme cluster splitter in lodash so that we don't break apart compound emojis
return split(name, "", 1)[0].toUpperCase();
}
export function avatarUrlForRoom(room: Room, width: number, height: number, resizeMethod?: ResizeMethod) {

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

@ -13,9 +13,7 @@ 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 { MatrixClientPeg } from './MatrixClientPeg';
import { _t } from './languageHandler';
import * as Roles from './Roles';
import { isValid3pidInvite } from "./RoomInvite";
@ -32,7 +30,7 @@ import { MatrixEvent } from "matrix-js-sdk/src/models/event";
// any text to display at all. For this reason they return deferred values
// to avoid the expense of looking up translations when they're not needed.
function textForMemberEvent(ev: MatrixEvent): () => string | null {
function textForMemberEvent(ev: MatrixEvent, allowJSX: boolean, showHiddenEvents?: boolean): () => string | null {
// XXX: SYJS-16 "sender is sometimes null for join messages"
const senderName = ev.sender ? ev.sender.name : ev.getSender();
const targetName = ev.target ? ev.target.name : ev.getStateKey();
@ -84,7 +82,7 @@ function textForMemberEvent(ev: MatrixEvent): () => string | null {
return () => _t('%(senderName)s changed their profile picture', { senderName });
} else if (!prevContent.avatar_url && content.avatar_url) {
return () => _t('%(senderName)s set a profile picture', { senderName });
} else if (SettingsStore.getValue("showHiddenEventsInTimeline")) {
} else if (showHiddenEvents ?? SettingsStore.getValue("showHiddenEventsInTimeline")) {
// This is a null rejoin, it will only be visible if using 'show hidden events' (labs)
return () => _t("%(senderName)s made no change", { senderName });
} else {
@ -319,91 +317,7 @@ function textForCanonicalAliasEvent(ev: MatrixEvent): () => string | null {
});
}
function textForCallAnswerEvent(event): () => 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): () => 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): () => string | null {
return () => {
const senderName = event.sender ? event.sender.name : _t('Someone');
return _t('%(senderName)s declined the call.', { senderName });
};
}
function textForCallInviteEvent(event): () => 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): () => string | null {
function textForThreePidInviteEvent(event: MatrixEvent): () => string | null {
const senderName = event.sender ? event.sender.name : event.getSender();
if (!isValid3pidInvite(event)) {
@ -419,7 +333,7 @@ function textForThreePidInviteEvent(event): () => string | null {
});
}
function textForHistoryVisibilityEvent(event): () => string | null {
function textForHistoryVisibilityEvent(event: MatrixEvent): () => string | null {
const senderName = event.sender ? event.sender.name : event.getSender();
switch (event.getContent().history_visibility) {
case 'invited':
@ -441,7 +355,7 @@ function textForHistoryVisibilityEvent(event): () => string | null {
}
// Currently will only display a change if a user's power level is changed
function textForPowerEvent(event): () => string | null {
function textForPowerEvent(event: MatrixEvent): () => string | null {
const senderName = event.sender ? event.sender.name : event.getSender();
if (!event.getPrevContent() || !event.getPrevContent().users ||
!event.getContent() || !event.getContent().users) {
@ -523,7 +437,7 @@ function textForPinnedEvent(event: MatrixEvent, allowJSX: boolean): () => string
return () => _t("%(senderName)s changed the pinned messages for the room.", { senderName });
}
function textForWidgetEvent(event): () => string | null {
function textForWidgetEvent(event: MatrixEvent): () => string | null {
const senderName = event.getSender();
const { name: prevName, type: prevType, url: prevUrl } = event.getPrevContent();
const { name, type, url } = event.getContent() || {};
@ -553,12 +467,12 @@ function textForWidgetEvent(event): () => string | null {
}
}
function textForWidgetLayoutEvent(event): () => string | null {
function textForWidgetLayoutEvent(event: MatrixEvent): () => string | null {
const senderName = event.sender?.name || event.getSender();
return () => _t("%(senderName)s has updated the widget layout", { senderName });
}
function textForMjolnirEvent(event): () => string | null {
function textForMjolnirEvent(event: MatrixEvent): () => string | null {
const senderName = event.getSender();
const { entity: prevEntity } = event.getPrevContent();
const { entity, recommendation, reason } = event.getContent();
@ -646,15 +560,13 @@ function textForMjolnirEvent(event): () => string | null {
}
interface IHandlers {
[type: string]: (ev: MatrixEvent, allowJSX?: boolean) => (() => string | JSX.Element | null);
[type: string]:
(ev: MatrixEvent, allowJSX: boolean, showHiddenEvents?: boolean) =>
(() => string | JSX.Element | null);
}
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 = {
@ -682,14 +594,27 @@ for (const evType of ALL_RULE_TYPES) {
stateHandlers[evType] = textForMjolnirEvent;
}
export function hasText(ev: MatrixEvent): boolean {
/**
* Determines whether the given event has text to display.
* @param ev The event
* @param showHiddenEvents An optional cached setting value for showHiddenEventsInTimeline
* to avoid hitting the settings store
*/
export function hasText(ev: MatrixEvent, showHiddenEvents?: boolean): boolean {
const handler = (ev.isState() ? stateHandlers : handlers)[ev.getType()];
return Boolean(handler?.(ev));
return Boolean(handler?.(ev, false, showHiddenEvents));
}
/**
* Gets the textual content of the given event.
* @param ev The event
* @param allowJSX Whether to output rich JSX content
* @param showHiddenEvents An optional cached setting value for showHiddenEventsInTimeline
* to avoid hitting the settings store
*/
export function textForEvent(ev: MatrixEvent): string;
export function textForEvent(ev: MatrixEvent, allowJSX: true): string | JSX.Element;
export function textForEvent(ev: MatrixEvent, allowJSX = false): string | JSX.Element {
export function textForEvent(ev: MatrixEvent, allowJSX: true, showHiddenEvents?: boolean): string | JSX.Element;
export function textForEvent(ev: MatrixEvent, allowJSX = false, showHiddenEvents?: boolean): string | JSX.Element {
const handler = (ev.isState() ? stateHandlers : handlers)[ev.getType()];
return handler?.(ev, allowJSX)?.() || '';
return handler?.(ev, allowJSX, showHiddenEvents)?.() || '';
}

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';
@ -54,7 +55,11 @@ const membershipTypes = [EventType.RoomMember, EventType.RoomThirdPartyInvite, E
// check if there is a previous event and it has the same sender as this event
// and the types are the same/is in continuedTypes and the time between them is <= CONTINUATION_MAX_INTERVAL
function shouldFormContinuation(prevEvent: MatrixEvent, mxEvent: MatrixEvent): boolean {
function shouldFormContinuation(
prevEvent: MatrixEvent,
mxEvent: MatrixEvent,
showHiddenEvents: boolean,
): boolean {
// sanity check inputs
if (!prevEvent || !prevEvent.sender || !mxEvent.sender) return false;
// check if within the max continuation period
@ -74,7 +79,7 @@ function shouldFormContinuation(prevEvent: MatrixEvent, mxEvent: MatrixEvent): b
mxEvent.sender.getMxcAvatarUrl() !== prevEvent.sender.getMxcAvatarUrl()) return false;
// if we don't have tile for previous event then it was shown by showHiddenEvents and has no SenderProfile
if (!haveTileForEvent(prevEvent)) return false;
if (!haveTileForEvent(prevEvent, showHiddenEvents)) return false;
return true;
}
@ -228,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);
@ -239,7 +247,8 @@ export default class MessagePanel extends React.Component<IProps, IState> {
};
// Cache hidden events setting on mount since Settings is expensive to
// query, and we check this in a hot code path.
// query, and we check this in a hot code path. This is also cached in
// our RoomContext, however we still need a fallback for roomless MessagePanels.
this.showHiddenEventsInTimeline = SettingsStore.getValue("showHiddenEventsInTimeline");
this.showTypingNotificationsWatcherRef =
@ -399,17 +408,21 @@ export default class MessagePanel extends React.Component<IProps, IState> {
return !this.isMounted;
};
private get showHiddenEvents(): boolean {
return this.context?.showHiddenEventsInTimeline ?? this.showHiddenEventsInTimeline;
}
// TODO: Implement granular (per-room) hide options
public shouldShowEvent(mxEv: MatrixEvent): boolean {
if (MatrixClientPeg.get().isUserIgnored(mxEv.getSender())) {
return false; // ignored = no show (only happens if the ignore happens after an event was received)
}
if (this.showHiddenEventsInTimeline) {
if (this.showHiddenEvents) {
return true;
}
if (!haveTileForEvent(mxEv)) {
if (!haveTileForEvent(mxEv, this.showHiddenEvents)) {
return false; // no tile = no show
}
@ -567,9 +580,23 @@ 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);
grouper.add(mxEv, this.showHiddenEvents);
continue;
} else {
// not part of group, so get the group tiles, close the
@ -644,12 +671,15 @@ 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?
const continuation = !wantsDateSeparator && shouldFormContinuation(prevEvent, mxEv);
const continuation = !wantsDateSeparator &&
shouldFormContinuation(prevEvent, mxEv, this.showHiddenEvents);
const eventId = mxEv.getId();
const highlight = (eventId === this.props.highlightedEventId);
@ -680,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}>
@ -702,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}
@ -710,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>,
);
@ -946,7 +980,7 @@ abstract class BaseGrouper {
}
public abstract shouldGroup(ev: MatrixEvent): boolean;
public abstract add(ev: MatrixEvent): void;
public abstract add(ev: MatrixEvent, showHiddenEvents?: boolean): void;
public abstract getTiles(): ReactNode[];
public abstract getNewPrevEvent(): MatrixEvent;
}
@ -1200,10 +1234,10 @@ class MemberGrouper extends BaseGrouper {
return membershipTypes.includes(ev.getType() as EventType);
}
public add(ev: MatrixEvent): void {
public add(ev: MatrixEvent, showHiddenEvents?: boolean): void {
if (ev.getType() === EventType.RoomMember) {
// We can ignore any events that don't actually have a message to display
if (!hasText(ev)) return;
if (!hasText(ev, showHiddenEvents)) return;
}
this.readMarker = this.readMarker || this.panel.readMarkerForEvent(
ev.getId(),

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

@ -166,6 +166,7 @@ export interface IState {
canReply: boolean;
layout: Layout;
lowBandwidth: boolean;
showHiddenEventsInTimeline: boolean;
showReadReceipts: boolean;
showRedactions: boolean;
showJoinLeaves: boolean;
@ -230,6 +231,7 @@ export default class RoomView extends React.Component<IProps, IState> {
canReply: false,
layout: SettingsStore.getValue("layout"),
lowBandwidth: SettingsStore.getValue("lowBandwidth"),
showHiddenEventsInTimeline: SettingsStore.getValue("showHiddenEventsInTimeline"),
showReadReceipts: true,
showRedactions: true,
showJoinLeaves: true,
@ -267,6 +269,9 @@ export default class RoomView extends React.Component<IProps, IState> {
SettingsStore.watchSetting("lowBandwidth", null, () =>
this.setState({ lowBandwidth: SettingsStore.getValue("lowBandwidth") }),
),
SettingsStore.watchSetting("showHiddenEventsInTimeline", null, () =>
this.setState({ showHiddenEventsInTimeline: SettingsStore.getValue("showHiddenEventsInTimeline") }),
),
];
}
@ -1388,7 +1393,7 @@ export default class RoomView extends React.Component<IProps, IState> {
continue;
}
if (!haveTileForEvent(mxEv)) {
if (!haveTileForEvent(mxEv, this.state.showHiddenEventsInTimeline)) {
// XXX: can this ever happen? It will make the result count
// not match the displayed count.
continue;

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");
@ -1337,7 +1337,8 @@ class TimelinePanel extends React.Component<IProps, IState> {
const shouldIgnore = !!ev.status || // local echo
(ignoreOwn && ev.getSender() === myUserId); // own message
const isWithoutTile = !haveTileForEvent(ev) || shouldHideEvent(ev, this.context);
const isWithoutTile = !haveTileForEvent(ev, this.context?.showHiddenEventsInTimeline) ||
shouldHideEvent(ev, this.context);
if (isWithoutTile || !node) {
// don't start counting if the event should be ignored,

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

@ -22,6 +22,7 @@ import ImageView from '../elements/ImageView';
import { MatrixClientPeg } from '../../../MatrixClientPeg';
import Modal from '../../../Modal';
import * as Avatar from '../../../Avatar';
import DMRoomMap from "../../../utils/DMRoomMap";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { mediaFromMxc } from "../../../customisations/Media";
import { IOOBData } from '../../../stores/ThreepidInviteStore';
@ -131,11 +132,14 @@ export default class RoomAvatar extends React.Component<IProps, IState> {
const { room, oobData, viewAvatarOnClick, onClick, ...otherProps } = this.props;
const roomName = room ? room.name : oobData.name;
// If the room is a DM, we use the other user's ID for the color hash
// in order to match the room avatar with their avatar
const idName = room ? (DMRoomMap.shared().getUserIdForRoomId(room.roomId) ?? room.roomId) : null;
return (
<BaseAvatar {...otherProps}
name={roomName}
idName={room ? room.roomId : null}
idName={idName}
urls={this.state.urls}
onClick={viewAvatarOnClick && this.state.urls[0] ? this.onRoomAvatarClick : onClick}
/>

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

@ -205,13 +205,14 @@ export default class ServerPickerDialog extends React.PureComponent<IProps, ISta
className="mx_ServerPickerDialog_otherHomeserverRadio"
checked={!this.state.defaultChosen}
onChange={this.onOtherChosen}
childrenInLabel={false}
>
<Field
type="text"
className="mx_ServerPickerDialog_otherHomeserver"
label={_t("Other homeserver")}
onChange={this.onHomeserverChange}
onClick={this.onOtherChosen}
onFocus={this.onOtherChosen}
ref={this.fieldRef}
onValidate={this.onHomeserverValidate}
value={this.state.otherHomeserver}

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

@ -488,8 +488,8 @@ export default class ImageView extends React.Component<IProps, IState> {
>
<img
src={this.props.src}
title={this.props.name}
style={style}
alt={this.props.name}
ref={this.image}
className="mx_ImageView_image"
draggable={true}

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

@ -14,14 +14,14 @@ 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 { _t } from '../../../languageHandler';
import PropTypes from 'prop-types';
import dis from '../../../dispatcher/dispatcher';
import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
import { makeUserPermalink, RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
import SettingsStore from "../../../settings/SettingsStore";
import { LayoutPropType } from "../../../settings/Layout";
import { Layout } from "../../../settings/Layout";
import escapeHtml from "escape-html";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { getUserNameColorClass } from "../../../utils/FormattingUtils";
@ -32,51 +32,54 @@ import { replaceableComponent } from "../../../utils/replaceableComponent";
import Spinner from './Spinner';
import ReplyTile from "../rooms/ReplyTile";
import Pill from './Pill';
import { Room } from 'matrix-js-sdk/src/models/room';
interface IProps {
// the latest event in this chain of replies
parentEv?: MatrixEvent;
// called when the ReplyThread contents has changed, including EventTiles thereof
onHeightChanged: () => void;
permalinkCreator: RoomPermalinkCreator;
// Specifies which layout to use.
layout?: Layout;
// Whether to always show a timestamp
alwaysShowTimestamps?: boolean;
}
interface IState {
// The loaded events to be rendered as linear-replies
events: MatrixEvent[];
// The latest loaded event which has not yet been shown
loadedEv: MatrixEvent;
// Whether the component is still loading more events
loading: boolean;
// Whether as error was encountered fetching a replied to event.
err: boolean;
}
// This component does no cycle detection, simply because the only way to make such a cycle would be to
// craft event_id's, using a homeserver that generates predictable event IDs; even then the impact would
// be low as each event being loaded (after the first) is triggered by an explicit user action.
@replaceableComponent("views.elements.ReplyThread")
export default class ReplyThread extends React.Component {
static propTypes = {
// the latest event in this chain of replies
parentEv: PropTypes.instanceOf(MatrixEvent),
// called when the ReplyThread contents has changed, including EventTiles thereof
onHeightChanged: PropTypes.func.isRequired,
permalinkCreator: PropTypes.instanceOf(RoomPermalinkCreator).isRequired,
// Specifies which layout to use.
layout: LayoutPropType,
// Whether to always show a timestamp
alwaysShowTimestamps: PropTypes.bool,
};
export default class ReplyThread extends React.Component<IProps, IState> {
static contextType = MatrixClientContext;
private unmounted = false;
private room: Room;
constructor(props, context) {
super(props, context);
this.state = {
// The loaded events to be rendered as linear-replies
events: [],
// The latest loaded event which has not yet been shown
loadedEv: null,
// Whether the component is still loading more events
loading: true,
// Whether as error was encountered fetching a replied to event.
err: false,
};
this.unmounted = false;
this.room = this.context.getRoom(this.props.parentEv.getRoomId());
this.onQuoteClick = this.onQuoteClick.bind(this);
this.canCollapse = this.canCollapse.bind(this);
this.collapse = this.collapse.bind(this);
}
static getParentEventId(ev) {
public static getParentEventId(ev: MatrixEvent): string {
if (!ev || ev.isRedacted()) return;
// XXX: For newer relations (annotations, replacements, etc.), we now
@ -92,7 +95,7 @@ export default class ReplyThread extends React.Component {
}
// Part of Replies fallback support
static stripPlainReply(body) {
public static stripPlainReply(body: string): string {
// Removes lines beginning with `> ` until you reach one that doesn't.
const lines = body.split('\n');
while (lines.length && lines[0].startsWith('> ')) lines.shift();
@ -102,7 +105,7 @@ export default class ReplyThread extends React.Component {
}
// Part of Replies fallback support
static stripHTMLReply(html) {
public static stripHTMLReply(html: string): string {
// Sanitize the original HTML for inclusion in <mx-reply>. We allow
// any HTML, since the original sender could use special tags that we
// don't recognize, but want to pass along to any recipients who do
@ -124,7 +127,10 @@ export default class ReplyThread extends React.Component {
}
// Part of Replies fallback support
static getNestedReplyText(ev, permalinkCreator) {
public static getNestedReplyText(
ev: MatrixEvent,
permalinkCreator: RoomPermalinkCreator,
): { body: string, html: string } {
if (!ev) return null;
let { body, formatted_body: html } = ev.getContent();
@ -200,7 +206,7 @@ export default class ReplyThread extends React.Component {
return { body, html };
}
static makeReplyMixIn(ev) {
public static makeReplyMixIn(ev: MatrixEvent) {
if (!ev) return {};
return {
'm.relates_to': {
@ -211,10 +217,15 @@ export default class ReplyThread extends React.Component {
};
}
static makeThread(parentEv, onHeightChanged, permalinkCreator, ref, layout, alwaysShowTimestamps) {
if (!ReplyThread.getParentEventId(parentEv)) {
return null;
}
public static makeThread(
parentEv: MatrixEvent,
onHeightChanged: () => void,
permalinkCreator: RoomPermalinkCreator,
ref: React.RefObject<ReplyThread>,
layout: Layout,
alwaysShowTimestamps: boolean,
): JSX.Element {
if (!ReplyThread.getParentEventId(parentEv)) return null;
return <ReplyThread
parentEv={parentEv}
onHeightChanged={onHeightChanged}
@ -237,7 +248,7 @@ export default class ReplyThread extends React.Component {
this.unmounted = true;
}
async initialize() {
private async initialize(): Promise<void> {
const { parentEv } = this.props;
// at time of making this component we checked that props.parentEv has a parentEventId
const ev = await this.getEvent(ReplyThread.getParentEventId(parentEv));
@ -256,7 +267,7 @@ export default class ReplyThread extends React.Component {
}
}
async getNextEvent(ev) {
private async getNextEvent(ev: MatrixEvent): Promise<MatrixEvent> {
try {
const inReplyToEventId = ReplyThread.getParentEventId(ev);
return await this.getEvent(inReplyToEventId);
@ -265,7 +276,7 @@ export default class ReplyThread extends React.Component {
}
}
async getEvent(eventId) {
private async getEvent(eventId: string): Promise<MatrixEvent> {
if (!eventId) return null;
const event = this.room.findEventById(eventId);
if (event) return event;
@ -282,15 +293,15 @@ export default class ReplyThread extends React.Component {
return this.room.findEventById(eventId);
}
canCollapse() {
public canCollapse = (): boolean => {
return this.state.events.length > 1;
}
};
collapse() {
public collapse = (): void => {
this.initialize();
}
};
async onQuoteClick() {
private onQuoteClick = async (): Promise<void> => {
const events = [this.state.loadedEv, ...this.state.events];
let loadedEv = null;
@ -304,9 +315,9 @@ export default class ReplyThread extends React.Component {
});
dis.fire(Action.FocusSendMessageComposer);
}
};
getReplyThreadColorClass(ev) {
private getReplyThreadColorClass(ev: MatrixEvent): string {
return getUserNameColorClass(ev.getSender()).replace("Username", "ReplyThread");
}

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

@ -1,39 +0,0 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import PropTypes from "prop-types";
import { _t } from "../../../languageHandler";
const Spinner = ({ w = 32, h = 32, message }) => (
<div className="mx_Spinner">
{ message && <React.Fragment><div className="mx_Spinner_Msg">{ message }</div>&nbsp;</React.Fragment> }
<div
className="mx_Spinner_icon"
style={{ width: w, height: h }}
aria-label={_t("Loading...")}
></div>
</div>
);
Spinner.propTypes = {
w: PropTypes.number,
h: PropTypes.number,
message: PropTypes.node,
};
export default Spinner;

View file

@ -0,0 +1,45 @@
/*
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.
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 { _t } from "../../../languageHandler";
interface IProps {
w?: number;
h?: number;
message?: string;
}
export default class Spinner extends React.PureComponent<IProps> {
public static defaultProps: Partial<IProps> = {
w: 32,
h: 32,
};
public render() {
const { w, h, message } = this.props;
return (
<div className="mx_Spinner">
{ message && <React.Fragment><div className="mx_Spinner_Msg">{ message }</div>&nbsp;</React.Fragment> }
<div
className="mx_Spinner_icon"
style={{ width: w, height: h }}
aria-label={_t("Loading...")}
/>
</div>
);
}
}

View file

@ -20,6 +20,10 @@ import { replaceableComponent } from "../../../utils/replaceableComponent";
interface IProps extends React.InputHTMLAttributes<HTMLInputElement> {
outlined?: boolean;
// If true (default), the children will be contained within a <label> element
// If false, they'll be in a div. Putting interactive components that have labels
// themselves in labels can cause strange bugs like https://github.com/vector-im/element-web/issues/18031
childrenInLabel?: boolean;
}
interface IState {
@ -29,10 +33,11 @@ interface IState {
export default class StyledRadioButton extends React.PureComponent<IProps, IState> {
public static readonly defaultProps = {
className: '',
childrenInLabel: true,
};
public render() {
const { children, className, disabled, outlined, ...otherProps } = this.props;
const { children, className, disabled, outlined, childrenInLabel, ...otherProps } = this.props;
const _className = classnames(
'mx_RadioButton',
className,
@ -42,12 +47,27 @@ export default class StyledRadioButton extends React.PureComponent<IProps, IStat
"mx_RadioButton_checked": this.props.checked,
"mx_RadioButton_outlined": outlined,
});
return <label className={_className}>
const radioButton = <React.Fragment>
<input type='radio' disabled={disabled} {...otherProps} />
{ /* Used to render the radio button circle */ }
<div><div /></div>
</React.Fragment>;
if (childrenInLabel) {
return <label className={_className}>
{ radioButton }
<div className="mx_RadioButton_content">{ children }</div>
<div className="mx_RadioButton_spacer" />
</label>;
} else {
return <div className={_className}>
<label className="mx_RadioButton_innerLabel">
{ radioButton }
</label>
<div className="mx_RadioButton_content">{ children }</div>
<div className="mx_RadioButton_spacer" />
</div>;
}
}
}

View file

@ -0,0 +1,91 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { ChangeEvent, FormEvent } from "react";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import Field from "./Field";
import { _t } from "../../../languageHandler";
import AccessibleButton from "./AccessibleButton";
interface IProps {
tags: string[];
onAdd: (tag: string) => void;
onRemove: (tag: string) => void;
disabled?: boolean;
label?: string;
placeholder?: string;
}
interface IState {
newTag: string;
}
/**
* A simple, controlled, composer for entering string tags. Contains a simple
* input, add button, and per-tag remove button.
*/
@replaceableComponent("views.elements.TagComposer")
export default class TagComposer extends React.PureComponent<IProps, IState> {
public constructor(props: IProps) {
super(props);
this.state = {
newTag: "",
};
}
private onInputChange = (ev: ChangeEvent<HTMLInputElement>) => {
this.setState({ newTag: ev.target.value });
};
private onAdd = (ev: FormEvent) => {
ev.preventDefault();
if (!this.state.newTag) return;
this.props.onAdd(this.state.newTag);
this.setState({ newTag: "" });
};
private onRemove(tag: string) {
// We probably don't need to proxy this, but for
// sanity of `this` we'll do so anyways.
this.props.onRemove(tag);
}
public render() {
return <div className='mx_TagComposer'>
<form className='mx_TagComposer_input' onSubmit={this.onAdd}>
<Field
value={this.state.newTag}
onChange={this.onInputChange}
label={this.props.label || _t("Keyword")}
placeholder={this.props.placeholder || _t("New keyword")}
disabled={this.props.disabled}
autoComplete="off"
/>
<AccessibleButton onClick={this.onAdd} kind='primary' disabled={this.props.disabled}>
{ _t("Add") }
</AccessibleButton>
</form>
<div className='mx_TagComposer_tags'>
{ this.props.tags.map((t, i) => (<div className='mx_TagComposer_tag' key={i}>
<span>{ t }</span>
<AccessibleButton onClick={this.onRemove.bind(this, t)} disabled={this.props.disabled} />
</div>)) }
</div>
</div>;
}
}

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

@ -273,13 +273,6 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
this.downloadImage();
this.setState({ showImage: true });
} // else don't download anything because we don't want to display anything.
this._afterComponentDidMount();
}
// To be overridden by subclasses (e.g. MStickerBody) for further
// initialisation after componentDidMount
_afterComponentDidMount() {
}
componentWillUnmount() {
@ -402,9 +395,9 @@ export default class MImageBody extends React.Component<IBodyProps, 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

View file

@ -14,9 +14,10 @@ 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 React from "react";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import RoomContext from "../../../contexts/RoomContext";
import * as TextForEvent from "../../../TextForEvent";
import { replaceableComponent } from "../../../utils/replaceableComponent";
@ -26,11 +27,11 @@ interface IProps {
@replaceableComponent("views.messages.TextualEvent")
export default class TextualEvent extends React.Component<IProps> {
render() {
const text = TextForEvent.textForEvent(this.props.mxEvent, true);
if (!text || (text as string).length === 0) return null;
return (
<div className="mx_TextualEvent">{ text }</div>
);
static contextType = RoomContext;
public render() {
const text = TextForEvent.textForEvent(this.props.mxEvent, true, this.context?.showHiddenEventsInTimeline);
if (!text) return null;
return <div className="mx_TextualEvent">{ text }</div>;
}
}

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 {
@ -320,7 +322,7 @@ export default class EventTile extends React.Component<IProps, IState> {
private suppressReadReceiptAnimation: boolean;
private isListeningForReceipts: boolean;
private tile = React.createRef();
private replyThread = React.createRef();
private replyThread = React.createRef<ReplyThread>();
public readonly ref = createRef<HTMLElement>();
@ -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 }
</>)
@ -1160,7 +1176,7 @@ function isMessageEvent(ev) {
return (messageTypes.includes(ev.getType()));
}
export function haveTileForEvent(e) {
export function haveTileForEvent(e: MatrixEvent, showHiddenEvents?: boolean) {
// Only messages have a tile (black-rectangle) if redacted
if (e.isRedacted() && !isMessageEvent(e)) return false;
@ -1170,7 +1186,7 @@ export function haveTileForEvent(e) {
const handler = getHandlerTile(e);
if (handler === undefined) return false;
if (handler === 'messages.TextualEvent') {
return hasText(e);
return hasText(e, showHiddenEvents);
} else if (handler === 'messages.RoomCreate') {
return Boolean(e.getContent()['predecessor']);
} else {

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

@ -18,10 +18,11 @@ import React from 'react';
import dis from '../../../dispatcher/dispatcher';
import { _t } from '../../../languageHandler';
import RoomViewStore from '../../../stores/RoomViewStore';
import PropTypes from "prop-types";
import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import ReplyTile from './ReplyTile';
import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
import { EventSubscription } from 'fbemitter';
function cancelQuoting() {
dis.dispatch({
@ -30,41 +31,46 @@ function cancelQuoting() {
});
}
interface IProps {
permalinkCreator: RoomPermalinkCreator;
}
interface IState {
event: MatrixEvent;
}
@replaceableComponent("views.rooms.ReplyPreview")
export default class ReplyPreview extends React.Component {
static propTypes = {
permalinkCreator: PropTypes.instanceOf(RoomPermalinkCreator).isRequired,
};
export default class ReplyPreview extends React.Component<IProps, IState> {
private unmounted = false;
private readonly roomStoreToken: EventSubscription;
constructor(props) {
super(props);
this.unmounted = false;
this.state = {
event: RoomViewStore.getQuotingEvent(),
};
this._onRoomViewStoreUpdate = this._onRoomViewStoreUpdate.bind(this);
this._roomStoreToken = RoomViewStore.addListener(this._onRoomViewStoreUpdate);
this.roomStoreToken = RoomViewStore.addListener(this.onRoomViewStoreUpdate);
}
componentWillUnmount() {
this.unmounted = true;
// Remove RoomStore listener
if (this._roomStoreToken) {
this._roomStoreToken.remove();
if (this.roomStoreToken) {
this.roomStoreToken.remove();
}
}
_onRoomViewStoreUpdate() {
private onRoomViewStoreUpdate = (): void => {
if (this.unmounted) return;
const event = RoomViewStore.getQuotingEvent();
if (this.state.event !== event) {
this.setState({ event });
}
}
};
render() {
if (!this.state.event) return null;

View file

@ -419,7 +419,7 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
>
<IconizedContextMenuOptionList first>
<IconizedContextMenuRadio
label={_t("Use default")}
label={_t("Global")}
active={state === ALL_MESSAGES}
iconClassName="mx_RoomTile_iconBell"
onClick={this.onClickAllNotifs}
@ -530,7 +530,7 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
) : null }
<IconizedContextMenuOption
onClick={this.onCopyRoomClick}
label={_t("Copy Link")}
label={_t("Copy Room Link")}
iconClassName="mx_RoomTile_iconCopyLink"
/>
<IconizedContextMenuOption

View file

@ -15,14 +15,15 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import React from "react";
import { SearchResult } from "matrix-js-sdk/src/models/search-result";
import EventTile, { haveTileForEvent } from "./EventTile";
import DateSeparator from '../messages/DateSeparator';
import RoomContext from "../../../contexts/RoomContext";
import SettingsStore from "../../../settings/SettingsStore";
import { UIFeature } from "../../../settings/UIFeature";
import { RoomPermalinkCreator } from '../../../utils/permalinks/Permalinks';
import { replaceableComponent } from "../../../utils/replaceableComponent";
import DateSeparator from "../messages/DateSeparator";
import EventTile, { haveTileForEvent } from "./EventTile";
interface IProps {
// a matrix-js-sdk SearchResult containing the details of this result
@ -37,6 +38,8 @@ interface IProps {
@replaceableComponent("views.rooms.SearchResultTile")
export default class SearchResultTile extends React.Component<IProps> {
static contextType = RoomContext;
public render() {
const result = this.props.searchResult;
const mxEv = result.context.getEvent();
@ -44,7 +47,10 @@ export default class SearchResultTile extends React.Component<IProps> {
const ts1 = mxEv.getTs();
const ret = [<DateSeparator key={ts1 + "-search"} ts={ts1} />];
const layout = SettingsStore.getValue("layout");
const isTwelveHour = SettingsStore.getValue("showTwelveHourTimestamps");
const alwaysShowTimestamps = SettingsStore.getValue("alwaysShowTimestamps");
const enableFlair = SettingsStore.getValue(UIFeature.Flair);
const timeline = result.context.getTimeline();
for (let j = 0; j < timeline.length; j++) {
@ -54,26 +60,25 @@ export default class SearchResultTile extends React.Component<IProps> {
if (!contextual) {
highlights = this.props.searchHighlights;
}
if (haveTileForEvent(ev)) {
ret.push((
if (haveTileForEvent(ev, this.context?.showHiddenEventsInTimeline)) {
ret.push(
<EventTile
key={`${eventId}+${j}`}
mxEvent={ev}
layout={layout}
contextual={contextual}
highlights={highlights}
permalinkCreator={this.props.permalinkCreator}
highlightLink={this.props.resultLink}
onHeightChanged={this.props.onHeightChanged}
isTwelveHour={SettingsStore.getValue("showTwelveHourTimestamps")}
isTwelveHour={isTwelveHour}
alwaysShowTimestamps={alwaysShowTimestamps}
enableFlair={SettingsStore.getValue(UIFeature.Flair)}
/>
));
enableFlair={enableFlair}
/>,
);
}
}
return (
<li data-scroll-tokens={eventId}>
{ ret }
</li>);
return <li data-scroll-tokens={eventId}>{ ret }</li>;
}
}

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

@ -1,917 +0,0 @@
/*
Copyright 2016 OpenMarket Ltd
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import * as sdk from '../../../index';
import { _t } from '../../../languageHandler';
import { MatrixClientPeg } from '../../../MatrixClientPeg';
import SettingsStore from '../../../settings/SettingsStore';
import Modal from '../../../Modal';
import {
NotificationUtils,
VectorPushRulesDefinitions,
PushRuleVectorState,
ContentRules,
} from '../../../notifications';
import SdkConfig from "../../../SdkConfig";
import LabelledToggleSwitch from "../elements/LabelledToggleSwitch";
import AccessibleButton from "../elements/AccessibleButton";
import { SettingLevel } from "../../../settings/SettingLevel";
import { UIFeature } from "../../../settings/UIFeature";
import { replaceableComponent } from "../../../utils/replaceableComponent";
// TODO: this "view" component still has far too much application logic in it,
// which should be factored out to other files.
// TODO: this component also does a lot of direct poking into this.state, which
// is VERY NAUGHTY.
/**
* Rules that Vector used to set in order to override the actions of default rules.
* These are used to port peoples existing overrides to match the current API.
* These can be removed and forgotten once everyone has moved to the new client.
*/
const LEGACY_RULES = {
"im.vector.rule.contains_display_name": ".m.rule.contains_display_name",
"im.vector.rule.room_one_to_one": ".m.rule.room_one_to_one",
"im.vector.rule.room_message": ".m.rule.message",
"im.vector.rule.invite_for_me": ".m.rule.invite_for_me",
"im.vector.rule.call": ".m.rule.call",
"im.vector.rule.notices": ".m.rule.suppress_notices",
};
function portLegacyActions(actions) {
const decoded = NotificationUtils.decodeActions(actions);
if (decoded !== null) {
return NotificationUtils.encodeActions(decoded);
} else {
// We don't recognise one of the actions here, so we don't try to
// canonicalise them.
return actions;
}
}
@replaceableComponent("views.settings.Notifications")
export default class Notifications extends React.Component {
static phases = {
LOADING: "LOADING", // The component is loading or sending data to the hs
DISPLAY: "DISPLAY", // The component is ready and display data
ERROR: "ERROR", // There was an error
};
state = {
phase: Notifications.phases.LOADING,
masterPushRule: undefined, // The master rule ('.m.rule.master')
vectorPushRules: [], // HS default push rules displayed in Vector UI
vectorContentRules: { // Keyword push rules displayed in Vector UI
vectorState: PushRuleVectorState.ON,
rules: [],
},
externalPushRules: [], // Push rules (except content rule) that have been defined outside Vector UI
externalContentRules: [], // Keyword push rules that have been defined outside Vector UI
threepids: [], // used for email notifications
};
componentDidMount() {
this._refreshFromServer();
}
onEnableNotificationsChange = (checked) => {
const self = this;
this.setState({
phase: Notifications.phases.LOADING,
});
MatrixClientPeg.get().setPushRuleEnabled(
'global', self.state.masterPushRule.kind, self.state.masterPushRule.rule_id, !checked,
).then(function() {
self._refreshFromServer();
});
};
onEnableDesktopNotificationsChange = (checked) => {
SettingsStore.setValue(
"notificationsEnabled", null,
SettingLevel.DEVICE,
checked,
).finally(() => {
this.forceUpdate();
});
};
onEnableDesktopNotificationBodyChange = (checked) => {
SettingsStore.setValue(
"notificationBodyEnabled", null,
SettingLevel.DEVICE,
checked,
).finally(() => {
this.forceUpdate();
});
};
onEnableAudioNotificationsChange = (checked) => {
SettingsStore.setValue(
"audioNotificationsEnabled", null,
SettingLevel.DEVICE,
checked,
).finally(() => {
this.forceUpdate();
});
};
/*
* Returns the email pusher (pusher of type 'email') for a given
* email address. Email pushers all have the same app ID, so since
* pushers are unique over (app ID, pushkey), there will be at most
* one such pusher.
*/
getEmailPusher(pushers, address) {
if (pushers === undefined) {
return undefined;
}
for (let i = 0; i < pushers.length; ++i) {
if (pushers[i].kind === 'email' && pushers[i].pushkey === address) {
return pushers[i];
}
}
return undefined;
}
onEnableEmailNotificationsChange = (address, checked) => {
let emailPusherPromise;
if (checked) {
const data = {};
data['brand'] = SdkConfig.get().brand;
emailPusherPromise = MatrixClientPeg.get().setPusher({
kind: 'email',
app_id: 'm.email',
pushkey: address,
app_display_name: 'Email Notifications',
device_display_name: address,
lang: navigator.language,
data: data,
append: true, // We always append for email pushers since we don't want to stop other accounts notifying to the same email address
});
} else {
const emailPusher = this.getEmailPusher(this.state.pushers, address);
emailPusher.kind = null;
emailPusherPromise = MatrixClientPeg.get().setPusher(emailPusher);
}
emailPusherPromise.then(() => {
this._refreshFromServer();
}, (error) => {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Error saving email notification preferences', '', ErrorDialog, {
title: _t('Error saving email notification preferences'),
description: _t('An error occurred whilst saving your email notification preferences.'),
});
});
};
onNotifStateButtonClicked = (event) => {
// FIXME: use .bind() rather than className metadata here surely
const vectorRuleId = event.target.className.split("-")[0];
const newPushRuleVectorState = event.target.className.split("-")[1];
if ("_keywords" === vectorRuleId) {
this._setKeywordsPushRuleVectorState(newPushRuleVectorState);
} else {
const rule = this.getRule(vectorRuleId);
if (rule) {
this._setPushRuleVectorState(rule, newPushRuleVectorState);
}
}
};
onKeywordsClicked = (event) => {
// Compute the keywords list to display
let keywords = [];
for (const i in this.state.vectorContentRules.rules) {
const rule = this.state.vectorContentRules.rules[i];
keywords.push(rule.pattern);
}
if (keywords.length) {
// As keeping the order of per-word push rules hs side is a bit tricky to code,
// display the keywords in alphabetical order to the user
keywords.sort();
keywords = keywords.join(", ");
} else {
keywords = "";
}
const TextInputDialog = sdk.getComponent("dialogs.TextInputDialog");
Modal.createTrackedDialog('Keywords Dialog', '', TextInputDialog, {
title: _t('Keywords'),
description: _t('Enter keywords separated by a comma:'),
button: _t('OK'),
value: keywords,
onFinished: (shouldLeave, newValue) => {
if (shouldLeave && newValue !== keywords) {
let newKeywords = newValue.split(',');
for (const i in newKeywords) {
newKeywords[i] = newKeywords[i].trim();
}
// Remove duplicates and empty
newKeywords = newKeywords.reduce(function(array, keyword) {
if (keyword !== "" && array.indexOf(keyword) < 0) {
array.push(keyword);
}
return array;
}, []);
this._setKeywords(newKeywords);
}
},
});
};
getRule(vectorRuleId) {
for (const i in this.state.vectorPushRules) {
const rule = this.state.vectorPushRules[i];
if (rule.vectorRuleId === vectorRuleId) {
return rule;
}
}
}
_setPushRuleVectorState(rule, newPushRuleVectorState) {
if (rule && rule.vectorState !== newPushRuleVectorState) {
this.setState({
phase: Notifications.phases.LOADING,
});
const self = this;
const cli = MatrixClientPeg.get();
const deferreds = [];
const ruleDefinition = VectorPushRulesDefinitions[rule.vectorRuleId];
if (rule.rule) {
const actions = ruleDefinition.vectorStateToActions[newPushRuleVectorState];
if (!actions) {
// The new state corresponds to disabling the rule.
deferreds.push(cli.setPushRuleEnabled('global', rule.rule.kind, rule.rule.rule_id, false));
} else {
// The new state corresponds to enabling the rule and setting specific actions
deferreds.push(this._updatePushRuleActions(rule.rule, actions, true));
}
}
Promise.all(deferreds).then(function() {
self._refreshFromServer();
}, function(error) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
console.error("Failed to change settings: " + error);
Modal.createTrackedDialog('Failed to change settings', '', ErrorDialog, {
title: _t('Failed to change settings'),
description: ((error && error.message) ? error.message : _t('Operation failed')),
onFinished: self._refreshFromServer,
});
});
}
}
_setKeywordsPushRuleVectorState(newPushRuleVectorState) {
// Is there really a change?
if (this.state.vectorContentRules.vectorState === newPushRuleVectorState
|| this.state.vectorContentRules.rules.length === 0) {
return;
}
const self = this;
const cli = MatrixClientPeg.get();
this.setState({
phase: Notifications.phases.LOADING,
});
// Update all rules in self.state.vectorContentRules
const deferreds = [];
for (const i in this.state.vectorContentRules.rules) {
const rule = this.state.vectorContentRules.rules[i];
let enabled; let actions;
switch (newPushRuleVectorState) {
case PushRuleVectorState.ON:
if (rule.actions.length !== 1) {
actions = PushRuleVectorState.actionsFor(PushRuleVectorState.ON);
}
if (this.state.vectorContentRules.vectorState === PushRuleVectorState.OFF) {
enabled = true;
}
break;
case PushRuleVectorState.LOUD:
if (rule.actions.length !== 3) {
actions = PushRuleVectorState.actionsFor(PushRuleVectorState.LOUD);
}
if (this.state.vectorContentRules.vectorState === PushRuleVectorState.OFF) {
enabled = true;
}
break;
case PushRuleVectorState.OFF:
enabled = false;
break;
}
if (actions) {
// Note that the workaround in _updatePushRuleActions will automatically
// enable the rule
deferreds.push(this._updatePushRuleActions(rule, actions, enabled));
} else if (enabled != undefined) {
deferreds.push(cli.setPushRuleEnabled('global', rule.kind, rule.rule_id, enabled));
}
}
Promise.all(deferreds).then(function(resps) {
self._refreshFromServer();
}, function(error) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
console.error("Can't update user notification settings: " + error);
Modal.createTrackedDialog('Can\'t update user notifcation settings', '', ErrorDialog, {
title: _t('Can\'t update user notification settings'),
description: ((error && error.message) ? error.message : _t('Operation failed')),
onFinished: self._refreshFromServer,
});
});
}
_setKeywords(newKeywords) {
this.setState({
phase: Notifications.phases.LOADING,
});
const self = this;
const cli = MatrixClientPeg.get();
const removeDeferreds = [];
// Remove per-word push rules of keywords that are no more in the list
const vectorContentRulesPatterns = [];
for (const i in self.state.vectorContentRules.rules) {
const rule = self.state.vectorContentRules.rules[i];
vectorContentRulesPatterns.push(rule.pattern);
if (newKeywords.indexOf(rule.pattern) < 0) {
removeDeferreds.push(cli.deletePushRule('global', rule.kind, rule.rule_id));
}
}
// If the keyword is part of `externalContentRules`, remove the rule
// before recreating it in the right Vector path
for (const i in self.state.externalContentRules) {
const rule = self.state.externalContentRules[i];
if (newKeywords.indexOf(rule.pattern) >= 0) {
removeDeferreds.push(cli.deletePushRule('global', rule.kind, rule.rule_id));
}
}
const onError = function(error) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
console.error("Failed to update keywords: " + error);
Modal.createTrackedDialog('Failed to update keywords', '', ErrorDialog, {
title: _t('Failed to update keywords'),
description: ((error && error.message) ? error.message : _t('Operation failed')),
onFinished: self._refreshFromServer,
});
};
// Then, add the new ones
Promise.all(removeDeferreds).then(function(resps) {
const deferreds = [];
let pushRuleVectorStateKind = self.state.vectorContentRules.vectorState;
if (pushRuleVectorStateKind === PushRuleVectorState.OFF) {
// When the current global keywords rule is OFF, we need to look at
// the flavor of rules in 'vectorContentRules' to apply the same actions
// when creating the new rule.
// Thus, this new rule will join the 'vectorContentRules' set.
if (self.state.vectorContentRules.rules.length) {
pushRuleVectorStateKind = PushRuleVectorState.contentRuleVectorStateKind(
self.state.vectorContentRules.rules[0],
);
} else {
// ON is default
pushRuleVectorStateKind = PushRuleVectorState.ON;
}
}
for (const i in newKeywords) {
const keyword = newKeywords[i];
if (vectorContentRulesPatterns.indexOf(keyword) < 0) {
if (self.state.vectorContentRules.vectorState !== PushRuleVectorState.OFF) {
deferreds.push(cli.addPushRule('global', 'content', keyword, {
actions: PushRuleVectorState.actionsFor(pushRuleVectorStateKind),
pattern: keyword,
}));
} else {
deferreds.push(self._addDisabledPushRule('global', 'content', keyword, {
actions: PushRuleVectorState.actionsFor(pushRuleVectorStateKind),
pattern: keyword,
}));
}
}
}
Promise.all(deferreds).then(function(resps) {
self._refreshFromServer();
}, onError);
}, onError);
}
// Create a push rule but disabled
_addDisabledPushRule(scope, kind, ruleId, body) {
const cli = MatrixClientPeg.get();
return cli.addPushRule(scope, kind, ruleId, body).then(() =>
cli.setPushRuleEnabled(scope, kind, ruleId, false),
);
}
// Check if any legacy im.vector rules need to be ported to the new API
// for overriding the actions of default rules.
_portRulesToNewAPI(rulesets) {
const needsUpdate = [];
const cli = MatrixClientPeg.get();
for (const kind in rulesets.global) {
const ruleset = rulesets.global[kind];
for (let i = 0; i < ruleset.length; ++i) {
const rule = ruleset[i];
if (rule.rule_id in LEGACY_RULES) {
console.log("Porting legacy rule", rule);
needsUpdate.push( function(kind, rule) {
return cli.setPushRuleActions(
'global', kind, LEGACY_RULES[rule.rule_id], portLegacyActions(rule.actions),
).then(() =>
cli.deletePushRule('global', kind, rule.rule_id),
).catch( (e) => {
console.warn(`Error when porting legacy rule: ${e}`);
});
}(kind, rule));
}
}
}
if (needsUpdate.length > 0) {
// If some of the rules need to be ported then wait for the porting
// to happen and then fetch the rules again.
return Promise.all(needsUpdate).then(() =>
cli.getPushRules(),
);
} else {
// Otherwise return the rules that we already have.
return rulesets;
}
}
_refreshFromServer = () => {
const self = this;
const pushRulesPromise = MatrixClientPeg.get().getPushRules().then(
self._portRulesToNewAPI,
).then(function(rulesets) {
/// XXX seriously? wtf is this?
MatrixClientPeg.get().pushRules = rulesets;
// Get homeserver default rules and triage them by categories
const ruleCategories = {
// The master rule (all notifications disabling)
'.m.rule.master': 'master',
// The default push rules displayed by Vector UI
'.m.rule.contains_display_name': 'vector',
'.m.rule.contains_user_name': 'vector',
'.m.rule.roomnotif': 'vector',
'.m.rule.room_one_to_one': 'vector',
'.m.rule.encrypted_room_one_to_one': 'vector',
'.m.rule.message': 'vector',
'.m.rule.encrypted': 'vector',
'.m.rule.invite_for_me': 'vector',
//'.m.rule.member_event': 'vector',
'.m.rule.call': 'vector',
'.m.rule.suppress_notices': 'vector',
'.m.rule.tombstone': 'vector',
// Others go to others
};
// HS default rules
const defaultRules = { master: [], vector: {}, others: [] };
for (const kind in rulesets.global) {
for (let i = 0; i < Object.keys(rulesets.global[kind]).length; ++i) {
const r = rulesets.global[kind][i];
const cat = ruleCategories[r.rule_id];
r.kind = kind;
if (r.rule_id[0] === '.') {
if (cat === 'vector') {
defaultRules.vector[r.rule_id] = r;
} else if (cat === 'master') {
defaultRules.master.push(r);
} else {
defaultRules['others'].push(r);
}
}
}
}
// Get the master rule if any defined by the hs
if (defaultRules.master.length > 0) {
self.state.masterPushRule = defaultRules.master[0];
}
// parse the keyword rules into our state
const contentRules = ContentRules.parseContentRules(rulesets);
self.state.vectorContentRules = {
vectorState: contentRules.vectorState,
rules: contentRules.rules,
};
self.state.externalContentRules = contentRules.externalRules;
// Build the rules displayed in the Vector UI matrix table
self.state.vectorPushRules = [];
self.state.externalPushRules = [];
const vectorRuleIds = [
'.m.rule.contains_display_name',
'.m.rule.contains_user_name',
'.m.rule.roomnotif',
'_keywords',
'.m.rule.room_one_to_one',
'.m.rule.encrypted_room_one_to_one',
'.m.rule.message',
'.m.rule.encrypted',
'.m.rule.invite_for_me',
//'im.vector.rule.member_event',
'.m.rule.call',
'.m.rule.suppress_notices',
'.m.rule.tombstone',
];
for (const i in vectorRuleIds) {
const vectorRuleId = vectorRuleIds[i];
if (vectorRuleId === '_keywords') {
// keywords needs a special handling
// For Vector UI, this is a single global push rule but translated in Matrix,
// it corresponds to all content push rules (stored in self.state.vectorContentRule)
self.state.vectorPushRules.push({
"vectorRuleId": "_keywords",
"description": (
<span>
{ _t('Messages containing <span>keywords</span>',
{},
{ 'span': (sub) =>
<span className="mx_UserNotifSettings_keywords" onClick={ self.onKeywordsClicked }>{sub}</span>,
},
)}
</span>
),
"vectorState": self.state.vectorContentRules.vectorState,
});
} else {
const ruleDefinition = VectorPushRulesDefinitions[vectorRuleId];
const rule = defaultRules.vector[vectorRuleId];
const vectorState = ruleDefinition.ruleToVectorState(rule);
//console.log("Refreshing vectorPushRules for " + vectorRuleId +", "+ ruleDefinition.description +", " + rule +", " + vectorState);
self.state.vectorPushRules.push({
"vectorRuleId": vectorRuleId,
"description": _t(ruleDefinition.description), // Text from VectorPushRulesDefinitions.js
"rule": rule,
"vectorState": vectorState,
});
// if there was a rule which we couldn't parse, add it to the external list
if (rule && !vectorState) {
rule.description = ruleDefinition.description;
self.state.externalPushRules.push(rule);
}
}
}
// Build the rules not managed by Vector UI
const otherRulesDescriptions = {
'.m.rule.message': _t('Notify for all other messages/rooms'),
'.m.rule.fallback': _t('Notify me for anything else'),
};
for (const i in defaultRules.others) {
const rule = defaultRules.others[i];
const ruleDescription = otherRulesDescriptions[rule.rule_id];
// Show enabled default rules that was modified by the user
if (ruleDescription && rule.enabled && !rule.default) {
rule.description = ruleDescription;
self.state.externalPushRules.push(rule);
}
}
});
const pushersPromise = MatrixClientPeg.get().getPushers().then(function(resp) {
self.setState({ pushers: resp.pushers });
});
Promise.all([pushRulesPromise, pushersPromise]).then(function() {
self.setState({
phase: Notifications.phases.DISPLAY,
});
}, function(error) {
console.error(error);
self.setState({
phase: Notifications.phases.ERROR,
});
}).finally(() => {
// actually explicitly update our state having been deep-manipulating it
self.setState({
masterPushRule: self.state.masterPushRule,
vectorContentRules: self.state.vectorContentRules,
vectorPushRules: self.state.vectorPushRules,
externalContentRules: self.state.externalContentRules,
externalPushRules: self.state.externalPushRules,
});
});
MatrixClientPeg.get().getThreePids().then((r) => this.setState({ threepids: r.threepids }));
};
_onClearNotifications = () => {
const cli = MatrixClientPeg.get();
cli.getRooms().forEach(r => {
if (r.getUnreadNotificationCount() > 0) {
const events = r.getLiveTimeline().getEvents();
if (events.length) cli.sendReadReceipt(events.pop());
}
});
};
_updatePushRuleActions(rule, actions, enabled) {
const cli = MatrixClientPeg.get();
return cli.setPushRuleActions(
'global', rule.kind, rule.rule_id, actions,
).then( function() {
// Then, if requested, enabled or disabled the rule
if (undefined != enabled) {
return cli.setPushRuleEnabled(
'global', rule.kind, rule.rule_id, enabled,
);
}
});
}
renderNotifRulesTableRow(title, className, pushRuleVectorState) {
return (
<tr key={ className }>
<th>
{ title }
</th>
<th>
<input className= {className + "-" + PushRuleVectorState.OFF}
type="radio"
checked={ pushRuleVectorState === PushRuleVectorState.OFF }
onChange={ this.onNotifStateButtonClicked } />
</th>
<th>
<input className= {className + "-" + PushRuleVectorState.ON}
type="radio"
checked={ pushRuleVectorState === PushRuleVectorState.ON }
onChange={ this.onNotifStateButtonClicked } />
</th>
<th>
<input className= {className + "-" + PushRuleVectorState.LOUD}
type="radio"
checked={ pushRuleVectorState === PushRuleVectorState.LOUD }
onChange={ this.onNotifStateButtonClicked } />
</th>
</tr>
);
}
renderNotifRulesTableRows() {
const rows = [];
for (const i in this.state.vectorPushRules) {
const rule = this.state.vectorPushRules[i];
if (rule.rule === undefined && rule.vectorRuleId.startsWith(".m.")) {
console.warn(`Skipping render of rule ${rule.vectorRuleId} due to no underlying rule`);
continue;
}
//console.log("rendering: " + rule.description + ", " + rule.vectorRuleId + ", " + rule.vectorState);
rows.push(this.renderNotifRulesTableRow(rule.description, rule.vectorRuleId, rule.vectorState));
}
return rows;
}
hasEmailPusher(pushers, address) {
if (pushers === undefined) {
return false;
}
for (let i = 0; i < pushers.length; ++i) {
if (pushers[i].kind === 'email' && pushers[i].pushkey === address) {
return true;
}
}
return false;
}
emailNotificationsRow(address, label) {
return <LabelledToggleSwitch value={this.hasEmailPusher(this.state.pushers, address)}
onChange={this.onEnableEmailNotificationsChange.bind(this, address)}
label={label} key={`emailNotif_${label}`} />;
}
render() {
let spinner;
if (this.state.phase === Notifications.phases.LOADING) {
const Loader = sdk.getComponent("elements.Spinner");
spinner = <Loader />;
}
let masterPushRuleDiv;
if (this.state.masterPushRule) {
masterPushRuleDiv = <LabelledToggleSwitch value={!this.state.masterPushRule.enabled}
onChange={this.onEnableNotificationsChange}
label={_t('Enable notifications for this account')} />;
}
let clearNotificationsButton;
if (MatrixClientPeg.get().getRooms().some(r => r.getUnreadNotificationCount() > 0)) {
clearNotificationsButton = <AccessibleButton onClick={this._onClearNotifications} kind='danger'>
{_t("Clear notifications")}
</AccessibleButton>;
}
// When enabled, the master rule inhibits all existing rules
// So do not show all notification settings
if (this.state.masterPushRule && this.state.masterPushRule.enabled) {
return (
<div>
{masterPushRuleDiv}
<div className="mx_UserNotifSettings_notifTable">
{ _t('All notifications are currently disabled for all targets.') }
</div>
{clearNotificationsButton}
</div>
);
}
const emailThreepids = this.state.threepids.filter((tp) => tp.medium === "email");
let emailNotificationsRows;
if (emailThreepids.length > 0) {
emailNotificationsRows = emailThreepids.map((threePid) => this.emailNotificationsRow(
threePid.address, `${_t('Enable email notifications')} (${threePid.address})`,
));
} else if (SettingsStore.getValue(UIFeature.ThirdPartyID)) {
emailNotificationsRows = <div>
{ _t('Add an email address to configure email notifications') }
</div>;
}
// Build external push rules
const externalRules = [];
for (const i in this.state.externalPushRules) {
const rule = this.state.externalPushRules[i];
externalRules.push(<li>{ _t(rule.description) }</li>);
}
// Show keywords not displayed by the vector UI as a single external push rule
let externalKeywords = [];
for (const i in this.state.externalContentRules) {
const rule = this.state.externalContentRules[i];
externalKeywords.push(rule.pattern);
}
if (externalKeywords.length) {
externalKeywords = externalKeywords.join(", ");
externalRules.push(<li>
{_t('Notifications on the following keywords follow rules which cant be displayed here:') }
{ externalKeywords }
</li>);
}
let devicesSection;
if (this.state.pushers === undefined) {
devicesSection = <div className="error">{ _t('Unable to fetch notification target list') }</div>;
} else if (this.state.pushers.length === 0) {
devicesSection = null;
} else {
// TODO: It would be great to be able to delete pushers from here too,
// and this wouldn't be hard to add.
const rows = [];
for (let i = 0; i < this.state.pushers.length; ++i) {
rows.push(<tr key={ i }>
<td>{this.state.pushers[i].app_display_name}</td>
<td>{this.state.pushers[i].device_display_name}</td>
</tr>);
}
devicesSection = (<table className="mx_UserNotifSettings_devicesTable">
<tbody>
{rows}
</tbody>
</table>);
}
if (devicesSection) {
devicesSection = (<div>
<h3>{ _t('Notification targets') }</h3>
{ devicesSection }
</div>);
}
let advancedSettings;
if (externalRules.length) {
const brand = SdkConfig.get().brand;
advancedSettings = (
<div>
<h3>{ _t('Advanced notification settings') }</h3>
{ _t('There are advanced notifications which are not shown here.') }<br />
{_t(
'You might have configured them in a client other than %(brand)s. ' +
'You cannot tune them in %(brand)s but they still apply.',
{ brand },
)}
<ul>
{ externalRules }
</ul>
</div>
);
}
return (
<div>
{masterPushRuleDiv}
<div className="mx_UserNotifSettings_notifTable">
{ spinner }
<LabelledToggleSwitch value={SettingsStore.getValue("notificationsEnabled")}
onChange={this.onEnableDesktopNotificationsChange}
label={_t('Enable desktop notifications for this session')} />
<LabelledToggleSwitch value={SettingsStore.getValue("notificationBodyEnabled")}
onChange={this.onEnableDesktopNotificationBodyChange}
label={_t('Show message in desktop notification')} />
<LabelledToggleSwitch value={SettingsStore.getValue("audioNotificationsEnabled")}
onChange={this.onEnableAudioNotificationsChange}
label={_t('Enable audible notifications for this session')} />
{ emailNotificationsRows }
<div className="mx_UserNotifSettings_pushRulesTableWrapper">
<table className="mx_UserNotifSettings_pushRulesTable">
<thead>
<tr>
<th width="55%"></th>
<th width="15%">{ _t('Off') }</th>
<th width="15%">{ _t('On') }</th>
<th width="15%">{ _t('Noisy') }</th>
</tr>
</thead>
<tbody>
{ this.renderNotifRulesTableRows() }
</tbody>
</table>
</div>
{ advancedSettings }
{ devicesSection }
{ clearNotificationsButton }
</div>
</div>
);
}
}

View file

@ -0,0 +1,647 @@
/*
Copyright 2016 - 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import Spinner from "../elements/Spinner";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { IAnnotatedPushRule, IPusher, PushRuleAction, PushRuleKind, RuleId } from "matrix-js-sdk/src/@types/PushRules";
import {
ContentRules,
IContentRules,
PushRuleVectorState,
VectorPushRulesDefinitions,
VectorState,
} from "../../../notifications";
import { _t, TranslatedString } from "../../../languageHandler";
import { IThreepid, ThreepidMedium } from "matrix-js-sdk/src/@types/threepids";
import LabelledToggleSwitch from "../elements/LabelledToggleSwitch";
import SettingsStore from "../../../settings/SettingsStore";
import StyledRadioButton from "../elements/StyledRadioButton";
import { SettingLevel } from "../../../settings/SettingLevel";
import Modal from "../../../Modal";
import ErrorDialog from "../dialogs/ErrorDialog";
import SdkConfig from "../../../SdkConfig";
import AccessibleButton from "../elements/AccessibleButton";
import TagComposer from "../elements/TagComposer";
import { objectClone } from "../../../utils/objects";
import { arrayDiff } from "../../../utils/arrays";
// TODO: this "view" component still has far too much application logic in it,
// which should be factored out to other files.
enum Phase {
Loading = "loading",
Ready = "ready",
Persisting = "persisting", // technically a meta-state for Ready, but whatever
Error = "error",
}
enum RuleClass {
Master = "master",
// The vector sections map approximately to UI sections
VectorGlobal = "vector_global",
VectorMentions = "vector_mentions",
VectorOther = "vector_other",
Other = "other", // unknown rules, essentially
}
const KEYWORD_RULE_ID = "_keywords"; // used as a placeholder "Rule ID" throughout this component
const KEYWORD_RULE_CATEGORY = RuleClass.VectorMentions;
// This array doesn't care about categories: it's just used for a simple sort
const RULE_DISPLAY_ORDER: string[] = [
// Global
RuleId.DM,
RuleId.EncryptedDM,
RuleId.Message,
RuleId.EncryptedMessage,
// Mentions
RuleId.ContainsDisplayName,
RuleId.ContainsUserName,
RuleId.AtRoomNotification,
// Other
RuleId.InviteToSelf,
RuleId.IncomingCall,
RuleId.SuppressNotices,
RuleId.Tombstone,
];
interface IVectorPushRule {
ruleId: RuleId | typeof KEYWORD_RULE_ID | string;
rule?: IAnnotatedPushRule;
description: TranslatedString | string;
vectorState: VectorState;
}
interface IProps {}
interface IState {
phase: Phase;
// Optional stuff is required when `phase === Ready`
masterPushRule?: IAnnotatedPushRule;
vectorKeywordRuleInfo?: IContentRules;
vectorPushRules?: {
[category in RuleClass]?: IVectorPushRule[];
};
pushers?: IPusher[];
threepids?: IThreepid[];
}
export default class Notifications extends React.PureComponent<IProps, IState> {
public constructor(props: IProps) {
super(props);
this.state = {
phase: Phase.Loading,
};
}
private get isInhibited(): boolean {
// Caution: The master rule's enabled state is inverted from expectation. When
// the master rule is *enabled* it means all other rules are *disabled* (or
// inhibited). Conversely, when the master rule is *disabled* then all other rules
// are *enabled* (or operate fine).
return this.state.masterPushRule?.enabled;
}
public componentDidMount() {
// noinspection JSIgnoredPromiseFromCall
this.refreshFromServer();
}
private async refreshFromServer() {
try {
const newState = (await Promise.all([
this.refreshRules(),
this.refreshPushers(),
this.refreshThreepids(),
])).reduce((p, c) => Object.assign(c, p), {});
this.setState({
...newState,
phase: Phase.Ready,
});
} catch (e) {
console.error("Error setting up notifications for settings: ", e);
this.setState({ phase: Phase.Error });
}
}
private async refreshRules(): Promise<Partial<IState>> {
const ruleSets = await MatrixClientPeg.get().getPushRules();
const categories = {
[RuleId.Master]: RuleClass.Master,
[RuleId.DM]: RuleClass.VectorGlobal,
[RuleId.EncryptedDM]: RuleClass.VectorGlobal,
[RuleId.Message]: RuleClass.VectorGlobal,
[RuleId.EncryptedMessage]: RuleClass.VectorGlobal,
[RuleId.ContainsDisplayName]: RuleClass.VectorMentions,
[RuleId.ContainsUserName]: RuleClass.VectorMentions,
[RuleId.AtRoomNotification]: RuleClass.VectorMentions,
[RuleId.InviteToSelf]: RuleClass.VectorOther,
[RuleId.IncomingCall]: RuleClass.VectorOther,
[RuleId.SuppressNotices]: RuleClass.VectorOther,
[RuleId.Tombstone]: RuleClass.VectorOther,
// Everything maps to a generic "other" (unknown rule)
};
const defaultRules: {
[k in RuleClass]: IAnnotatedPushRule[];
} = {
[RuleClass.Master]: [],
[RuleClass.VectorGlobal]: [],
[RuleClass.VectorMentions]: [],
[RuleClass.VectorOther]: [],
[RuleClass.Other]: [],
};
for (const k in ruleSets.global) {
// noinspection JSUnfilteredForInLoop
const kind = k as PushRuleKind;
for (const r of ruleSets.global[kind]) {
const rule: IAnnotatedPushRule = Object.assign(r, { kind });
const category = categories[rule.rule_id] ?? RuleClass.Other;
if (rule.rule_id[0] === '.') {
defaultRules[category].push(rule);
}
}
}
const preparedNewState: Partial<IState> = {};
if (defaultRules.master.length > 0) {
preparedNewState.masterPushRule = defaultRules.master[0];
} else {
// XXX: Can this even happen? How do we safely recover?
throw new Error("Failed to locate a master push rule");
}
// Parse keyword rules
preparedNewState.vectorKeywordRuleInfo = ContentRules.parseContentRules(ruleSets);
// Prepare rendering for all of our known rules
preparedNewState.vectorPushRules = {};
const vectorCategories = [RuleClass.VectorGlobal, RuleClass.VectorMentions, RuleClass.VectorOther];
for (const category of vectorCategories) {
preparedNewState.vectorPushRules[category] = [];
for (const rule of defaultRules[category]) {
const definition = VectorPushRulesDefinitions[rule.rule_id];
const vectorState = definition.ruleToVectorState(rule);
preparedNewState.vectorPushRules[category].push({
ruleId: rule.rule_id,
rule, vectorState,
description: _t(definition.description),
});
}
// Quickly sort the rules for display purposes
preparedNewState.vectorPushRules[category].sort((a, b) => {
let idxA = RULE_DISPLAY_ORDER.indexOf(a.ruleId);
let idxB = RULE_DISPLAY_ORDER.indexOf(b.ruleId);
// Assume unknown things go at the end
if (idxA < 0) idxA = RULE_DISPLAY_ORDER.length;
if (idxB < 0) idxB = RULE_DISPLAY_ORDER.length;
return idxA - idxB;
});
if (category === KEYWORD_RULE_CATEGORY) {
preparedNewState.vectorPushRules[category].push({
ruleId: KEYWORD_RULE_ID,
description: _t("Messages containing keywords"),
vectorState: preparedNewState.vectorKeywordRuleInfo.vectorState,
});
}
}
return preparedNewState;
}
private refreshPushers(): Promise<Partial<IState>> {
return MatrixClientPeg.get().getPushers();
}
private refreshThreepids(): Promise<Partial<IState>> {
return MatrixClientPeg.get().getThreePids();
}
private showSaveError() {
Modal.createTrackedDialog('Error saving notification preferences', '', ErrorDialog, {
title: _t('Error saving notification preferences'),
description: _t('An error occurred whilst saving your notification preferences.'),
});
}
private onMasterRuleChanged = async (checked: boolean) => {
this.setState({ phase: Phase.Persisting });
try {
const masterRule = this.state.masterPushRule;
await MatrixClientPeg.get().setPushRuleEnabled('global', masterRule.kind, masterRule.rule_id, !checked);
await this.refreshFromServer();
} catch (e) {
this.setState({ phase: Phase.Error });
console.error("Error updating master push rule:", e);
this.showSaveError();
}
};
private onEmailNotificationsChanged = async (email: string, checked: boolean) => {
this.setState({ phase: Phase.Persisting });
try {
if (checked) {
await MatrixClientPeg.get().setPusher({
kind: "email",
app_id: "m.email",
pushkey: email,
app_display_name: "Email Notifications",
device_display_name: email,
lang: navigator.language,
data: {
brand: SdkConfig.get().brand,
},
// We always append for email pushers since we don't want to stop other
// accounts notifying to the same email address
append: true,
});
} else {
const pusher = this.state.pushers.find(p => p.kind === "email" && p.pushkey === email);
pusher.kind = null; // flag for delete
await MatrixClientPeg.get().setPusher(pusher);
}
await this.refreshFromServer();
} catch (e) {
this.setState({ phase: Phase.Error });
console.error("Error updating email pusher:", e);
this.showSaveError();
}
};
private onDesktopNotificationsChanged = async (checked: boolean) => {
await SettingsStore.setValue("notificationsEnabled", null, SettingLevel.DEVICE, checked);
this.forceUpdate(); // the toggle is controlled by SettingsStore#getValue()
};
private onDesktopShowBodyChanged = async (checked: boolean) => {
await SettingsStore.setValue("notificationBodyEnabled", null, SettingLevel.DEVICE, checked);
this.forceUpdate(); // the toggle is controlled by SettingsStore#getValue()
};
private onAudioNotificationsChanged = async (checked: boolean) => {
await SettingsStore.setValue("audioNotificationsEnabled", null, SettingLevel.DEVICE, checked);
this.forceUpdate(); // the toggle is controlled by SettingsStore#getValue()
};
private onRadioChecked = async (rule: IVectorPushRule, checkedState: VectorState) => {
this.setState({ phase: Phase.Persisting });
try {
const cli = MatrixClientPeg.get();
if (rule.ruleId === KEYWORD_RULE_ID) {
// Update all the keywords
for (const rule of this.state.vectorKeywordRuleInfo.rules) {
let enabled: boolean;
let actions: PushRuleAction[];
if (checkedState === VectorState.On) {
if (rule.actions.length !== 1) { // XXX: Magic number
actions = PushRuleVectorState.actionsFor(checkedState);
}
if (this.state.vectorKeywordRuleInfo.vectorState === VectorState.Off) {
enabled = true;
}
} else if (checkedState === VectorState.Loud) {
if (rule.actions.length !== 3) { // XXX: Magic number
actions = PushRuleVectorState.actionsFor(checkedState);
}
if (this.state.vectorKeywordRuleInfo.vectorState === VectorState.Off) {
enabled = true;
}
} else {
enabled = false;
}
if (actions) {
await cli.setPushRuleActions('global', rule.kind, rule.rule_id, actions);
}
if (enabled !== undefined) {
await cli.setPushRuleEnabled('global', rule.kind, rule.rule_id, enabled);
}
}
} else {
const definition = VectorPushRulesDefinitions[rule.ruleId];
const actions = definition.vectorStateToActions[checkedState];
if (!actions) {
await cli.setPushRuleEnabled('global', rule.rule.kind, rule.rule.rule_id, false);
} else {
await cli.setPushRuleActions('global', rule.rule.kind, rule.rule.rule_id, actions);
await cli.setPushRuleEnabled('global', rule.rule.kind, rule.rule.rule_id, true);
}
}
await this.refreshFromServer();
} catch (e) {
this.setState({ phase: Phase.Error });
console.error("Error updating push rule:", e);
this.showSaveError();
}
};
private onClearNotificationsClicked = () => {
MatrixClientPeg.get().getRooms().forEach(r => {
if (r.getUnreadNotificationCount() > 0) {
const events = r.getLiveTimeline().getEvents();
if (events.length) {
// noinspection JSIgnoredPromiseFromCall
MatrixClientPeg.get().sendReadReceipt(events[events.length - 1]);
}
}
});
};
private async setKeywords(keywords: string[], originalRules: IAnnotatedPushRule[]) {
try {
// De-duplicate and remove empties
keywords = Array.from(new Set(keywords)).filter(k => !!k);
const oldKeywords = Array.from(new Set(originalRules.map(r => r.pattern))).filter(k => !!k);
// Note: Technically because of the UI interaction (at the time of writing), the diff
// will only ever be +/-1 so we don't really have to worry about efficiently handling
// tons of keyword changes.
const diff = arrayDiff(oldKeywords, keywords);
for (const word of diff.removed) {
for (const rule of originalRules.filter(r => r.pattern === word)) {
await MatrixClientPeg.get().deletePushRule('global', rule.kind, rule.rule_id);
}
}
let ruleVectorState = this.state.vectorKeywordRuleInfo.vectorState;
if (ruleVectorState === VectorState.Off) {
// When the current global keywords rule is OFF, we need to look at
// the flavor of existing rules to apply the same actions
// when creating the new rule.
if (originalRules.length) {
ruleVectorState = PushRuleVectorState.contentRuleVectorStateKind(originalRules[0]);
} else {
ruleVectorState = VectorState.On; // default
}
}
const kind = PushRuleKind.ContentSpecific;
for (const word of diff.added) {
await MatrixClientPeg.get().addPushRule('global', kind, word, {
actions: PushRuleVectorState.actionsFor(ruleVectorState),
pattern: word,
});
if (ruleVectorState === VectorState.Off) {
await MatrixClientPeg.get().setPushRuleEnabled('global', kind, word, false);
}
}
await this.refreshFromServer();
} catch (e) {
this.setState({ phase: Phase.Error });
console.error("Error updating keyword push rules:", e);
this.showSaveError();
}
}
private onKeywordAdd = (keyword: string) => {
const originalRules = objectClone(this.state.vectorKeywordRuleInfo.rules);
// We add the keyword immediately as a sort of local echo effect
this.setState({
phase: Phase.Persisting,
vectorKeywordRuleInfo: {
...this.state.vectorKeywordRuleInfo,
rules: [
...this.state.vectorKeywordRuleInfo.rules,
// XXX: Horrible assumption that we don't need the remaining fields
{ pattern: keyword } as IAnnotatedPushRule,
],
},
}, async () => {
await this.setKeywords(this.state.vectorKeywordRuleInfo.rules.map(r => r.pattern), originalRules);
});
};
private onKeywordRemove = (keyword: string) => {
const originalRules = objectClone(this.state.vectorKeywordRuleInfo.rules);
// We remove the keyword immediately as a sort of local echo effect
this.setState({
phase: Phase.Persisting,
vectorKeywordRuleInfo: {
...this.state.vectorKeywordRuleInfo,
rules: this.state.vectorKeywordRuleInfo.rules.filter(r => r.pattern !== keyword),
},
}, async () => {
await this.setKeywords(this.state.vectorKeywordRuleInfo.rules.map(r => r.pattern), originalRules);
});
};
private renderTopSection() {
const masterSwitch = <LabelledToggleSwitch
value={!this.isInhibited}
label={_t("Enable for this account")}
onChange={this.onMasterRuleChanged}
disabled={this.state.phase === Phase.Persisting}
/>;
// If all the rules are inhibited, don't show anything.
if (this.isInhibited) {
return masterSwitch;
}
const emailSwitches = this.state.threepids.filter(t => t.medium === ThreepidMedium.Email)
.map(e => <LabelledToggleSwitch
key={e.address}
value={this.state.pushers.some(p => p.kind === "email" && p.pushkey === e.address)}
label={_t("Enable email notifications for %(email)s", { email: e.address })}
onChange={this.onEmailNotificationsChanged.bind(this, e.address)}
disabled={this.state.phase === Phase.Persisting}
/>);
return <>
{ masterSwitch }
<LabelledToggleSwitch
value={SettingsStore.getValue("notificationsEnabled")}
onChange={this.onDesktopNotificationsChanged}
label={_t('Enable desktop notifications for this session')}
disabled={this.state.phase === Phase.Persisting}
/>
<LabelledToggleSwitch
value={SettingsStore.getValue("notificationBodyEnabled")}
onChange={this.onDesktopShowBodyChanged}
label={_t('Show message in desktop notification')}
disabled={this.state.phase === Phase.Persisting}
/>
<LabelledToggleSwitch
value={SettingsStore.getValue("audioNotificationsEnabled")}
onChange={this.onAudioNotificationsChanged}
label={_t('Enable audible notifications for this session')}
disabled={this.state.phase === Phase.Persisting}
/>
{ emailSwitches }
</>;
}
private renderCategory(category: RuleClass) {
if (category !== RuleClass.VectorOther && this.isInhibited) {
return null; // nothing to show for the section
}
let clearNotifsButton: JSX.Element;
if (
category === RuleClass.VectorOther
&& MatrixClientPeg.get().getRooms().some(r => r.getUnreadNotificationCount() > 0)
) {
clearNotifsButton = <AccessibleButton
onClick={this.onClearNotificationsClicked}
kind='danger'
className='mx_UserNotifSettings_clearNotifsButton'
>{ _t("Clear notifications") }</AccessibleButton>;
}
if (category === RuleClass.VectorOther && this.isInhibited) {
// only render the utility buttons (if needed)
if (clearNotifsButton) {
return <div className='mx_UserNotifSettings_floatingSection'>
<div>{ _t("Other") }</div>
{ clearNotifsButton }
</div>;
}
return null;
}
let keywordComposer: JSX.Element;
if (category === RuleClass.VectorMentions) {
keywordComposer = <TagComposer
tags={this.state.vectorKeywordRuleInfo?.rules.map(r => r.pattern)}
onAdd={this.onKeywordAdd}
onRemove={this.onKeywordRemove}
disabled={this.state.phase === Phase.Persisting}
label={_t("Keyword")}
placeholder={_t("New keyword")}
/>;
}
const makeRadio = (r: IVectorPushRule, s: VectorState) => (
<StyledRadioButton
key={r.ruleId}
name={r.ruleId}
checked={r.vectorState === s}
onChange={this.onRadioChecked.bind(this, r, s)}
disabled={this.state.phase === Phase.Persisting}
/>
);
const rows = this.state.vectorPushRules[category].map(r => <tr key={category + r.ruleId}>
<td>{ r.description }</td>
<td>{ makeRadio(r, VectorState.Off) }</td>
<td>{ makeRadio(r, VectorState.On) }</td>
<td>{ makeRadio(r, VectorState.Loud) }</td>
</tr>);
let sectionName: TranslatedString;
switch (category) {
case RuleClass.VectorGlobal:
sectionName = _t("Global");
break;
case RuleClass.VectorMentions:
sectionName = _t("Mentions & keywords");
break;
case RuleClass.VectorOther:
sectionName = _t("Other");
break;
default:
throw new Error("Developer error: Unnamed notifications section: " + category);
}
return <>
<table className='mx_UserNotifSettings_pushRulesTable'>
<thead>
<tr>
<th>{ sectionName }</th>
<th>{ _t("Off") }</th>
<th>{ _t("On") }</th>
<th>{ _t("Noisy") }</th>
</tr>
</thead>
<tbody>
{ rows }
</tbody>
</table>
{ clearNotifsButton }
{ keywordComposer }
</>;
}
private renderTargets() {
if (this.isInhibited) return null; // no targets if there's no notifications
const rows = this.state.pushers.map(p => <tr key={p.kind+p.pushkey}>
<td>{ p.app_display_name }</td>
<td>{ p.device_display_name }</td>
</tr>);
if (!rows.length) return null; // no targets to show
return <div className='mx_UserNotifSettings_floatingSection'>
<div>{ _t("Notification targets") }</div>
<table>
<tbody>
{ rows }
</tbody>
</table>
</div>;
}
public render() {
if (this.state.phase === Phase.Loading) {
// Ends up default centered
return <Spinner />;
} else if (this.state.phase === Phase.Error) {
return <p>{ _t("There was an error loading your notification settings.") }</p>;
}
return <div className='mx_UserNotifSettings'>
{ this.renderTopSection() }
{ this.renderCategory(RuleClass.VectorGlobal) }
{ this.renderCategory(RuleClass.VectorMentions) }
{ this.renderCategory(RuleClass.VectorOther) }
{ this.renderTargets() }
</div>;
}
}

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

@ -1,5 +1,5 @@
/*
Copyright 2019 New Vector Ltd
Copyright 2019-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,12 @@ limitations under the License.
import React from 'react';
import { _t } from "../../../../../languageHandler";
import * as sdk from "../../../../../index";
import { replaceableComponent } from "../../../../../utils/replaceableComponent";
import Notifications from "../../Notifications";
@replaceableComponent("views.settings.tabs.user.NotificationUserSettingsTab")
export default class NotificationUserSettingsTab extends React.Component {
constructor() {
super();
}
render() {
const Notifications = sdk.getComponent("views.settings.Notifications");
return (
<div className="mx_SettingsTab mx_NotificationUserSettingsTab">
<div className="mx_SettingsTab_heading">{ _t("Notifications") }</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

@ -30,6 +30,7 @@ import { replaceableComponent } from "../../../utils/replaceableComponent";
import UIStore from '../../../stores/UIStore';
import { lerp } from '../../../utils/AnimationUtils';
import { MarkedExecution } from '../../../utils/MarkedExecution';
import { EventSubscription } from 'fbemitter';
const PIP_VIEW_WIDTH = 336;
const PIP_VIEW_HEIGHT = 232;
@ -108,7 +109,7 @@ function getPrimarySecondaryCalls(calls: MatrixCall[]): [MatrixCall, MatrixCall[
*/
@replaceableComponent("views.voip.CallPreview")
export default class CallPreview extends React.Component<IProps, IState> {
private roomStoreToken: any;
private roomStoreToken: EventSubscription;
private dispatcherRef: string;
private settingsWatcherRef: string;
private callViewWrapper = createRef<HTMLDivElement>();
@ -240,7 +241,7 @@ export default class CallPreview extends React.Component<IProps, IState> {
this.scheduledUpdate.mark();
};
private onRoomViewStoreUpdate = (payload) => {
private onRoomViewStoreUpdate = () => {
if (RoomViewStore.getRoomId() === this.state.roomId) return;
const roomId = RoomViewStore.getRoomId();

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

@ -41,6 +41,7 @@ const RoomContext = createContext<IState>({
canReply: false,
layout: Layout.Group,
lowBandwidth: false,
showHiddenEventsInTimeline: false,
showReadReceipts: true,
showRedactions: true,
showJoinLeaves: true,

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",
@ -1133,33 +1120,24 @@
"Connecting to integration manager...": "Connecting to integration manager...",
"Cannot connect to integration manager": "Cannot connect to integration manager",
"The integration manager is offline or it cannot reach your homeserver.": "The integration manager is offline or it cannot reach your homeserver.",
"Error saving email notification preferences": "Error saving email notification preferences",
"An error occurred whilst saving your email notification preferences.": "An error occurred whilst saving your email notification preferences.",
"Keywords": "Keywords",
"Enter keywords separated by a comma:": "Enter keywords separated by a comma:",
"Failed to change settings": "Failed to change settings",
"Can't update user notification settings": "Can't update user notification settings",
"Failed to update keywords": "Failed to update keywords",
"Messages containing <span>keywords</span>": "Messages containing <span>keywords</span>",
"Notify for all other messages/rooms": "Notify for all other messages/rooms",
"Notify me for anything else": "Notify me for anything else",
"Enable notifications for this account": "Enable notifications for this account",
"Clear notifications": "Clear notifications",
"All notifications are currently disabled for all targets.": "All notifications are currently disabled for all targets.",
"Enable email notifications": "Enable email notifications",
"Add an email address to configure email notifications": "Add an email address to configure email notifications",
"Notifications on the following keywords follow rules which cant be displayed here:": "Notifications on the following keywords follow rules which cant be displayed here:",
"Unable to fetch notification target list": "Unable to fetch notification target list",
"Notification targets": "Notification targets",
"Advanced notification settings": "Advanced notification settings",
"There are advanced notifications which are not shown here.": "There are advanced notifications which are not shown here.",
"You might have configured them in a client other than %(brand)s. You cannot tune them in %(brand)s but they still apply.": "You might have configured them in a client other than %(brand)s. You cannot tune them in %(brand)s but they still apply.",
"Messages containing keywords": "Messages containing keywords",
"Error saving notification preferences": "Error saving notification preferences",
"An error occurred whilst saving your notification preferences.": "An error occurred whilst saving your notification preferences.",
"Enable for this account": "Enable for this account",
"Enable email notifications for %(email)s": "Enable email notifications for %(email)s",
"Enable desktop notifications for this session": "Enable desktop notifications for this session",
"Show message in desktop notification": "Show message in desktop notification",
"Enable audible notifications for this session": "Enable audible notifications for this session",
"Clear notifications": "Clear notifications",
"Keyword": "Keyword",
"New keyword": "New keyword",
"Global": "Global",
"Mentions & keywords": "Mentions & keywords",
"Off": "Off",
"On": "On",
"Noisy": "Noisy",
"Notification targets": "Notification targets",
"There was an error loading your notification settings.": "There was an error loading your notification settings.",
"Failed to save your profile": "Failed to save your profile",
"The operation could not be completed": "The operation could not be completed",
"<a>Upgrade</a> to your own domain": "<a>Upgrade</a> to your own domain",
@ -1254,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",
@ -1658,7 +1640,6 @@
"Show %(count)s more|other": "Show %(count)s more",
"Show %(count)s more|one": "Show %(count)s more",
"Show less": "Show less",
"Use default": "Use default",
"All messages": "All messages",
"Mentions & Keywords": "Mentions & Keywords",
"Notification options": "Notification options",
@ -1667,7 +1648,7 @@
"Favourite": "Favourite",
"Low Priority": "Low Priority",
"Invite People": "Invite People",
"Copy Link": "Copy Link",
"Copy Room Link": "Copy Room Link",
"Leave Room": "Leave Room",
"Room options": "Room options",
"%(count)s unread messages including mentions.|other": "%(count)s unread messages including mentions.",
@ -1861,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",

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 {

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