Merge branch 'develop' of github.com:matrix-org/matrix-react-sdk into export-conversations

This commit is contained in:
Jaiwanth 2021-07-19 12:57:27 +05:30
commit fe2cac56f9
208 changed files with 5158 additions and 2854 deletions

View file

@ -1,3 +1,15 @@
<!-- Please read https://github.com/matrix-org/matrix-js-sdk/blob/develop/CONTRIBUTING.rst before submitting your pull request --> <!-- Please read https://github.com/matrix-org/matrix-js-sdk/blob/develop/CONTRIBUTING.md before submitting your pull request -->
<!-- Include a Sign-Off as described in https://github.com/matrix-org/matrix-js-sdk/blob/develop/CONTRIBUTING.rst#sign-off --> <!-- Include a Sign-Off as described in https://github.com/matrix-org/matrix-js-sdk/blob/develop/CONTRIBUTING.md#sign-off -->
<!-- To specify text for the changelog entry (otherwise the PR title will be used):
Notes:
Changes in this project generate changelog entries in element-web by default.
To suppress this:
element-web notes: none
...or to specify different notes:
element-web notes: <notes>
-->

View file

@ -1,5 +1,8 @@
name: Develop name: Develop
on: on:
# These tests won't work for non-develop branches at the moment as they
# won't pull in the right versions of other repos, so they're only enabled
# on develop.
push: push:
branches: [develop] branches: [develop]
pull_request: pull_request:

3
.gitignore vendored
View file

@ -15,3 +15,6 @@ package-lock.json
.DS_Store .DS_Store
*.tmp *.tmp
.vscode
.vscode/

6
__mocks__/FontManager.js Normal file
View file

@ -0,0 +1,6 @@
// Stub out FontManager for tests as it doesn't validate anything we don't already know given
// our fixed test environment and it requires the installation of node-canvas.
module.exports = {
fixupColorFonts: () => Promise.resolve(),
};

1
__mocks__/workerMock.js Normal file
View file

@ -0,0 +1 @@
module.exports = jest.fn();

View file

@ -129,6 +129,7 @@
"@types/classnames": "^2.2.11", "@types/classnames": "^2.2.11",
"@types/commonmark": "^0.27.4", "@types/commonmark": "^0.27.4",
"@types/counterpart": "^0.18.1", "@types/counterpart": "^0.18.1",
"@types/css-font-loading-module": "^0.0.6",
"@types/diff-match-patch": "^1.0.32", "@types/diff-match-patch": "^1.0.32",
"@types/flux": "^3.1.9", "@types/flux": "^3.1.9",
"@types/jest": "^26.0.20", "@types/jest": "^26.0.20",
@ -189,7 +190,8 @@
"\\$webapp/i18n/languages.json": "<rootDir>/__mocks__/languages.json", "\\$webapp/i18n/languages.json": "<rootDir>/__mocks__/languages.json",
"decoderWorker\\.min\\.js": "<rootDir>/__mocks__/empty.js", "decoderWorker\\.min\\.js": "<rootDir>/__mocks__/empty.js",
"decoderWorker\\.min\\.wasm": "<rootDir>/__mocks__/empty.js", "decoderWorker\\.min\\.wasm": "<rootDir>/__mocks__/empty.js",
"waveWorker\\.min\\.js": "<rootDir>/__mocks__/empty.js" "waveWorker\\.min\\.js": "<rootDir>/__mocks__/empty.js",
"workers/(.+)\\.worker\\.ts": "<rootDir>/__mocks__/workerMock.js"
}, },
"transformIgnorePatterns": [ "transformIgnorePatterns": [
"/node_modules/(?!matrix-js-sdk).+$" "/node_modules/(?!matrix-js-sdk).+$"

View file

@ -121,6 +121,7 @@
@import "./views/elements/_AddressTile.scss"; @import "./views/elements/_AddressTile.scss";
@import "./views/elements/_DesktopBuildsNotice.scss"; @import "./views/elements/_DesktopBuildsNotice.scss";
@import "./views/elements/_DesktopCapturerSourcePicker.scss"; @import "./views/elements/_DesktopCapturerSourcePicker.scss";
@import "./views/elements/_DialPadBackspaceButton.scss";
@import "./views/elements/_DirectorySearchBox.scss"; @import "./views/elements/_DirectorySearchBox.scss";
@import "./views/elements/_Dropdown.scss"; @import "./views/elements/_Dropdown.scss";
@import "./views/elements/_EditableItemList.scss"; @import "./views/elements/_EditableItemList.scss";
@ -149,6 +150,7 @@
@import "./views/elements/_StyledCheckbox.scss"; @import "./views/elements/_StyledCheckbox.scss";
@import "./views/elements/_StyledRadioButton.scss"; @import "./views/elements/_StyledRadioButton.scss";
@import "./views/elements/_SyntaxHighlight.scss"; @import "./views/elements/_SyntaxHighlight.scss";
@import "./views/elements/_TagComposer.scss";
@import "./views/elements/_TextWithTooltip.scss"; @import "./views/elements/_TextWithTooltip.scss";
@import "./views/elements/_ToggleSwitch.scss"; @import "./views/elements/_ToggleSwitch.scss";
@import "./views/elements/_Tooltip.scss"; @import "./views/elements/_Tooltip.scss";
@ -164,6 +166,7 @@
@import "./views/messages/_MEmoteBody.scss"; @import "./views/messages/_MEmoteBody.scss";
@import "./views/messages/_MFileBody.scss"; @import "./views/messages/_MFileBody.scss";
@import "./views/messages/_MImageBody.scss"; @import "./views/messages/_MImageBody.scss";
@import "./views/messages/_MImageReplyBody.scss";
@import "./views/messages/_MJitsiWidgetEvent.scss"; @import "./views/messages/_MJitsiWidgetEvent.scss";
@import "./views/messages/_MNoticeBody.scss"; @import "./views/messages/_MNoticeBody.scss";
@import "./views/messages/_MStickerBody.scss"; @import "./views/messages/_MStickerBody.scss";
@ -213,6 +216,7 @@
@import "./views/rooms/_PinnedEventTile.scss"; @import "./views/rooms/_PinnedEventTile.scss";
@import "./views/rooms/_PresenceLabel.scss"; @import "./views/rooms/_PresenceLabel.scss";
@import "./views/rooms/_ReplyPreview.scss"; @import "./views/rooms/_ReplyPreview.scss";
@import "./views/rooms/_ReplyTile.scss";
@import "./views/rooms/_RoomBreadcrumbs.scss"; @import "./views/rooms/_RoomBreadcrumbs.scss";
@import "./views/rooms/_RoomHeader.scss"; @import "./views/rooms/_RoomHeader.scss";
@import "./views/rooms/_RoomList.scss"; @import "./views/rooms/_RoomList.scss";
@ -261,9 +265,9 @@
@import "./views/toasts/_NonUrgentEchoFailureToast.scss"; @import "./views/toasts/_NonUrgentEchoFailureToast.scss";
@import "./views/verification/_VerificationShowSas.scss"; @import "./views/verification/_VerificationShowSas.scss";
@import "./views/voip/_CallContainer.scss"; @import "./views/voip/_CallContainer.scss";
@import "./views/voip/_CallPreview.scss";
@import "./views/voip/_CallView.scss"; @import "./views/voip/_CallView.scss";
@import "./views/voip/_CallViewForRoom.scss"; @import "./views/voip/_CallViewForRoom.scss";
@import "./views/voip/_CallPreview.scss";
@import "./views/voip/_DialPad.scss"; @import "./views/voip/_DialPad.scss";
@import "./views/voip/_DialPadContextMenu.scss"; @import "./views/voip/_DialPadContextMenu.scss";
@import "./views/voip/_DialPadModal.scss"; @import "./views/voip/_DialPadModal.scss";

View file

@ -1,6 +1,7 @@
/* /*
Copyright 2017 Travis Ralston Copyright 2017 Travis Ralston
Copyright 2019 New Vector Ltd Copyright 2019 New Vector Ltd
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -20,7 +21,6 @@ limitations under the License.
padding: 0 0 0 16px; padding: 0 0 0 16px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
position: absolute;
top: 0; top: 0;
bottom: 0; bottom: 0;
left: 0; left: 0;
@ -28,11 +28,93 @@ limitations under the License.
margin-top: 8px; margin-top: 8px;
} }
.mx_TabbedView_tabsOnLeft {
flex-direction: column;
position: absolute;
.mx_TabbedView_tabLabels {
width: 170px;
max-width: 170px;
position: fixed;
}
.mx_TabbedView_tabPanel {
margin-left: 240px; // 170px sidebar + 70px padding
flex-direction: column;
}
.mx_TabbedView_tabLabel_active {
background-color: $tab-label-active-bg-color;
color: $tab-label-active-fg-color;
}
.mx_TabbedView_tabLabel_active .mx_TabbedView_maskedIcon::before {
background-color: $tab-label-active-icon-bg-color;
}
.mx_TabbedView_maskedIcon {
width: 16px;
height: 16px;
margin-left: 8px;
margin-right: 16px;
}
.mx_TabbedView_maskedIcon::before {
mask-size: 16px;
width: 16px;
height: 16px;
}
}
.mx_TabbedView_tabsOnTop {
flex-direction: column;
.mx_TabbedView_tabLabels {
display: flex;
margin-bottom: 8px;
}
.mx_TabbedView_tabLabel {
padding-left: 0px;
padding-right: 52px;
.mx_TabbedView_tabLabel_text {
font-size: 15px;
color: $tertiary-fg-color;
}
}
.mx_TabbedView_tabPanel {
flex-direction: row;
}
.mx_TabbedView_tabLabel_active {
color: $accent-color;
.mx_TabbedView_tabLabel_text {
color: $accent-color;
}
}
.mx_TabbedView_tabLabel_active .mx_TabbedView_maskedIcon::before {
background-color: $accent-color;
}
.mx_TabbedView_maskedIcon {
width: 22px;
height: 22px;
margin-left: 0px;
margin-right: 8px;
}
.mx_TabbedView_maskedIcon::before {
mask-size: 22px;
width: inherit;
height: inherit;
}
}
.mx_TabbedView_tabLabels { .mx_TabbedView_tabLabels {
width: 170px;
max-width: 170px;
color: $tab-label-fg-color; color: $tab-label-fg-color;
position: fixed;
} }
.mx_TabbedView_tabLabel { .mx_TabbedView_tabLabel {
@ -46,43 +128,25 @@ limitations under the License.
position: relative; position: relative;
} }
.mx_TabbedView_tabLabel_active {
background-color: $tab-label-active-bg-color;
color: $tab-label-active-fg-color;
}
.mx_TabbedView_maskedIcon { .mx_TabbedView_maskedIcon {
margin-left: 8px;
margin-right: 16px;
width: 16px;
height: 16px;
display: inline-block; display: inline-block;
} }
.mx_TabbedView_maskedIcon::before { .mx_TabbedView_maskedIcon::before {
display: inline-block; display: inline-block;
background-color: $tab-label-icon-bg-color; background-color: $icon-button-color;
mask-repeat: no-repeat; mask-repeat: no-repeat;
mask-size: 16px;
width: 16px;
height: 16px;
mask-position: center; mask-position: center;
content: ''; content: '';
} }
.mx_TabbedView_tabLabel_active .mx_TabbedView_maskedIcon::before {
background-color: $tab-label-active-icon-bg-color;
}
.mx_TabbedView_tabLabel_text { .mx_TabbedView_tabLabel_text {
vertical-align: middle; vertical-align: middle;
} }
.mx_TabbedView_tabPanel { .mx_TabbedView_tabPanel {
margin-left: 240px; // 170px sidebar + 70px padding
flex-grow: 1; flex-grow: 1;
display: flex; display: flex;
flex-direction: column;
min-height: 0; // firefox min-height: 0; // firefox
} }

View file

@ -49,4 +49,8 @@ limitations under the License.
padding-right: 6px; // with the fixed width this ends up as a visual 8px most of the time, as intended. padding-right: 6px; // with the fixed width this ends up as a visual 8px most of the time, as intended.
padding-left: 8px; // isolate from recording circle / play control padding-left: 8px; // isolate from recording circle / play control
} }
&.mx_VoiceMessagePrimaryContainer_noWaveform {
max-width: 162px; // with all the padding this results in 185px wide
}
} }

View file

@ -14,6 +14,10 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
.mx_InviteDialog_transferWrapper .mx_Dialog {
padding-bottom: 16px;
}
.mx_InviteDialog_addressBar { .mx_InviteDialog_addressBar {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
@ -286,16 +290,41 @@ limitations under the License.
} }
} }
.mx_InviteDialog { .mx_InviteDialog_other {
// Prevent the dialog from jumping around randomly when elements change. // Prevent the dialog from jumping around randomly when elements change.
height: 600px; height: 600px;
padding-left: 20px; // the design wants some padding on the left padding-left: 20px; // the design wants some padding on the left
display: flex;
.mx_InviteDialog_userSections {
height: calc(100% - 115px); // mx_InviteDialog's height minus some for the upper and lower elements
}
}
.mx_InviteDialog_content {
height: calc(100% - 36px); // full height minus the size of the header
overflow: hidden;
}
.mx_InviteDialog_transfer {
width: 496px;
height: 466px;
flex-direction: column; flex-direction: column;
.mx_InviteDialog_content { .mx_InviteDialog_content {
overflow: hidden; flex-direction: column;
height: 100%;
.mx_TabbedView {
height: calc(100% - 60px);
}
overflow: visible;
}
.mx_InviteDialog_addressBar {
margin-top: 8px;
}
input[type="checkbox"] {
margin-right: 8px;
} }
} }
@ -303,7 +332,6 @@ limitations under the License.
margin-top: 4px; margin-top: 4px;
overflow-y: auto; overflow-y: auto;
padding: 0 45px 4px 0; padding: 0 45px 4px 0;
height: calc(100% - 115px); // mx_InviteDialog's height minus some for the upper and lower elements
} }
.mx_InviteDialog_hasFooter .mx_InviteDialog_userSections { .mx_InviteDialog_hasFooter .mx_InviteDialog_userSections {
@ -318,6 +346,74 @@ limitations under the License.
padding: 0; padding: 0;
} }
.mx_InviteDialog_dialPad .mx_InviteDialog_dialPadField {
border-top: 0;
border-left: 0;
border-right: 0;
border-radius: 0;
margin-top: 0;
border-color: $quaternary-fg-color;
input {
font-size: 18px;
font-weight: 600;
padding-top: 0;
}
}
.mx_InviteDialog_dialPad .mx_InviteDialog_dialPadField:focus-within {
border-color: $accent-color;
}
.mx_InviteDialog_dialPadField .mx_Field_postfix {
/* Remove border separator between postfix and field content */
border-left: none;
}
.mx_InviteDialog_dialPad {
width: 224px;
margin-top: 16px;
margin-left: auto;
margin-right: auto;
}
.mx_InviteDialog_dialPad .mx_DialPad {
row-gap: 16px;
column-gap: 48px;
margin-left: auto;
margin-right: auto;
}
.mx_InviteDialog_transferConsultConnect {
padding-top: 16px;
/* This wants a drop shadow the full width of the dialog, so relative-position it
* and make it wider, then compensate with padding
*/
position: relative;
width: 496px;
left: -24px;
padding-left: 24px;
padding-right: 24px;
border-top: 1px solid $message-body-panel-bg-color;
display: flex;
flex-direction: row;
align-items: center;
}
.mx_InviteDialog_transferConsultConnect_pushRight {
margin-left: auto;
}
.mx_InviteDialog_userDirectoryIcon::before {
mask-image: url('$(res)/img/voip/tab-userdirectory.svg');
}
.mx_InviteDialog_dialPadIcon::before {
mask-image: url('$(res)/img/voip/tab-dialpad.svg');
}
.mx_InviteDialog_multiInviterError { .mx_InviteDialog_multiInviterError {
> h4 { > h4 {
font-size: $font-15px; font-size: $font-15px;

View file

@ -0,0 +1,40 @@
/*
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_DialPadBackspaceButton {
position: relative;
height: 28px;
width: 28px;
&::before {
/* force this element to appear on the DOM */
content: "";
background-color: #8D97A5;
width: inherit;
height: inherit;
top: 0px;
left: 0px;
position: absolute;
display: inline-block;
vertical-align: middle;
mask-image: url('$(res)/img/element-icons/call/delete.svg');
mask-position: 8px;
mask-size: 20px;
mask-repeat: no-repeat;
}
}

View file

@ -14,6 +14,10 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
$button-size: 32px;
$icon-size: 22px;
$button-gap: 24px;
.mx_ImageView { .mx_ImageView {
display: flex; display: flex;
width: 100%; width: 100%;
@ -66,16 +70,17 @@ limitations under the License.
pointer-events: initial; pointer-events: initial;
display: flex; display: flex;
align-items: center; align-items: center;
gap: calc($button-gap - ($button-size - $icon-size));
} }
.mx_ImageView_button { .mx_ImageView_button {
margin-left: 24px; padding: calc(($button-size - $icon-size) / 2);
display: block; display: block;
&::before { &::before {
content: ''; content: '';
height: 22px; height: $icon-size;
width: 22px; width: $icon-size;
mask-repeat: no-repeat; mask-repeat: no-repeat;
mask-size: contain; mask-size: contain;
mask-position: center; mask-position: center;
@ -109,11 +114,12 @@ limitations under the License.
} }
.mx_ImageView_button_close { .mx_ImageView_button_close {
padding: calc($button-size - $button-size);
border-radius: 100%; border-radius: 100%;
background: #21262c; // same on all themes background: #21262c; // same on all themes
&::before { &::before {
width: 32px; width: $button-size;
height: 32px; height: $button-size;
mask-image: url('$(res)/img/image-view/close.svg'); mask-image: url('$(res)/img/image-view/close.svg');
mask-size: 40%; mask-size: 40%;
} }

View file

@ -16,22 +16,45 @@ limitations under the License.
.mx_ReplyThread { .mx_ReplyThread {
margin-top: 0; margin-top: 0;
}
.mx_ReplyThread .mx_DateSeparator {
font-size: 1em !important;
margin-top: 0;
margin-bottom: 0;
padding-bottom: 1px;
bottom: -5px;
}
.mx_ReplyThread_show {
cursor: pointer;
}
blockquote.mx_ReplyThread {
margin-left: 0; margin-left: 0;
margin-right: 0;
margin-bottom: 8px;
padding-left: 10px; padding-left: 10px;
border-left: 4px solid $blockquote-bar-color; border-left: 4px solid $button-bg-color;
.mx_ReplyThread_show {
cursor: pointer;
}
&.mx_ReplyThread_color1 {
border-left-color: $username-variant1-color;
}
&.mx_ReplyThread_color2 {
border-left-color: $username-variant2-color;
}
&.mx_ReplyThread_color3 {
border-left-color: $username-variant3-color;
}
&.mx_ReplyThread_color4 {
border-left-color: $username-variant4-color;
}
&.mx_ReplyThread_color5 {
border-left-color: $username-variant5-color;
}
&.mx_ReplyThread_color6 {
border-left-color: $username-variant6-color;
}
&.mx_ReplyThread_color7 {
border-left-color: $username-variant7-color;
}
&.mx_ReplyThread_color8 {
border-left-color: $username-variant8-color;
}
} }

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

@ -83,12 +83,12 @@ limitations under the License.
mask-size: cover; mask-size: cover;
mask-image: url('$(res)/img/element-icons/room/composer/attach.svg'); mask-image: url('$(res)/img/element-icons/room/composer/attach.svg');
background-color: $message-body-panel-icon-fg-color; background-color: $message-body-panel-icon-fg-color;
width: 13px; width: 15px;
height: 15px; height: 15px;
position: absolute; position: absolute;
top: 8px; top: 8px;
left: 9px; left: 8px;
} }
} }

View file

@ -0,0 +1,37 @@
/*
Copyright 2020 Tulir Asokan <tulir@maunium.net>
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_MImageReplyBody {
display: flex;
.mx_MImageBody_thumbnail_container {
flex: 1;
margin-right: 4px;
}
.mx_MImageReplyBody_info {
flex: 1;
.mx_MImageReplyBody_sender {
grid-area: sender;
}
.mx_MImageReplyBody_filename {
grid-area: filename;
}
}
}

View file

@ -198,8 +198,9 @@ $irc-line-height: $font-18px;
.mx_ReplyThread { .mx_ReplyThread {
margin: 0; margin: 0;
.mx_SenderProfile { .mx_SenderProfile {
order: unset;
max-width: unset;
width: unset; width: unset;
max-width: var(--name-width);
background: transparent; background: transparent;
} }

View file

@ -22,28 +22,34 @@ limitations under the License.
max-height: 50vh; max-height: 50vh;
overflow: auto; overflow: auto;
box-shadow: 0px -16px 32px $composer-shadow-color; box-shadow: 0px -16px 32px $composer-shadow-color;
.mx_ReplyPreview_section {
border-bottom: 1px solid $primary-hairline-color;
.mx_ReplyPreview_header {
margin: 8px;
color: $primary-fg-color;
font-weight: 400;
opacity: 0.4;
}
.mx_ReplyPreview_tile {
margin: 0 8px;
}
.mx_ReplyPreview_title {
float: left;
}
.mx_ReplyPreview_cancel {
float: right;
cursor: pointer;
display: flex;
}
.mx_ReplyPreview_clear {
clear: both;
}
}
} }
.mx_ReplyPreview_section {
border-bottom: 1px solid $primary-hairline-color;
}
.mx_ReplyPreview_header {
margin: 12px;
color: $primary-fg-color;
font-weight: 400;
opacity: 0.4;
}
.mx_ReplyPreview_title {
float: left;
}
.mx_ReplyPreview_cancel {
float: right;
cursor: pointer;
}
.mx_ReplyPreview_clear {
clear: both;
}

View file

@ -0,0 +1,119 @@
/*
Copyright 2020 Tulir Asokan <tulir@maunium.net>
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_ReplyTile {
position: relative;
padding: 2px 0;
font-size: $font-14px;
line-height: $font-16px;
&.mx_ReplyTile_audio .mx_MFileBody_info_icon::before {
mask-image: url("$(res)/img/element-icons/speaker.svg");
}
&.mx_ReplyTile_video .mx_MFileBody_info_icon::before {
mask-image: url("$(res)/img/element-icons/call/video-call.svg");
}
.mx_MFileBody {
.mx_MFileBody_info {
margin: 5px 0;
}
.mx_MFileBody_download {
display: none;
}
}
> a {
display: flex;
flex-direction: column;
text-decoration: none;
color: $primary-fg-color;
}
.mx_RedactedBody {
padding: 4px 0 2px 20px;
&::before {
height: 13px;
width: 13px;
top: 5px;
}
}
// We do reply size limiting with CSS to avoid duplicating the TextualBody component.
.mx_EventTile_content {
$reply-lines: 2;
$line-height: $font-22px;
pointer-events: none;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: $reply-lines;
line-height: $line-height;
.mx_EventTile_body.mx_EventTile_bigEmoji {
line-height: $line-height !important;
font-size: $font-14px !important; // Override the big emoji override
}
// Hide line numbers
.mx_EventTile_lineNumbers {
display: none;
}
// Hack to cut content in <pre> tags too
.mx_EventTile_pre_container > pre {
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: $reply-lines;
padding: 4px;
}
.markdown-body blockquote,
.markdown-body dl,
.markdown-body ol,
.markdown-body p,
.markdown-body pre,
.markdown-body table,
.markdown-body ul {
margin-bottom: 4px;
}
}
&.mx_ReplyTile_info {
padding-top: 0;
}
.mx_SenderProfile {
font-size: $font-14px;
line-height: $font-17px;
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

@ -193,6 +193,10 @@ limitations under the License.
mask-image: url('$(res)/img/element-icons/settings.svg'); mask-image: url('$(res)/img/element-icons/settings.svg');
} }
.mx_RoomTile_iconCopyLink::before {
mask-image: url('$(res)/img/element-icons/link.svg');
}
.mx_RoomTile_iconInvite::before { .mx_RoomTile_iconInvite::before {
mask-image: url('$(res)/img/element-icons/room/invite.svg'); mask-image: url('$(res)/img/element-icons/room/invite.svg');
} }

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

View file

@ -16,11 +16,21 @@ limitations under the License.
.mx_DialPad { .mx_DialPad {
display: grid; display: grid;
row-gap: 16px;
column-gap: 0px;
margin-top: 24px;
margin-left: auto;
margin-right: auto;
/* squeeze the dial pad buttons together horizontally */
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(3, 1fr);
gap: 16px;
} }
.mx_DialPad_button { .mx_DialPad_button {
display: flex;
flex-direction: column;
justify-content: center;
width: 40px; width: 40px;
height: 40px; height: 40px;
background-color: $dialpad-button-bg-color; background-color: $dialpad-button-bg-color;
@ -29,10 +39,19 @@ limitations under the License.
font-weight: 600; font-weight: 600;
text-align: center; text-align: center;
vertical-align: middle; vertical-align: middle;
line-height: 40px; margin-left: auto;
margin-right: auto;
} }
.mx_DialPad_deleteButton, .mx_DialPad_dialButton { .mx_DialPad_button .mx_DialPad_buttonSubText {
font-size: 8px;
}
.mx_DialPad_dialButton {
/* Always show the dial button in the center grid column */
grid-column: 2;
background-color: $accent-color;
&::before { &::before {
content: ''; content: '';
display: inline-block; display: inline-block;
@ -42,21 +61,7 @@ limitations under the License.
mask-repeat: no-repeat; mask-repeat: no-repeat;
mask-size: 20px; mask-size: 20px;
mask-position: center; mask-position: center;
background-color: $primary-bg-color; background-color: #FFF; // on all themes
}
}
.mx_DialPad_deleteButton {
background-color: $notice-primary-color;
&::before {
mask-image: url('$(res)/img/element-icons/call/delete.svg');
mask-position: 9px; // delete icon is right-heavy so have to be slightly to the left to look centered
}
}
.mx_DialPad_dialButton {
background-color: $accent-color;
&::before {
mask-image: url('$(res)/img/element-icons/call/voice-call.svg'); mask-image: url('$(res)/img/element-icons/call/voice-call.svg');
} }
} }

View file

@ -14,10 +14,40 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
.mx_DialPadContextMenu_dialPad .mx_DialPad {
row-gap: 16px;
column-gap: 32px;
}
.mx_DialPadContextMenuWrapper {
padding: 15px;
}
.mx_DialPadContextMenu_header { .mx_DialPadContextMenu_header {
margin-top: 12px; border: none;
margin-left: 12px; margin-top: 32px;
margin-right: 12px; margin-left: 20px;
margin-right: 20px;
/* a separator between the input line and the dial buttons */
border-bottom: 1px solid $quaternary-fg-color;
transition: border-bottom 0.25s;
}
.mx_DialPadContextMenu_cancel {
float: right;
mask: url('$(res)/img/feather-customised/cancel.svg');
mask-repeat: no-repeat;
mask-position: center;
mask-size: cover;
width: 14px;
height: 14px;
background-color: $dialog-close-fg-color;
cursor: pointer;
}
.mx_DialPadContextMenu_header:focus-within {
border-bottom: 1px solid $accent-color;
} }
.mx_DialPadContextMenu_title { .mx_DialPadContextMenu_title {
@ -30,7 +60,6 @@ limitations under the License.
height: 1.5em; height: 1.5em;
font-size: 18px; font-size: 18px;
font-weight: 600; font-weight: 600;
max-width: 150px;
border: none; border: none;
margin: 0px; margin: 0px;
} }
@ -38,7 +67,7 @@ limitations under the License.
font-size: 18px; font-size: 18px;
font-weight: 600; font-weight: 600;
overflow: hidden; overflow: hidden;
max-width: 150px; max-width: 185px;
text-align: left; text-align: left;
direction: rtl; direction: rtl;
padding: 8px 0px; padding: 8px 0px;
@ -48,13 +77,3 @@ limitations under the License.
.mx_DialPadContextMenu_dialPad { .mx_DialPadContextMenu_dialPad {
margin: 16px; margin: 16px;
} }
.mx_DialPadContextMenu_horizSep {
position: relative;
&::before {
content: '';
position: absolute;
width: 100%;
border-bottom: 1px solid $input-darker-bg-color;
}
}

View file

@ -19,14 +19,23 @@ limitations under the License.
} }
.mx_DialPadModal { .mx_DialPadModal {
width: 192px; width: 292px;
height: 368px; height: 370px;
padding: 16px 0px 0px 0px;
} }
.mx_DialPadModal_header { .mx_DialPadModal_header {
margin-top: 12px; margin-top: 32px;
margin-left: 12px; margin-left: 40px;
margin-right: 12px; margin-right: 40px;
/* a separator between the input line and the dial buttons */
border-bottom: 1px solid $quaternary-fg-color;
transition: border-bottom 0.25s;
}
.mx_DialPadModal_header:focus-within {
border-bottom: 1px solid $accent-color;
} }
.mx_DialPadModal_title { .mx_DialPadModal_title {
@ -45,11 +54,18 @@ limitations under the License.
height: 14px; height: 14px;
background-color: $dialog-close-fg-color; background-color: $dialog-close-fg-color;
cursor: pointer; cursor: pointer;
margin-right: 16px;
} }
.mx_DialPadModal_field { .mx_DialPadModal_field {
border: none; border: none;
margin: 0px; margin: 0px;
height: 30px;
}
.mx_DialPadModal_field .mx_Field_postfix {
/* Remove border separator between postfix and field content */
border-left: none;
} }
.mx_DialPadModal_field input { .mx_DialPadModal_field input {
@ -62,13 +78,3 @@ limitations under the License.
margin-right: 16px; margin-right: 16px;
margin-top: 16px; margin-top: 16px;
} }
.mx_DialPadModal_horizSep {
position: relative;
&::before {
content: '';
position: absolute;
width: 100%;
border-bottom: 1px solid $input-darker-bg-color;
}
}

View file

@ -0,0 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.97991 1.48403L4 4.80062L1 4.80062C0.447715 4.80062 0 5.24834 0 5.80062V10.2006C0 10.7529 0.447714 11.2006 0.999999 11.2006L4 11.2006L7.97991 14.5172C8.30557 14.7886 8.8 14.557 8.8 14.1331V1.86814C8.8 1.44422 8.30557 1.21265 7.97991 1.48403Z" fill="#737D8C"/>
<path d="M14.1258 2.79107C13.8998 2.50044 13.4809 2.44808 13.1903 2.67413C12.9 2.89992 12.8475 3.3181 13.0726 3.6087L13.0731 3.60935L13.0738 3.61021L13.0829 3.62231C13.0917 3.63418 13.1059 3.65355 13.1248 3.68011C13.1625 3.73326 13.2187 3.81496 13.2872 3.92256C13.4243 4.13812 13.6097 4.45554 13.7955 4.85371C14.169 5.65407 14.5329 6.75597 14.5329 8.00036C14.5329 9.24475 14.169 10.3466 13.7955 11.147C13.6097 11.5452 13.4243 11.8626 13.2872 12.0782C13.2187 12.1858 13.1625 12.2675 13.1248 12.3206C13.1059 12.3472 13.0917 12.3665 13.0829 12.3784L13.0738 12.3905L13.0731 12.3914L13.0725 12.3921C12.8475 12.6827 12.9 13.1008 13.1903 13.3266C13.4809 13.5526 13.8998 13.5003 14.1258 13.2097L13.629 12.8232C14.1258 13.2096 14.1258 13.2097 14.1258 13.2097L14.1272 13.2079L14.1291 13.2055L14.1346 13.1982L14.1523 13.1748C14.1669 13.1552 14.187 13.1277 14.2119 13.0926C14.2617 13.0225 14.3305 12.9221 14.4121 12.794C14.5749 12.5381 14.7895 12.1698 15.0037 11.7109C15.4302 10.7969 15.8663 9.49883 15.8663 8.00036C15.8663 6.50189 15.4302 5.20379 15.0037 4.28987C14.7895 3.83089 14.5749 3.4626 14.4121 3.20673C14.3305 3.07862 14.2617 2.97818 14.2119 2.90811C14.187 2.87306 14.1669 2.84556 14.1523 2.82596L14.1346 2.80249L14.1291 2.79525L14.1272 2.79278L14.1264 2.79183C14.1264 2.79183 14.1258 2.79107 13.5996 3.20036L14.1258 2.79107Z" fill="#737D8C"/>
<path d="M11.7264 5.19121C11.5004 4.90058 11.0815 4.84823 10.7909 5.07427C10.501 5.29973 10.4482 5.71698 10.6722 6.00752L10.6745 6.01057C10.6775 6.01457 10.6831 6.02223 10.691 6.03338C10.7069 6.05572 10.7318 6.09189 10.7628 6.14057C10.8249 6.23827 10.9103 6.38426 10.9961 6.56815C11.1696 6.93993 11.3335 7.44183 11.3335 8.00051C11.3335 8.55918 11.1696 9.06108 10.9961 9.43287C10.9103 9.61675 10.8249 9.76275 10.7628 9.86045C10.7318 9.90912 10.7069 9.94529 10.691 9.96763C10.6831 9.97879 10.6775 9.98645 10.6745 9.99044L10.6722 9.9935C10.4482 10.284 10.501 10.7013 10.7909 10.9267C11.0815 11.1528 11.5004 11.1004 11.7264 10.8098L11.2002 10.4005C11.7264 10.8098 11.7264 10.8098 11.7264 10.8098L11.7276 10.8083L11.7291 10.8064L11.7329 10.8014L11.7439 10.7868C11.7526 10.7751 11.7642 10.7593 11.7781 10.7396C11.806 10.7004 11.8436 10.6455 11.8876 10.5763C11.9755 10.4383 12.0901 10.2414 12.2043 9.99672C12.4308 9.51136 12.6669 8.81326 12.6669 8.00051C12.6669 7.18775 12.4308 6.48965 12.2043 6.0043C12.0901 5.75961 11.9755 5.56275 11.8876 5.42473C11.8436 5.35555 11.806 5.30065 11.7781 5.26138C11.7642 5.24173 11.7526 5.22596 11.7439 5.21422L11.7329 5.19964L11.7291 5.19465L11.7276 5.19274L11.727 5.19193C11.727 5.19193 11.7264 5.19121 11.2002 5.60051L11.7264 5.19121Z" fill="#737D8C"/>
</svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

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

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 19C10.9 19 10 19.9 10 21C10 22.1 10.9 23 12 23C13.1 23 14 22.1 14 21C14 19.9 13.1 19 12 19ZM6 1C4.9 1 4 1.9 4 3C4 4.1 4.9 5 6 5C7.1 5 8 4.1 8 3C8 1.9 7.1 1 6 1ZM6 7C4.9 7 4 7.9 4 9C4 10.1 4.9 11 6 11C7.1 11 8 10.1 8 9C8 7.9 7.1 7 6 7ZM6 13C4.9 13 4 13.9 4 15C4 16.1 4.9 17 6 17C7.1 17 8 16.1 8 15C8 13.9 7.1 13 6 13ZM18 5C19.1 5 20 4.1 20 3C20 1.9 19.1 1 18 1C16.9 1 16 1.9 16 3C16 4.1 16.9 5 18 5ZM12 13C10.9 13 10 13.9 10 15C10 16.1 10.9 17 12 17C13.1 17 14 16.1 14 15C14 13.9 13.1 13 12 13ZM18 13C16.9 13 16 13.9 16 15C16 16.1 16.9 17 18 17C19.1 17 20 16.1 20 15C20 13.9 19.1 13 18 13ZM18 7C16.9 7 16 7.9 16 9C16 10.1 16.9 11 18 11C19.1 11 20 10.1 20 9C20 7.9 19.1 7 18 7ZM12 7C10.9 7 10 7.9 10 9C10 10.1 10.9 11 12 11C13.1 11 14 10.1 14 9C14 7.9 13.1 7 12 7ZM12 1C10.9 1 10 1.9 10 3C10 4.1 10.9 5 12 5C13.1 5 14 4.1 14 3C14 1.9 13.1 1 12 1Z" fill="#8D97A5"/>
</svg>

After

Width:  |  Height:  |  Size: 979 B

View file

@ -0,0 +1,7 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="path-1-inside-1" fill="white">
<path fill-rule="evenodd" clip-rule="evenodd" d="M18.1502 21.1214C16.3946 22.3074 14.2782 23 12 23C9.52367 23 7.23845 22.1817 5.4 20.8008C2.72821 18.794 1 15.5988 1 12C1 5.92487 5.92487 1 12 1C18.0751 1 23 5.92487 23 12C23 15.797 21.0762 19.1446 18.1502 21.1214ZM12 12.55C13.8225 12.55 15.3 10.9494 15.3 8.975C15.3 7.00058 13.8225 5.4 12 5.4C10.1775 5.4 8.7 7.00058 8.7 8.975C8.7 10.9494 10.1775 12.55 12 12.55ZM12 20.8C14.3782 20.8 16.536 19.8566 18.1197 18.3237C17.1403 15.9056 14.7693 14.2 12 14.2C9.23066 14.2 6.85969 15.9056 5.88028 18.3237C7.46399 19.8566 9.62183 20.8 12 20.8Z"/>
</mask>
<path fill-rule="evenodd" clip-rule="evenodd" d="M18.1502 21.1214C16.3946 22.3074 14.2782 23 12 23C9.52367 23 7.23845 22.1817 5.4 20.8008C2.72821 18.794 1 15.5988 1 12C1 5.92487 5.92487 1 12 1C18.0751 1 23 5.92487 23 12C23 15.797 21.0762 19.1446 18.1502 21.1214ZM12 12.55C13.8225 12.55 15.3 10.9494 15.3 8.975C15.3 7.00058 13.8225 5.4 12 5.4C10.1775 5.4 8.7 7.00058 8.7 8.975C8.7 10.9494 10.1775 12.55 12 12.55ZM12 20.8C14.3782 20.8 16.536 19.8566 18.1197 18.3237C17.1403 15.9056 14.7693 14.2 12 14.2C9.23066 14.2 6.85969 15.9056 5.88028 18.3237C7.46399 19.8566 9.62183 20.8 12 20.8Z" fill="#8D97A5"/>
<path d="M18.1502 21.1214L18.9339 22.2814L18.1502 21.1214ZM5.4 20.8008L4.55919 21.9202H4.55919L5.4 20.8008ZM18.1197 18.3237L19.0934 19.3296L19.7717 18.6731L19.4173 17.7981L18.1197 18.3237ZM5.88028 18.3237L4.58268 17.7981L4.22829 18.6731L4.90659 19.3296L5.88028 18.3237ZM12 24.4C14.5662 24.4 16.9541 23.619 18.9339 22.2814L17.3665 19.9613C15.835 20.9959 13.9902 21.6 12 21.6V24.4ZM4.55919 21.9202C6.63176 23.477 9.21011 24.4 12 24.4V21.6C9.83723 21.6 7.84514 20.8865 6.24081 19.6814L4.55919 21.9202ZM-0.399998 12C-0.399998 16.0577 1.55052 19.6603 4.55919 21.9202L6.24081 19.6814C3.90591 17.9276 2.4 15.1399 2.4 12H-0.399998ZM12 -0.399998C5.15167 -0.399998 -0.399998 5.15167 -0.399998 12H2.4C2.4 6.69807 6.69807 2.4 12 2.4V-0.399998ZM24.4 12C24.4 5.15167 18.8483 -0.399998 12 -0.399998V2.4C17.3019 2.4 21.6 6.69807 21.6 12H24.4ZM18.9339 22.2814C22.2288 20.0554 24.4 16.2815 24.4 12H21.6C21.6 15.3124 19.9236 18.2337 17.3665 19.9613L18.9339 22.2814ZM13.9 8.975C13.9 10.2838 12.9459 11.15 12 11.15V13.95C14.6991 13.95 16.7 11.615 16.7 8.975H13.9ZM12 6.8C12.9459 6.8 13.9 7.66616 13.9 8.975H16.7C16.7 6.335 14.6991 4 12 4V6.8ZM10.1 8.975C10.1 7.66616 11.0541 6.8 12 6.8V4C9.30086 4 7.3 6.335 7.3 8.975H10.1ZM12 11.15C11.0541 11.15 10.1 10.2838 10.1 8.975H7.3C7.3 11.615 9.30086 13.95 12 13.95V11.15ZM17.146 17.3178C15.8129 18.6081 14.0004 19.4 12 19.4V22.2C14.756 22.2 17.2591 21.1051 19.0934 19.3296L17.146 17.3178ZM12 15.6C14.1797 15.6 16.0494 16.9415 16.8221 18.8493L19.4173 17.7981C18.2312 14.8697 15.359 12.8 12 12.8V15.6ZM7.17788 18.8493C7.95058 16.9415 9.8203 15.6 12 15.6V12.8C8.64102 12.8 5.7688 14.8697 4.58268 17.7981L7.17788 18.8493ZM12 19.4C9.99963 19.4 8.18709 18.6081 6.85397 17.3178L4.90659 19.3296C6.74088 21.1051 9.24402 22.2 12 22.2V19.4Z" fill="#8D97A5" mask="url(#path-1-inside-1)"/>
</svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

View file

@ -118,7 +118,7 @@ $voipcall-plinth-color: #394049;
// ******************** // ********************
$theme-button-bg-color: #e3e8f0; $theme-button-bg-color: #e3e8f0;
$dialpad-button-bg-color: #6F7882; $dialpad-button-bg-color: #394049;
$roomlist-button-bg-color: rgba(141, 151, 165, 0.2); // Buttons include the filter box, explore button, and sublist buttons $roomlist-button-bg-color: rgba(141, 151, 165, 0.2); // Buttons include the filter box, explore button, and sublist buttons
$roomlist-filter-active-bg-color: $bg-color; $roomlist-filter-active-bg-color: $bg-color;

View file

@ -15,6 +15,8 @@ limitations under the License.
*/ */
import "matrix-js-sdk/src/@types/global"; // load matrix-js-sdk's type extensions first import "matrix-js-sdk/src/@types/global"; // load matrix-js-sdk's type extensions first
// Load types for the WG CSS Font Loading APIs https://github.com/Microsoft/TypeScript/issues/13569
import "@types/css-font-loading-module";
import "@types/modernizr"; import "@types/modernizr";
import ContentMessages from "../ContentMessages"; import ContentMessages from "../ContentMessages";

23
src/@types/worker-loader.d.ts vendored Normal file
View file

@ -0,0 +1,23 @@
/*
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.
*/
declare module "*.worker.ts" {
class WebpackWorker extends Worker {
constructor();
}
export default WebpackWorker;
}

View file

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

View file

@ -248,7 +248,7 @@ export default class AddThreepid {
/** /**
* Takes a phone number verification code as entered by the user and validates * Takes a phone number verification code as entered by the user and validates
* it with the ID server, then if successful, adds the phone number. * it with the identity server, then if successful, adds the phone number.
* @param {string} msisdnToken phone number verification code as entered by the user * @param {string} msisdnToken phone number verification code as entered by the user
* @return {Promise} Resolves if the phone number was added. Rejects with an object * @return {Promise} Resolves if the phone number was added. Rejects with an object
* with a "message" property which contains a human-readable message detailing why * with a "message" property which contains a human-readable message detailing why

View file

@ -21,7 +21,7 @@ import { ResizeMethod } from "matrix-js-sdk/src/@types/partials";
import DMRoomMap from './utils/DMRoomMap'; import DMRoomMap from './utils/DMRoomMap';
import { mediaFromMxc } from "./customisations/Media"; import { mediaFromMxc } from "./customisations/Media";
import SettingsStore from "./settings/SettingsStore"; import SpaceStore from "./stores/SpaceStore";
// Not to be used for BaseAvatar urls as that has similar default avatar fallback already // Not to be used for BaseAvatar urls as that has similar default avatar fallback already
export function avatarUrlForMember( export function avatarUrlForMember(
@ -153,7 +153,7 @@ export function avatarUrlForRoom(room: Room, width: number, height: number, resi
} }
// space rooms cannot be DMs so skip the rest // space rooms cannot be DMs so skip the rest
if (SettingsStore.getValue("feature_spaces") && room.isSpaceRoom()) return null; if (SpaceStore.spacesEnabled && room.isSpaceRoom()) return null;
let otherMember = null; let otherMember = null;
const otherUserId = DMRoomMap.shared().getUserIdForRoomId(room.roomId); const otherUserId = DMRoomMap.shared().getUserIdForRoomId(room.roomId);

60
src/BlurhashEncoder.ts Normal file
View file

@ -0,0 +1,60 @@
/*
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 { defer, IDeferred } from "matrix-js-sdk/src/utils";
// @ts-ignore - `.ts` is needed here to make TS happy
import BlurhashWorker from "./workers/blurhash.worker.ts";
interface IBlurhashWorkerResponse {
seq: number;
blurhash: string;
}
export class BlurhashEncoder {
private static internalInstance = new BlurhashEncoder();
public static get instance(): BlurhashEncoder {
return BlurhashEncoder.internalInstance;
}
private readonly worker: Worker;
private seq = 0;
private pendingDeferredMap = new Map<number, IDeferred<string>>();
constructor() {
this.worker = new BlurhashWorker();
this.worker.onmessage = this.onMessage;
}
private onMessage = (ev: MessageEvent<IBlurhashWorkerResponse>) => {
const { seq, blurhash } = ev.data;
const deferred = this.pendingDeferredMap.get(seq);
if (deferred) {
this.pendingDeferredMap.delete(seq);
deferred.resolve(blurhash);
}
};
public getBlurhash(imageData: ImageData): Promise<string> {
const seq = this.seq++;
const deferred = defer<string>();
this.pendingDeferredMap.set(seq, deferred);
this.worker.postMessage({ seq, imageData });
return deferred.promise;
}
}

View file

@ -154,7 +154,7 @@ export default class CallHandler extends EventEmitter {
private supportsPstnProtocol = null; private supportsPstnProtocol = null;
private pstnSupportPrefixed = null; // True if the server only support the prefixed pstn protocol private pstnSupportPrefixed = null; // True if the server only support the prefixed pstn protocol
private supportsSipNativeVirtual = null; // im.vector.protocol.sip_virtual and im.vector.protocol.sip_native private supportsSipNativeVirtual = null; // im.vector.protocol.sip_virtual and im.vector.protocol.sip_native
private pstnSupportCheckTimer: NodeJS.Timeout; // number actually because we're in the browser private pstnSupportCheckTimer: number;
// For rooms we've been invited to, true if they're from virtual user, false if we've checked and they aren't. // For rooms we've been invited to, true if they're from virtual user, false if we've checked and they aren't.
private invitedRoomsAreVirtual = new Map<string, boolean>(); private invitedRoomsAreVirtual = new Map<string, boolean>();
private invitedRoomCheckInProgress = false; private invitedRoomCheckInProgress = false;
@ -394,7 +394,7 @@ export default class CallHandler extends EventEmitter {
} }
private setCallListeners(call: MatrixCall) { private setCallListeners(call: MatrixCall) {
let mappedRoomId = CallHandler.sharedInstance().roomIdForCall(call); let mappedRoomId = this.roomIdForCall(call);
call.on(CallEvent.Error, (err: CallError) => { call.on(CallEvent.Error, (err: CallError) => {
if (!this.matchesCallForThisRoom(call)) return; if (!this.matchesCallForThisRoom(call)) return;
@ -871,6 +871,12 @@ export default class CallHandler extends EventEmitter {
case Action.DialNumber: case Action.DialNumber:
this.dialNumber(payload.number); this.dialNumber(payload.number);
break; break;
case Action.TransferCallToMatrixID:
this.startTransferToMatrixID(payload.call, payload.destination, payload.consultFirst);
break;
case Action.TransferCallToPhoneNumber:
this.startTransferToPhoneNumber(payload.call, payload.destination, payload.consultFirst);
break;
} }
}; };
@ -905,6 +911,48 @@ export default class CallHandler extends EventEmitter {
}); });
} }
private async startTransferToPhoneNumber(call: MatrixCall, destination: string, consultFirst: boolean) {
const results = await this.pstnLookup(destination);
if (!results || results.length === 0 || !results[0].userid) {
Modal.createTrackedDialog('', '', ErrorDialog, {
title: _t("Unable to transfer call"),
description: _t("There was an error looking up the phone number"),
});
return;
}
await this.startTransferToMatrixID(call, results[0].userid, consultFirst);
}
private async startTransferToMatrixID(call: MatrixCall, destination: string, consultFirst: boolean) {
if (consultFirst) {
const dmRoomId = await ensureDMExists(MatrixClientPeg.get(), destination);
dis.dispatch({
action: 'place_call',
type: call.type,
room_id: dmRoomId,
transferee: call,
});
dis.dispatch({
action: 'view_room',
room_id: dmRoomId,
should_peek: false,
joining: false,
});
} else {
try {
await call.transfer(destination);
} catch (e) {
console.log("Failed to transfer call", e);
Modal.createTrackedDialog('Failed to transfer call', '', ErrorDialog, {
title: _t('Transfer Failed'),
description: _t('Failed to transfer call'),
});
}
}
}
setActiveCallRoomId(activeCallRoomId: string) { setActiveCallRoomId(activeCallRoomId: string) {
logger.info("Setting call in room " + activeCallRoomId + " active"); logger.info("Setting call in room " + activeCallRoomId + " active");

View file

@ -17,7 +17,6 @@ limitations under the License.
*/ */
import React from "react"; import React from "react";
import { encode } from "blurhash";
import { MatrixClient } from "matrix-js-sdk/src/client"; import { MatrixClient } from "matrix-js-sdk/src/client";
import dis from './dispatcher/dispatcher'; import dis from './dispatcher/dispatcher';
@ -28,7 +27,6 @@ import RoomViewStore from './stores/RoomViewStore';
import encrypt from "browser-encrypt-attachment"; import encrypt from "browser-encrypt-attachment";
import extractPngChunks from "png-chunks-extract"; import extractPngChunks from "png-chunks-extract";
import Spinner from "./components/views/elements/Spinner"; import Spinner from "./components/views/elements/Spinner";
import { Action } from "./dispatcher/actions"; import { Action } from "./dispatcher/actions";
import CountlyAnalytics from "./CountlyAnalytics"; import CountlyAnalytics from "./CountlyAnalytics";
import { import {
@ -39,7 +37,8 @@ import {
UploadStartedPayload, UploadStartedPayload,
} from "./dispatcher/payloads/UploadPayload"; } from "./dispatcher/payloads/UploadPayload";
import { IUpload } from "./models/IUpload"; import { IUpload } from "./models/IUpload";
import { IImageInfo } from "matrix-js-sdk/src/@types/partials"; import { IAbortablePromise, IImageInfo } from "matrix-js-sdk/src/@types/partials";
import { BlurhashEncoder } from "./BlurhashEncoder";
const MAX_WIDTH = 800; const MAX_WIDTH = 800;
const MAX_HEIGHT = 600; const MAX_HEIGHT = 600;
@ -85,10 +84,6 @@ interface IThumbnail {
thumbnail: Blob; thumbnail: Blob;
} }
interface IAbortablePromise<T> extends Promise<T> {
abort(): void;
}
/** /**
* Create a thumbnail for a image DOM element. * Create a thumbnail for a image DOM element.
* The image will be smaller than MAX_WIDTH and MAX_HEIGHT. * The image will be smaller than MAX_WIDTH and MAX_HEIGHT.
@ -107,55 +102,62 @@ interface IAbortablePromise<T> extends Promise<T> {
* @return {Promise} A promise that resolves with an object with an info key * @return {Promise} A promise that resolves with an object with an info key
* and a thumbnail key. * and a thumbnail key.
*/ */
function createThumbnail( async function createThumbnail(
element: ThumbnailableElement, element: ThumbnailableElement,
inputWidth: number, inputWidth: number,
inputHeight: number, inputHeight: number,
mimeType: string, mimeType: string,
): Promise<IThumbnail> { ): Promise<IThumbnail> {
return new Promise((resolve) => { let targetWidth = inputWidth;
let targetWidth = inputWidth; let targetHeight = inputHeight;
let targetHeight = inputHeight; if (targetHeight > MAX_HEIGHT) {
if (targetHeight > MAX_HEIGHT) { targetWidth = Math.floor(targetWidth * (MAX_HEIGHT / targetHeight));
targetWidth = Math.floor(targetWidth * (MAX_HEIGHT / targetHeight)); targetHeight = MAX_HEIGHT;
targetHeight = MAX_HEIGHT; }
} if (targetWidth > MAX_WIDTH) {
if (targetWidth > MAX_WIDTH) { targetHeight = Math.floor(targetHeight * (MAX_WIDTH / targetWidth));
targetHeight = Math.floor(targetHeight * (MAX_WIDTH / targetWidth)); targetWidth = MAX_WIDTH;
targetWidth = MAX_WIDTH; }
}
const canvas = document.createElement("canvas"); let canvas: HTMLCanvasElement | OffscreenCanvas;
if (window.OffscreenCanvas) {
canvas = new window.OffscreenCanvas(targetWidth, targetHeight);
} else {
canvas = document.createElement("canvas");
canvas.width = targetWidth; canvas.width = targetWidth;
canvas.height = targetHeight; canvas.height = targetHeight;
const context = canvas.getContext("2d"); }
context.drawImage(element, 0, 0, targetWidth, targetHeight);
const imageData = context.getImageData(0, 0, targetWidth, targetHeight); const context = canvas.getContext("2d");
const blurhash = encode( context.drawImage(element, 0, 0, targetWidth, targetHeight);
imageData.data,
imageData.width, let thumbnailPromise: Promise<Blob>;
imageData.height,
// use 4 components on the longer dimension, if square then both if (window.OffscreenCanvas) {
imageData.width >= imageData.height ? 4 : 3, thumbnailPromise = (canvas as OffscreenCanvas).convertToBlob({ type: mimeType });
imageData.height >= imageData.width ? 4 : 3, } else {
); thumbnailPromise = new Promise<Blob>(resolve => (canvas as HTMLCanvasElement).toBlob(resolve, mimeType));
canvas.toBlob(function(thumbnail) { }
resolve({
info: { const imageData = context.getImageData(0, 0, targetWidth, targetHeight);
thumbnail_info: { // thumbnailPromise and blurhash promise are being awaited concurrently
w: targetWidth, const blurhash = await BlurhashEncoder.instance.getBlurhash(imageData);
h: targetHeight, const thumbnail = await thumbnailPromise;
mimetype: thumbnail.type,
size: thumbnail.size, return {
}, info: {
w: inputWidth, thumbnail_info: {
h: inputHeight, w: targetWidth,
[BLURHASH_FIELD]: blurhash, h: targetHeight,
}, mimetype: thumbnail.type,
thumbnail, size: thumbnail.size,
}); },
}, mimeType); w: inputWidth,
}); h: inputHeight,
[BLURHASH_FIELD]: blurhash,
},
thumbnail,
};
} }
/** /**
@ -333,7 +335,7 @@ export function uploadFile(
roomId: string, roomId: string,
file: File | Blob, file: File | Blob,
progressHandler?: any, // TODO: Types progressHandler?: any, // TODO: Types
): Promise<{url?: string, file?: any}> { // TODO: Types ): IAbortablePromise<{url?: string, file?: any}> { // TODO: Types
let canceled = false; let canceled = false;
if (matrixClient.isRoomEncrypted(roomId)) { if (matrixClient.isRoomEncrypted(roomId)) {
// If the room is encrypted then encrypt the file before uploading it. // If the room is encrypted then encrypt the file before uploading it.
@ -365,8 +367,8 @@ export function uploadFile(
encryptInfo.mimetype = file.type; encryptInfo.mimetype = file.type;
} }
return { "file": encryptInfo }; return { "file": encryptInfo };
}); }) as IAbortablePromise<{ file: any }>;
(prom as IAbortablePromise<any>).abort = () => { prom.abort = () => {
canceled = true; canceled = true;
if (uploadPromise) matrixClient.cancelUpload(uploadPromise); if (uploadPromise) matrixClient.cancelUpload(uploadPromise);
}; };
@ -379,8 +381,8 @@ export function uploadFile(
if (canceled) throw new UploadCanceledError(); if (canceled) throw new UploadCanceledError();
// If the attachment isn't encrypted then include the URL directly. // If the attachment isn't encrypted then include the URL directly.
return { url }; return { url };
}); }) as IAbortablePromise<{ url: string }>;
(promise1 as any).abort = () => { promise1.abort = () => {
canceled = true; canceled = true;
matrixClient.cancelUpload(basePromise); matrixClient.cancelUpload(basePromise);
}; };
@ -551,10 +553,10 @@ export default class ContentMessages {
content.msgtype = 'm.file'; content.msgtype = 'm.file';
resolve(); resolve();
} }
}); }) as IAbortablePromise<void>;
// create temporary abort handler for before the actual upload gets passed off to js-sdk // create temporary abort handler for before the actual upload gets passed off to js-sdk
(prom as IAbortablePromise<any>).abort = () => { prom.abort = () => {
upload.canceled = true; upload.canceled = true;
}; };
@ -583,9 +585,7 @@ export default class ContentMessages {
// XXX: upload.promise must be the promise that // XXX: upload.promise must be the promise that
// is returned by uploadFile as it has an abort() // is returned by uploadFile as it has an abort()
// method hacked onto it. // method hacked onto it.
upload.promise = uploadFile( upload.promise = uploadFile(matrixClient, roomId, file, onProgress);
matrixClient, roomId, file, onProgress,
);
return upload.promise.then(function(result) { return upload.promise.then(function(result) {
content.file = result.file; content.file = result.file;
content.url = result.url; content.url = result.url;

View file

@ -364,8 +364,8 @@ export default class CountlyAnalytics {
private initTime = CountlyAnalytics.getTimestamp(); private initTime = CountlyAnalytics.getTimestamp();
private firstPage = true; private firstPage = true;
private heartbeatIntervalId: NodeJS.Timeout; private heartbeatIntervalId: number;
private activityIntervalId: NodeJS.Timeout; private activityIntervalId: number;
private trackTime = true; private trackTime = true;
private lastBeat: number; private lastBeat: number;
private storedDuration = 0; private storedDuration = 0;

View file

@ -46,8 +46,8 @@ export class DecryptionFailureTracker {
}; };
// Set to an interval ID when `start` is called // Set to an interval ID when `start` is called
public checkInterval: NodeJS.Timeout = null; public checkInterval: number = null;
public trackInterval: NodeJS.Timeout = null; public trackInterval: number = null;
// Spread the load on `Analytics` by tracking at a low frequency, `TRACK_INTERVAL_MS`. // Spread the load on `Analytics` by tracking at a low frequency, `TRACK_INTERVAL_MS`.
static TRACK_INTERVAL_MS = 60000; static TRACK_INTERVAL_MS = 60000;

View file

@ -25,7 +25,6 @@ import _linkifyElement from 'linkifyjs/element';
import _linkifyString from 'linkifyjs/string'; import _linkifyString from 'linkifyjs/string';
import classNames from 'classnames'; import classNames from 'classnames';
import EMOJIBASE_REGEX from 'emojibase-regex'; import EMOJIBASE_REGEX from 'emojibase-regex';
import url from 'url';
import katex from 'katex'; import katex from 'katex';
import { AllHtmlEntities } from 'html-entities'; import { AllHtmlEntities } from 'html-entities';
import { IContent } from 'matrix-js-sdk/src/models/event'; import { IContent } from 'matrix-js-sdk/src/models/event';
@ -153,10 +152,8 @@ export function getHtmlText(insaneHtml: string): string {
*/ */
export function isUrlPermitted(inputUrl: string): boolean { export function isUrlPermitted(inputUrl: string): boolean {
try { try {
const parsed = url.parse(inputUrl);
if (!parsed.protocol) return false;
// URL parser protocol includes the trailing colon // URL parser protocol includes the trailing colon
return PERMITTED_URL_SCHEMES.includes(parsed.protocol.slice(0, -1)); return PERMITTED_URL_SCHEMES.includes(new URL(inputUrl).protocol.slice(0, -1));
} catch (e) { } catch (e) {
return false; return false;
} }

View file

@ -127,7 +127,7 @@ export default class IdentityAuthClient {
await this._matrixClient.getIdentityAccount(token); await this._matrixClient.getIdentityAccount(token);
} catch (e) { } catch (e) {
if (e.errcode === "M_TERMS_NOT_SIGNED") { if (e.errcode === "M_TERMS_NOT_SIGNED") {
console.log("Identity Server requires new terms to be agreed to"); console.log("Identity server requires new terms to be agreed to");
await startTermsFlow([new Service( await startTermsFlow([new Service(
SERVICE_TYPES.IS, SERVICE_TYPES.IS,
identityServerUrl, identityServerUrl,

View file

@ -21,6 +21,7 @@ import { createClient } from 'matrix-js-sdk/src/matrix';
import { InvalidStoreError } from "matrix-js-sdk/src/errors"; import { InvalidStoreError } from "matrix-js-sdk/src/errors";
import { MatrixClient } from "matrix-js-sdk/src/client"; import { MatrixClient } from "matrix-js-sdk/src/client";
import { decryptAES, encryptAES, IEncryptedPayload } from "matrix-js-sdk/src/crypto/aes"; import { decryptAES, encryptAES, IEncryptedPayload } from "matrix-js-sdk/src/crypto/aes";
import { QueryDict } from 'matrix-js-sdk/src/utils';
import { IMatrixClientCreds, MatrixClientPeg } from './MatrixClientPeg'; import { IMatrixClientCreds, MatrixClientPeg } from './MatrixClientPeg';
import SecurityCustomisations from "./customisations/Security"; import SecurityCustomisations from "./customisations/Security";
@ -65,7 +66,7 @@ interface ILoadSessionOpts {
guestIsUrl?: string; guestIsUrl?: string;
ignoreGuest?: boolean; ignoreGuest?: boolean;
defaultDeviceDisplayName?: string; defaultDeviceDisplayName?: string;
fragmentQueryParams?: Record<string, string>; fragmentQueryParams?: QueryDict;
} }
/** /**
@ -118,8 +119,8 @@ export async function loadSession(opts: ILoadSessionOpts = {}): Promise<boolean>
) { ) {
console.log("Using guest access credentials"); console.log("Using guest access credentials");
return doSetLoggedIn({ return doSetLoggedIn({
userId: fragmentQueryParams.guest_user_id, userId: fragmentQueryParams.guest_user_id as string,
accessToken: fragmentQueryParams.guest_access_token, accessToken: fragmentQueryParams.guest_access_token as string,
homeserverUrl: guestHsUrl, homeserverUrl: guestHsUrl,
identityServerUrl: guestIsUrl, identityServerUrl: guestIsUrl,
guest: true, guest: true,
@ -173,7 +174,7 @@ export async function getStoredSessionOwner(): Promise<[string, boolean]> {
* login, else false * login, else false
*/ */
export function attemptTokenLogin( export function attemptTokenLogin(
queryParams: Record<string, string>, queryParams: QueryDict,
defaultDeviceDisplayName?: string, defaultDeviceDisplayName?: string,
fragmentAfterLogin?: string, fragmentAfterLogin?: string,
): Promise<boolean> { ): Promise<boolean> {
@ -198,7 +199,7 @@ export function attemptTokenLogin(
homeserver, homeserver,
identityServer, identityServer,
"m.login.token", { "m.login.token", {
token: queryParams.loginToken, token: queryParams.loginToken as string,
initial_device_display_name: defaultDeviceDisplayName, initial_device_display_name: defaultDeviceDisplayName,
}, },
).then(function(creds) { ).then(function(creds) {

View file

@ -17,8 +17,8 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { ICreateClientOpts } from 'matrix-js-sdk/src/matrix'; import { ICreateClientOpts, PendingEventOrdering } from 'matrix-js-sdk/src/matrix';
import { MatrixClient } from 'matrix-js-sdk/src/client'; import { IStartClientOpts, MatrixClient } from 'matrix-js-sdk/src/client';
import { MemoryStore } from 'matrix-js-sdk/src/store/memory'; import { MemoryStore } from 'matrix-js-sdk/src/store/memory';
import * as utils from 'matrix-js-sdk/src/utils'; import * as utils from 'matrix-js-sdk/src/utils';
import { EventTimeline } from 'matrix-js-sdk/src/models/event-timeline'; import { EventTimeline } from 'matrix-js-sdk/src/models/event-timeline';
@ -47,25 +47,8 @@ export interface IMatrixClientCreds {
freshLogin?: boolean; freshLogin?: boolean;
} }
// TODO: Move this to the js-sdk
export interface IOpts {
initialSyncLimit?: number;
pendingEventOrdering?: "detached" | "chronological";
lazyLoadMembers?: boolean;
clientWellKnownPollPeriod?: number;
}
export interface IMatrixClientPeg { export interface IMatrixClientPeg {
opts: IOpts; opts: IStartClientOpts;
/**
* Sets the script href passed to the IndexedDB web worker
* If set, a separate web worker will be started to run the IndexedDB
* queries on.
*
* @param {string} script href to the script to be passed to the web worker
*/
setIndexedDbWorkerScript(script: string): void;
/** /**
* Return the server name of the user's homeserver * Return the server name of the user's homeserver
@ -127,7 +110,7 @@ class _MatrixClientPeg implements IMatrixClientPeg {
// client is started in 'start'. These can be altered // client is started in 'start'. These can be altered
// at any time up to after the 'will_start_client' // at any time up to after the 'will_start_client'
// event is finished processing. // event is finished processing.
public opts: IOpts = { public opts: IStartClientOpts = {
initialSyncLimit: 20, initialSyncLimit: 20,
}; };
@ -141,10 +124,6 @@ class _MatrixClientPeg implements IMatrixClientPeg {
constructor() { constructor() {
} }
public setIndexedDbWorkerScript(script: string): void {
createMatrixClient.indexedDbWorkerScript = script;
}
public get(): MatrixClient { public get(): MatrixClient {
return this.matrixClient; return this.matrixClient;
} }
@ -231,7 +210,7 @@ class _MatrixClientPeg implements IMatrixClientPeg {
const opts = utils.deepCopy(this.opts); const opts = utils.deepCopy(this.opts);
// the react sdk doesn't work without this, so don't allow // the react sdk doesn't work without this, so don't allow
opts.pendingEventOrdering = "detached"; opts.pendingEventOrdering = PendingEventOrdering.Detached;
opts.lazyLoadMembers = true; opts.lazyLoadMembers = true;
opts.clientWellKnownPollPeriod = 2 * 60 * 60; // 2 hours opts.clientWellKnownPollPeriod = 2 * 60 * 60; // 2 hours

View file

@ -328,7 +328,7 @@ export const Notifier = {
onEvent: function(ev: MatrixEvent) { onEvent: function(ev: MatrixEvent) {
if (!this.isSyncing) return; // don't alert for any messages initially if (!this.isSyncing) return; // don't alert for any messages initially
if (ev.sender && ev.sender.userId === MatrixClientPeg.get().credentials.userId) return; if (ev.getSender() === MatrixClientPeg.get().credentials.userId) return;
MatrixClientPeg.get().decryptEventIfNeeded(ev); MatrixClientPeg.get().decryptEventIfNeeded(ev);

View file

@ -17,6 +17,7 @@ limitations under the License.
import { Room } from "matrix-js-sdk/src/models/room"; import { Room } from "matrix-js-sdk/src/models/room";
import { MatrixClientPeg } from './MatrixClientPeg'; import { MatrixClientPeg } from './MatrixClientPeg';
import AliasCustomisations from './customisations/Alias';
/** /**
* Given a room object, return the alias we should use for it, * Given a room object, return the alias we should use for it,
@ -28,7 +29,18 @@ import { MatrixClientPeg } from './MatrixClientPeg';
* @returns {string} A display alias for the given room * @returns {string} A display alias for the given room
*/ */
export function getDisplayAliasForRoom(room: Room): string { export function getDisplayAliasForRoom(room: Room): string {
return room.getCanonicalAlias() || room.getAltAliases()[0]; return getDisplayAliasForAliasSet(
room.getCanonicalAlias(), room.getAltAliases(),
);
}
// The various display alias getters should all feed through this one path so
// there's a single place to change the logic.
export function getDisplayAliasForAliasSet(canonicalAlias: string, altAliases: string[]): string {
if (AliasCustomisations.getDisplayAliasForAliasSet) {
return AliasCustomisations.getDisplayAliasForAliasSet(canonicalAlias, altAliases);
}
return canonicalAlias || altAliases?.[0];
} }
export function looksLikeDirectMessageRoom(room: Room, myUserId: string): boolean { export function looksLikeDirectMessageRoom(room: Room, myUserId: string): boolean {
@ -72,10 +84,8 @@ export function guessAndSetDMRoom(room: Room, isDirect: boolean): Promise<void>
this room as a DM room this room as a DM room
* @returns {object} A promise * @returns {object} A promise
*/ */
export function setDMRoom(roomId: string, userId: string): Promise<void> { export async function setDMRoom(roomId: string, userId: string): Promise<void> {
if (MatrixClientPeg.get().isGuest()) { if (MatrixClientPeg.get().isGuest()) return;
return Promise.resolve();
}
const mDirectEvent = MatrixClientPeg.get().getAccountData('m.direct'); const mDirectEvent = MatrixClientPeg.get().getAccountData('m.direct');
let dmRoomMap = {}; let dmRoomMap = {};
@ -104,7 +114,7 @@ export function setDMRoom(roomId: string, userId: string): Promise<void> {
dmRoomMap[userId] = roomList; dmRoomMap[userId] = roomList;
} }
return MatrixClientPeg.get().setAccountData('m.direct', dmRoomMap); await MatrixClientPeg.get().setAccountData('m.direct', dmRoomMap);
} }
/** /**

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2019 The Matrix.org Foundation C.I.C. Copyright 2019 - 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -14,26 +14,42 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {
IResultRoomEvents,
ISearchRequestBody,
ISearchResponse,
ISearchResult,
ISearchResults,
SearchOrderBy,
} from "matrix-js-sdk/src/@types/search";
import { IRoomEventFilter } from "matrix-js-sdk/src/filter";
import { EventType } from "matrix-js-sdk/src/@types/event";
import { ISearchArgs } from "./indexing/BaseEventIndexManager";
import EventIndexPeg from "./indexing/EventIndexPeg"; import EventIndexPeg from "./indexing/EventIndexPeg";
import { MatrixClientPeg } from "./MatrixClientPeg"; import { MatrixClientPeg } from "./MatrixClientPeg";
import { SearchResult } from "matrix-js-sdk/src/models/search-result";
const SEARCH_LIMIT = 10; const SEARCH_LIMIT = 10;
async function serverSideSearch(term, roomId = undefined) { async function serverSideSearch(
term: string,
roomId: string = undefined,
): Promise<{ response: ISearchResponse, query: ISearchRequestBody }> {
const client = MatrixClientPeg.get(); const client = MatrixClientPeg.get();
const filter = { const filter: IRoomEventFilter = {
limit: SEARCH_LIMIT, limit: SEARCH_LIMIT,
}; };
if (roomId !== undefined) filter.rooms = [roomId]; if (roomId !== undefined) filter.rooms = [roomId];
const body = { const body: ISearchRequestBody = {
search_categories: { search_categories: {
room_events: { room_events: {
search_term: term, search_term: term,
filter: filter, filter: filter,
order_by: "recent", order_by: SearchOrderBy.Recent,
event_context: { event_context: {
before_limit: 1, before_limit: 1,
after_limit: 1, after_limit: 1,
@ -45,31 +61,26 @@ async function serverSideSearch(term, roomId = undefined) {
const response = await client.search({ body: body }); const response = await client.search({ body: body });
const result = { return { response, query: body };
response: response,
query: body,
};
return result;
} }
async function serverSideSearchProcess(term, roomId = undefined) { async function serverSideSearchProcess(term: string, roomId: string = undefined): Promise<ISearchResults> {
const client = MatrixClientPeg.get(); const client = MatrixClientPeg.get();
const result = await serverSideSearch(term, roomId); const result = await serverSideSearch(term, roomId);
// The js-sdk method backPaginateRoomEventsSearch() uses _query internally // The js-sdk method backPaginateRoomEventsSearch() uses _query internally
// so we're reusing the concept here since we wan't to delegate the // so we're reusing the concept here since we want to delegate the
// pagination back to backPaginateRoomEventsSearch() in some cases. // pagination back to backPaginateRoomEventsSearch() in some cases.
const searchResult = { const searchResults: ISearchResults = {
_query: result.query, _query: result.query,
results: [], results: [],
highlights: [], highlights: [],
}; };
return client.processRoomEventsSearch(searchResult, result.response); return client.processRoomEventsSearch(searchResults, result.response);
} }
function compareEvents(a, b) { function compareEvents(a: ISearchResult, b: ISearchResult): number {
const aEvent = a.result; const aEvent = a.result;
const bEvent = b.result; const bEvent = b.result;
@ -79,7 +90,7 @@ function compareEvents(a, b) {
return 0; return 0;
} }
async function combinedSearch(searchTerm) { async function combinedSearch(searchTerm: string): Promise<ISearchResults> {
const client = MatrixClientPeg.get(); const client = MatrixClientPeg.get();
// Create two promises, one for the local search, one for the // Create two promises, one for the local search, one for the
@ -111,10 +122,10 @@ async function combinedSearch(searchTerm) {
// returns since that one can be either a server-side one, a local one or a // returns since that one can be either a server-side one, a local one or a
// fake one to fetch the remaining cached events. See the docs for // fake one to fetch the remaining cached events. See the docs for
// combineEvents() for an explanation why we need to cache events. // combineEvents() for an explanation why we need to cache events.
const emptyResult = { const emptyResult: ISeshatSearchResults = {
seshatQuery: localQuery, seshatQuery: localQuery,
_query: serverQuery, _query: serverQuery,
serverSideNextBatch: serverResponse.next_batch, serverSideNextBatch: serverResponse.search_categories.room_events.next_batch,
cachedEvents: [], cachedEvents: [],
oldestEventFrom: "server", oldestEventFrom: "server",
results: [], results: [],
@ -125,7 +136,7 @@ async function combinedSearch(searchTerm) {
const combinedResult = combineResponses(emptyResult, localResponse, serverResponse.search_categories.room_events); const combinedResult = combineResponses(emptyResult, localResponse, serverResponse.search_categories.room_events);
// Let the client process the combined result. // Let the client process the combined result.
const response = { const response: ISearchResponse = {
search_categories: { search_categories: {
room_events: combinedResult, room_events: combinedResult,
}, },
@ -139,10 +150,14 @@ async function combinedSearch(searchTerm) {
return result; return result;
} }
async function localSearch(searchTerm, roomId = undefined, processResult = true) { async function localSearch(
searchTerm: string,
roomId: string = undefined,
processResult = true,
): Promise<{ response: IResultRoomEvents, query: ISearchArgs }> {
const eventIndex = EventIndexPeg.get(); const eventIndex = EventIndexPeg.get();
const searchArgs = { const searchArgs: ISearchArgs = {
search_term: searchTerm, search_term: searchTerm,
before_limit: 1, before_limit: 1,
after_limit: 1, after_limit: 1,
@ -167,11 +182,18 @@ async function localSearch(searchTerm, roomId = undefined, processResult = true)
return result; return result;
} }
async function localSearchProcess(searchTerm, roomId = undefined) { export interface ISeshatSearchResults extends ISearchResults {
seshatQuery?: ISearchArgs;
cachedEvents?: ISearchResult[];
oldestEventFrom?: "local" | "server";
serverSideNextBatch?: string;
}
async function localSearchProcess(searchTerm: string, roomId: string = undefined): Promise<ISeshatSearchResults> {
const emptyResult = { const emptyResult = {
results: [], results: [],
highlights: [], highlights: [],
}; } as ISeshatSearchResults;
if (searchTerm === "") return emptyResult; if (searchTerm === "") return emptyResult;
@ -179,7 +201,7 @@ async function localSearchProcess(searchTerm, roomId = undefined) {
emptyResult.seshatQuery = result.query; emptyResult.seshatQuery = result.query;
const response = { const response: ISearchResponse = {
search_categories: { search_categories: {
room_events: result.response, room_events: result.response,
}, },
@ -192,7 +214,7 @@ async function localSearchProcess(searchTerm, roomId = undefined) {
return processedResult; return processedResult;
} }
async function localPagination(searchResult) { async function localPagination(searchResult: ISeshatSearchResults): Promise<ISeshatSearchResults> {
const eventIndex = EventIndexPeg.get(); const eventIndex = EventIndexPeg.get();
const searchArgs = searchResult.seshatQuery; const searchArgs = searchResult.seshatQuery;
@ -221,10 +243,10 @@ async function localPagination(searchResult) {
return result; return result;
} }
function compareOldestEvents(firstResults, secondResults) { function compareOldestEvents(firstResults: ISearchResult[], secondResults: ISearchResult[]): number {
try { try {
const oldestFirstEvent = firstResults.results[firstResults.results.length - 1].result; const oldestFirstEvent = firstResults[firstResults.length - 1].result;
const oldestSecondEvent = secondResults.results[secondResults.results.length - 1].result; const oldestSecondEvent = secondResults[secondResults.length - 1].result;
if (oldestFirstEvent.origin_server_ts <= oldestSecondEvent.origin_server_ts) { if (oldestFirstEvent.origin_server_ts <= oldestSecondEvent.origin_server_ts) {
return -1; return -1;
@ -236,7 +258,12 @@ function compareOldestEvents(firstResults, secondResults) {
} }
} }
function combineEventSources(previousSearchResult, response, a, b) { function combineEventSources(
previousSearchResult: ISeshatSearchResults,
response: IResultRoomEvents,
a: ISearchResult[],
b: ISearchResult[],
): void {
// Merge event sources and sort the events. // Merge event sources and sort the events.
const combinedEvents = a.concat(b).sort(compareEvents); const combinedEvents = a.concat(b).sort(compareEvents);
// Put half of the events in the response, and cache the other half. // Put half of the events in the response, and cache the other half.
@ -353,8 +380,12 @@ function combineEventSources(previousSearchResult, response, a, b) {
* different event sources. * different event sources.
* *
*/ */
function combineEvents(previousSearchResult, localEvents = undefined, serverEvents = undefined) { function combineEvents(
const response = {}; previousSearchResult: ISeshatSearchResults,
localEvents: IResultRoomEvents = undefined,
serverEvents: IResultRoomEvents = undefined,
): IResultRoomEvents {
const response = {} as IResultRoomEvents;
const cachedEvents = previousSearchResult.cachedEvents; const cachedEvents = previousSearchResult.cachedEvents;
let oldestEventFrom = previousSearchResult.oldestEventFrom; let oldestEventFrom = previousSearchResult.oldestEventFrom;
@ -364,7 +395,7 @@ function combineEvents(previousSearchResult, localEvents = undefined, serverEven
// This is a first search call, combine the events from the server and // This is a first search call, combine the events from the server and
// the local index. Note where our oldest event came from, we shall // the local index. Note where our oldest event came from, we shall
// fetch the next batch of events from the other source. // fetch the next batch of events from the other source.
if (compareOldestEvents(localEvents, serverEvents) < 0) { if (compareOldestEvents(localEvents.results, serverEvents.results) < 0) {
oldestEventFrom = "local"; oldestEventFrom = "local";
} }
@ -375,7 +406,7 @@ function combineEvents(previousSearchResult, localEvents = undefined, serverEven
// meaning that our oldest event was on the server. // meaning that our oldest event was on the server.
// Change the source of the oldest event if our local event is older // Change the source of the oldest event if our local event is older
// than the cached one. // than the cached one.
if (compareOldestEvents(localEvents, cachedEvents) < 0) { if (compareOldestEvents(localEvents.results, cachedEvents) < 0) {
oldestEventFrom = "local"; oldestEventFrom = "local";
} }
combineEventSources(previousSearchResult, response, localEvents.results, cachedEvents); combineEventSources(previousSearchResult, response, localEvents.results, cachedEvents);
@ -384,7 +415,7 @@ function combineEvents(previousSearchResult, localEvents = undefined, serverEven
// meaning that our oldest event was in the local index. // meaning that our oldest event was in the local index.
// Change the source of the oldest event if our server event is older // Change the source of the oldest event if our server event is older
// than the cached one. // than the cached one.
if (compareOldestEvents(serverEvents, cachedEvents) < 0) { if (compareOldestEvents(serverEvents.results, cachedEvents) < 0) {
oldestEventFrom = "server"; oldestEventFrom = "server";
} }
combineEventSources(previousSearchResult, response, serverEvents.results, cachedEvents); combineEventSources(previousSearchResult, response, serverEvents.results, cachedEvents);
@ -412,7 +443,11 @@ function combineEvents(previousSearchResult, localEvents = undefined, serverEven
* @return {object} A response object that combines the events from the * @return {object} A response object that combines the events from the
* different event sources. * different event sources.
*/ */
function combineResponses(previousSearchResult, localEvents = undefined, serverEvents = undefined) { function combineResponses(
previousSearchResult: ISeshatSearchResults,
localEvents: IResultRoomEvents = undefined,
serverEvents: IResultRoomEvents = undefined,
): IResultRoomEvents {
// Combine our events first. // Combine our events first.
const response = combineEvents(previousSearchResult, localEvents, serverEvents); const response = combineEvents(previousSearchResult, localEvents, serverEvents);
@ -454,42 +489,51 @@ function combineResponses(previousSearchResult, localEvents = undefined, serverE
return response; return response;
} }
function restoreEncryptionInfo(searchResultSlice = []) { interface IEncryptedSeshatEvent {
curve25519Key: string;
ed25519Key: string;
algorithm: string;
forwardingCurve25519KeyChain: string[];
}
function restoreEncryptionInfo(searchResultSlice: SearchResult[] = []): void {
for (let i = 0; i < searchResultSlice.length; i++) { for (let i = 0; i < searchResultSlice.length; i++) {
const timeline = searchResultSlice[i].context.getTimeline(); const timeline = searchResultSlice[i].context.getTimeline();
for (let j = 0; j < timeline.length; j++) { for (let j = 0; j < timeline.length; j++) {
const ev = timeline[j]; const mxEv = timeline[j];
const ev = mxEv.event as IEncryptedSeshatEvent;
if (ev.event.curve25519Key) { if (ev.curve25519Key) {
ev.makeEncrypted( mxEv.makeEncrypted(
"m.room.encrypted", EventType.RoomMessageEncrypted,
{ algorithm: ev.event.algorithm }, { algorithm: ev.algorithm },
ev.event.curve25519Key, ev.curve25519Key,
ev.event.ed25519Key, ev.ed25519Key,
); );
ev.forwardingCurve25519KeyChain = ev.event.forwardingCurve25519KeyChain; // @ts-ignore
mxEv.forwardingCurve25519KeyChain = ev.forwardingCurve25519KeyChain;
delete ev.event.curve25519Key; delete ev.curve25519Key;
delete ev.event.ed25519Key; delete ev.ed25519Key;
delete ev.event.algorithm; delete ev.algorithm;
delete ev.event.forwardingCurve25519KeyChain; delete ev.forwardingCurve25519KeyChain;
} }
} }
} }
} }
async function combinedPagination(searchResult) { async function combinedPagination(searchResult: ISeshatSearchResults): Promise<ISeshatSearchResults> {
const eventIndex = EventIndexPeg.get(); const eventIndex = EventIndexPeg.get();
const client = MatrixClientPeg.get(); const client = MatrixClientPeg.get();
const searchArgs = searchResult.seshatQuery; const searchArgs = searchResult.seshatQuery;
const oldestEventFrom = searchResult.oldestEventFrom; const oldestEventFrom = searchResult.oldestEventFrom;
let localResult; let localResult: IResultRoomEvents;
let serverSideResult; let serverSideResult: ISearchResponse;
// Fetch events from the local index if we have a token for itand if it's // Fetch events from the local index if we have a token for it and if it's
// the local indexes turn or the server has exhausted its results. // the local indexes turn or the server has exhausted its results.
if (searchArgs.next_batch && (!searchResult.serverSideNextBatch || oldestEventFrom === "server")) { if (searchArgs.next_batch && (!searchResult.serverSideNextBatch || oldestEventFrom === "server")) {
localResult = await eventIndex.search(searchArgs); localResult = await eventIndex.search(searchArgs);
@ -502,7 +546,7 @@ async function combinedPagination(searchResult) {
serverSideResult = await client.search(body); serverSideResult = await client.search(body);
} }
let serverEvents; let serverEvents: IResultRoomEvents;
if (serverSideResult) { if (serverSideResult) {
serverEvents = serverSideResult.search_categories.room_events; serverEvents = serverSideResult.search_categories.room_events;
@ -532,8 +576,8 @@ async function combinedPagination(searchResult) {
return result; return result;
} }
function eventIndexSearch(term, roomId = undefined) { function eventIndexSearch(term: string, roomId: string = undefined): Promise<ISearchResults> {
let searchPromise; let searchPromise: Promise<ISearchResults>;
if (roomId !== undefined) { if (roomId !== undefined) {
if (MatrixClientPeg.get().isRoomEncrypted(roomId)) { if (MatrixClientPeg.get().isRoomEncrypted(roomId)) {
@ -554,7 +598,7 @@ function eventIndexSearch(term, roomId = undefined) {
return searchPromise; return searchPromise;
} }
function eventIndexSearchPagination(searchResult) { function eventIndexSearchPagination(searchResult: ISeshatSearchResults): Promise<ISeshatSearchResults> {
const client = MatrixClientPeg.get(); const client = MatrixClientPeg.get();
const seshatQuery = searchResult.seshatQuery; const seshatQuery = searchResult.seshatQuery;
@ -580,7 +624,7 @@ function eventIndexSearchPagination(searchResult) {
} }
} }
export function searchPagination(searchResult) { export function searchPagination(searchResult: ISearchResults): Promise<ISearchResults> {
const eventIndex = EventIndexPeg.get(); const eventIndex = EventIndexPeg.get();
const client = MatrixClientPeg.get(); const client = MatrixClientPeg.get();
@ -590,7 +634,7 @@ export function searchPagination(searchResult) {
else return eventIndexSearchPagination(searchResult); else return eventIndexSearchPagination(searchResult);
} }
export default function eventSearch(term, roomId = undefined) { export default function eventSearch(term: string, roomId: string = undefined): Promise<ISearchResults> {
const eventIndex = EventIndexPeg.get(); const eventIndex = EventIndexPeg.get();
if (eventIndex === null) return serverSideSearchProcess(term, roomId); if (eventIndex === null) return serverSideSearchProcess(term, roomId);

View file

@ -13,7 +13,6 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import { MatrixClientPeg } from './MatrixClientPeg'; import { MatrixClientPeg } from './MatrixClientPeg';
import { _t } from './languageHandler'; import { _t } from './languageHandler';
@ -32,7 +31,7 @@ import { MatrixEvent } from "matrix-js-sdk/src/models/event";
// any text to display at all. For this reason they return deferred values // 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. // to avoid the expense of looking up translations when they're not needed.
function textForMemberEvent(ev): () => string | null { function textForMemberEvent(ev: MatrixEvent, allowJSX: boolean, showHiddenEvents?: boolean): () => string | null {
// XXX: SYJS-16 "sender is sometimes null for join messages" // XXX: SYJS-16 "sender is sometimes null for join messages"
const senderName = ev.sender ? ev.sender.name : ev.getSender(); const senderName = ev.sender ? ev.sender.name : ev.getSender();
const targetName = ev.target ? ev.target.name : ev.getStateKey(); const targetName = ev.target ? ev.target.name : ev.getStateKey();
@ -84,7 +83,7 @@ function textForMemberEvent(ev): () => string | null {
return () => _t('%(senderName)s changed their profile picture', { senderName }); return () => _t('%(senderName)s changed their profile picture', { senderName });
} else if (!prevContent.avatar_url && content.avatar_url) { } else if (!prevContent.avatar_url && content.avatar_url) {
return () => _t('%(senderName)s set a profile picture', { senderName }); 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) // This is a null rejoin, it will only be visible if using 'show hidden events' (labs)
return () => _t("%(senderName)s made no change", { senderName }); return () => _t("%(senderName)s made no change", { senderName });
} else { } else {
@ -127,7 +126,7 @@ function textForMemberEvent(ev): () => string | null {
} }
} }
function textForTopicEvent(ev): () => string | null { function textForTopicEvent(ev: MatrixEvent): () => string | null {
const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
return () => _t('%(senderDisplayName)s changed the topic to "%(topic)s".', { return () => _t('%(senderDisplayName)s changed the topic to "%(topic)s".', {
senderDisplayName, senderDisplayName,
@ -140,7 +139,7 @@ function textForRoomAvatarEvent(ev: MatrixEvent): () => string | null {
return () => _t('%(senderDisplayName)s changed the room avatar.', { senderDisplayName }); return () => _t('%(senderDisplayName)s changed the room avatar.', { senderDisplayName });
} }
function textForRoomNameEvent(ev): () => string | null { function textForRoomNameEvent(ev: MatrixEvent): () => string | null {
const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
if (!ev.getContent().name || ev.getContent().name.trim().length === 0) { if (!ev.getContent().name || ev.getContent().name.trim().length === 0) {
@ -159,12 +158,12 @@ function textForRoomNameEvent(ev): () => string | null {
}); });
} }
function textForTombstoneEvent(ev): () => string | null { function textForTombstoneEvent(ev: MatrixEvent): () => string | null {
const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
return () => _t('%(senderDisplayName)s upgraded this room.', { senderDisplayName }); return () => _t('%(senderDisplayName)s upgraded this room.', { senderDisplayName });
} }
function textForJoinRulesEvent(ev): () => string | null { function textForJoinRulesEvent(ev: MatrixEvent): () => string | null {
const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
switch (ev.getContent().join_rule) { switch (ev.getContent().join_rule) {
case "public": case "public":
@ -184,7 +183,7 @@ function textForJoinRulesEvent(ev): () => string | null {
} }
} }
function textForGuestAccessEvent(ev): () => string | null { function textForGuestAccessEvent(ev: MatrixEvent): () => string | null {
const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
switch (ev.getContent().guest_access) { switch (ev.getContent().guest_access) {
case "can_join": case "can_join":
@ -200,7 +199,7 @@ function textForGuestAccessEvent(ev): () => string | null {
} }
} }
function textForRelatedGroupsEvent(ev): () => string | null { function textForRelatedGroupsEvent(ev: MatrixEvent): () => string | null {
const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
const groups = ev.getContent().groups || []; const groups = ev.getContent().groups || [];
const prevGroups = ev.getPrevContent().groups || []; const prevGroups = ev.getPrevContent().groups || [];
@ -230,7 +229,7 @@ function textForRelatedGroupsEvent(ev): () => string | null {
} }
} }
function textForServerACLEvent(ev): () => string | null { function textForServerACLEvent(ev: MatrixEvent): () => string | null {
const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
const prevContent = ev.getPrevContent(); const prevContent = ev.getPrevContent();
const current = ev.getContent(); const current = ev.getContent();
@ -285,7 +284,7 @@ function textForMessageEvent(ev: MatrixEvent): () => string | null {
}; };
} }
function textForCanonicalAliasEvent(ev): () => string | null { function textForCanonicalAliasEvent(ev: MatrixEvent): () => string | null {
const senderName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); const senderName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
const oldAlias = ev.getPrevContent().alias; const oldAlias = ev.getPrevContent().alias;
const oldAltAliases = ev.getPrevContent().alt_aliases || []; const oldAltAliases = ev.getPrevContent().alt_aliases || [];
@ -336,7 +335,7 @@ function textForCanonicalAliasEvent(ev): () => string | null {
}); });
} }
function textForCallAnswerEvent(event): () => string | null { function textForCallAnswerEvent(event: MatrixEvent): () => string | null {
return () => { return () => {
const senderName = event.sender ? event.sender.name : _t('Someone'); const senderName = event.sender ? event.sender.name : _t('Someone');
const supported = MatrixClientPeg.get().supportsVoip() ? '' : _t('(not supported by this browser)'); const supported = MatrixClientPeg.get().supportsVoip() ? '' : _t('(not supported by this browser)');
@ -344,7 +343,7 @@ function textForCallAnswerEvent(event): () => string | null {
}; };
} }
function textForCallHangupEvent(event): () => string | null { function textForCallHangupEvent(event: MatrixEvent): () => string | null {
const getSenderName = () => event.sender ? event.sender.name : _t('Someone'); const getSenderName = () => event.sender ? event.sender.name : _t('Someone');
const eventContent = event.getContent(); const eventContent = event.getContent();
let getReason = () => ""; let getReason = () => "";
@ -381,14 +380,14 @@ function textForCallHangupEvent(event): () => string | null {
return () => _t('%(senderName)s ended the call.', { senderName: getSenderName() }) + ' ' + getReason(); return () => _t('%(senderName)s ended the call.', { senderName: getSenderName() }) + ' ' + getReason();
} }
function textForCallRejectEvent(event): () => string | null { function textForCallRejectEvent(event: MatrixEvent): () => string | null {
return () => { return () => {
const senderName = event.sender ? event.sender.name : _t('Someone'); const senderName = event.sender ? event.sender.name : _t('Someone');
return _t('%(senderName)s declined the call.', { senderName }); return _t('%(senderName)s declined the call.', { senderName });
}; };
} }
function textForCallInviteEvent(event): () => string | null { function textForCallInviteEvent(event: MatrixEvent): () => string | null {
const getSenderName = () => event.sender ? event.sender.name : _t('Someone'); const getSenderName = () => event.sender ? event.sender.name : _t('Someone');
// FIXME: Find a better way to determine this from the event? // FIXME: Find a better way to determine this from the event?
let isVoice = true; let isVoice = true;
@ -420,7 +419,7 @@ function textForCallInviteEvent(event): () => string | null {
} }
} }
function textForThreePidInviteEvent(event): () => string | null { function textForThreePidInviteEvent(event: MatrixEvent): () => string | null {
const senderName = event.sender ? event.sender.name : event.getSender(); const senderName = event.sender ? event.sender.name : event.getSender();
if (!isValid3pidInvite(event)) { if (!isValid3pidInvite(event)) {
@ -436,7 +435,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(); const senderName = event.sender ? event.sender.name : event.getSender();
switch (event.getContent().history_visibility) { switch (event.getContent().history_visibility) {
case 'invited': case 'invited':
@ -458,13 +457,14 @@ function textForHistoryVisibilityEvent(event): () => string | null {
} }
// Currently will only display a change if a user's power level is changed // 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(); const senderName = event.sender ? event.sender.name : event.getSender();
if (!event.getPrevContent() || !event.getPrevContent().users || if (!event.getPrevContent() || !event.getPrevContent().users ||
!event.getContent() || !event.getContent().users) { !event.getContent() || !event.getContent().users) {
return null; return null;
} }
const userDefault = event.getContent().users_default || 0; const previousUserDefault = event.getPrevContent().users_default || 0;
const currentUserDefault = event.getContent().users_default || 0;
// Construct set of userIds // Construct set of userIds
const users = []; const users = [];
Object.keys(event.getContent().users).forEach( Object.keys(event.getContent().users).forEach(
@ -480,9 +480,16 @@ function textForPowerEvent(event): () => string | null {
const diffs = []; const diffs = [];
users.forEach((userId) => { users.forEach((userId) => {
// Previous power level // Previous power level
const from = event.getPrevContent().users[userId]; let from = event.getPrevContent().users[userId];
if (!Number.isInteger(from)) {
from = previousUserDefault;
}
// Current power level // Current power level
const to = event.getContent().users[userId]; let to = event.getContent().users[userId];
if (!Number.isInteger(to)) {
to = currentUserDefault;
}
if (from === previousUserDefault && to === currentUserDefault) { return; }
if (to !== from) { if (to !== from) {
diffs.push({ userId, from, to }); diffs.push({ userId, from, to });
} }
@ -496,8 +503,8 @@ function textForPowerEvent(event): () => string | null {
powerLevelDiffText: diffs.map(diff => powerLevelDiffText: diffs.map(diff =>
_t('%(userId)s from %(fromPowerLevel)s to %(toPowerLevel)s', { _t('%(userId)s from %(fromPowerLevel)s to %(toPowerLevel)s', {
userId: diff.userId, userId: diff.userId,
fromPowerLevel: Roles.textualPowerLevel(diff.from, userDefault), fromPowerLevel: Roles.textualPowerLevel(diff.from, previousUserDefault),
toPowerLevel: Roles.textualPowerLevel(diff.to, userDefault), toPowerLevel: Roles.textualPowerLevel(diff.to, currentUserDefault),
}), }),
).join(", "), ).join(", "),
}); });
@ -532,7 +539,7 @@ function textForPinnedEvent(event: MatrixEvent, allowJSX: boolean): () => string
return () => _t("%(senderName)s changed the pinned messages for the room.", { senderName }); 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 senderName = event.getSender();
const { name: prevName, type: prevType, url: prevUrl } = event.getPrevContent(); const { name: prevName, type: prevType, url: prevUrl } = event.getPrevContent();
const { name, type, url } = event.getContent() || {}; const { name, type, url } = event.getContent() || {};
@ -562,12 +569,12 @@ function textForWidgetEvent(event): () => string | null {
} }
} }
function textForWidgetLayoutEvent(event): () => string | null { function textForWidgetLayoutEvent(event: MatrixEvent): () => string | null {
const senderName = event.sender?.name || event.getSender(); const senderName = event.sender?.name || event.getSender();
return () => _t("%(senderName)s has updated the widget layout", { senderName }); 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 senderName = event.getSender();
const { entity: prevEntity } = event.getPrevContent(); const { entity: prevEntity } = event.getPrevContent();
const { entity, recommendation, reason } = event.getContent(); const { entity, recommendation, reason } = event.getContent();
@ -655,7 +662,9 @@ function textForMjolnirEvent(event): () => string | null {
} }
interface IHandlers { 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 = { const handlers: IHandlers = {
@ -693,14 +702,27 @@ for (const evType of ALL_RULE_TYPES) {
stateHandlers[evType] = textForMjolnirEvent; stateHandlers[evType] = textForMjolnirEvent;
} }
export function hasText(ev): 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()]; 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): string;
export function textForEvent(ev: MatrixEvent, allowJSX: true): string | JSX.Element; export function textForEvent(ev: MatrixEvent, allowJSX: true, showHiddenEvents?: boolean): string | JSX.Element;
export function textForEvent(ev: MatrixEvent, allowJSX = false): string | JSX.Element { export function textForEvent(ev: MatrixEvent, allowJSX = false, showHiddenEvents?: boolean): string | JSX.Element {
const handler = (ev.isState() ? stateHandlers : handlers)[ev.getType()]; const handler = (ev.isState() ? stateHandlers : handlers)[ev.getType()];
return handler?.(ev, allowJSX)?.() || ''; return handler?.(ev, allowJSX, showHiddenEvents)?.() || '';
} }

View file

@ -30,7 +30,7 @@ import { haveTileForEvent } from "./components/views/rooms/EventTile";
* @returns {boolean} True if the given event should affect the unread message count * @returns {boolean} True if the given event should affect the unread message count
*/ */
export function eventTriggersUnreadCount(ev: MatrixEvent): boolean { export function eventTriggersUnreadCount(ev: MatrixEvent): boolean {
if (ev.sender && ev.sender.userId == MatrixClientPeg.get().credentials.userId) { if (ev.getSender() === MatrixClientPeg.get().credentials.userId) {
return false; return false;
} }
@ -63,9 +63,7 @@ export function doesRoomHaveUnreadMessages(room: Room): boolean {
// https://github.com/vector-im/element-web/issues/2427 // https://github.com/vector-im/element-web/issues/2427
// ...and possibly some of the others at // ...and possibly some of the others at
// https://github.com/vector-im/element-web/issues/3363 // https://github.com/vector-im/element-web/issues/3363
if (room.timeline.length && if (room.timeline.length && room.timeline[room.timeline.length - 1].getSender() === myUserId) {
room.timeline[room.timeline.length - 1].sender &&
room.timeline[room.timeline.length - 1].sender.userId === myUserId) {
return false; return false;
} }

View file

@ -27,8 +27,8 @@ import EmojiProvider from './EmojiProvider';
import NotifProvider from './NotifProvider'; import NotifProvider from './NotifProvider';
import { timeout } from "../utils/promise"; import { timeout } from "../utils/promise";
import AutocompleteProvider, { ICommand } from "./AutocompleteProvider"; import AutocompleteProvider, { ICommand } from "./AutocompleteProvider";
import SettingsStore from "../settings/SettingsStore";
import SpaceProvider from "./SpaceProvider"; import SpaceProvider from "./SpaceProvider";
import SpaceStore from "../stores/SpaceStore";
export interface ISelectionRange { export interface ISelectionRange {
beginning?: boolean; // whether the selection is in the first block of the editor or not beginning?: boolean; // whether the selection is in the first block of the editor or not
@ -58,8 +58,7 @@ const PROVIDERS = [
DuckDuckGoProvider, DuckDuckGoProvider,
]; ];
// as the spaces feature is device configurable only, and toggling it refreshes the page, we can do this here if (SpaceStore.spacesEnabled) {
if (SettingsStore.getValue("feature_spaces")) {
PROVIDERS.push(SpaceProvider); PROVIDERS.push(SpaceProvider);
} else { } else {
PROVIDERS.push(CommunityProvider); PROVIDERS.push(CommunityProvider);

View file

@ -28,7 +28,7 @@ import { PillCompletion } from './Components';
import { makeRoomPermalink } from "../utils/permalinks/Permalinks"; import { makeRoomPermalink } from "../utils/permalinks/Permalinks";
import { ICompletion, ISelectionRange } from "./Autocompleter"; import { ICompletion, ISelectionRange } from "./Autocompleter";
import RoomAvatar from '../components/views/avatars/RoomAvatar'; import RoomAvatar from '../components/views/avatars/RoomAvatar';
import SettingsStore from "../settings/SettingsStore"; import SpaceStore from "../stores/SpaceStore";
const ROOM_REGEX = /\B#\S*/g; const ROOM_REGEX = /\B#\S*/g;
@ -59,7 +59,8 @@ export default class RoomProvider extends AutocompleteProvider {
const cli = MatrixClientPeg.get(); const cli = MatrixClientPeg.get();
let rooms = cli.getVisibleRooms(); let rooms = cli.getVisibleRooms();
if (SettingsStore.getValue("feature_spaces")) { // if spaces are enabled then filter them out here as they get their own autocomplete provider
if (SpaceStore.spacesEnabled) {
rooms = rooms.filter(r => !r.isSpaceRoom()); rooms = rooms.filter(r => !r.isSpaceRoom());
} }

View file

@ -54,7 +54,7 @@ export default class InteractiveAuthComponent extends React.Component {
// * emailSid {string} If email auth was performed, the sid of // * emailSid {string} If email auth was performed, the sid of
// the auth session. // the auth session.
// * clientSecret {string} The client secret used in auth // * clientSecret {string} The client secret used in auth
// sessions with the ID server. // sessions with the identity server.
onAuthFinished: PropTypes.func.isRequired, onAuthFinished: PropTypes.func.isRequired,
// Inputs provided by the user to the auth process // Inputs provided by the user to the auth process

View file

@ -63,6 +63,7 @@ import ToastContainer from './ToastContainer';
import MyGroups from "./MyGroups"; import MyGroups from "./MyGroups";
import UserView from "./UserView"; import UserView from "./UserView";
import GroupView from "./GroupView"; import GroupView from "./GroupView";
import SpaceStore from "../../stores/SpaceStore";
// We need to fetch each pinned message individually (if we don't already have it) // We need to fetch each pinned message individually (if we don't already have it)
// so each pinned message may trigger a request. Limit the number per room for sanity. // so each pinned message may trigger a request. Limit the number per room for sanity.
@ -631,7 +632,7 @@ class LoggedInView extends React.Component<IProps, IState> {
> >
<ToastContainer /> <ToastContainer />
<div ref={this._resizeContainer} className={bodyClasses}> <div ref={this._resizeContainer} className={bodyClasses}>
{ SettingsStore.getValue("feature_spaces") ? <SpacePanel /> : null } { SpaceStore.spacesEnabled ? <SpacePanel /> : null }
<LeftPanel <LeftPanel
isMinimized={this.props.collapseLhs || false} isMinimized={this.props.collapseLhs || false}
resizeNotifier={this.props.resizeNotifier} resizeNotifier={this.props.resizeNotifier}

View file

@ -19,7 +19,7 @@ import { createClient } from "matrix-js-sdk/src/matrix";
import { InvalidStoreError } from "matrix-js-sdk/src/errors"; import { InvalidStoreError } from "matrix-js-sdk/src/errors";
import { RoomMember } from "matrix-js-sdk/src/models/room-member"; import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { sleep, defer, IDeferred } from "matrix-js-sdk/src/utils"; import { sleep, defer, IDeferred, QueryDict } from "matrix-js-sdk/src/utils";
// focus-visible is a Polyfill for the :focus-visible CSS pseudo-attribute used by _AccessibleButton.scss // focus-visible is a Polyfill for the :focus-visible CSS pseudo-attribute used by _AccessibleButton.scss
import 'focus-visible'; import 'focus-visible';
@ -105,6 +105,8 @@ import VerificationRequestToast from '../views/toasts/VerificationRequestToast';
import PerformanceMonitor, { PerformanceEntryNames } from "../../performance"; import PerformanceMonitor, { PerformanceEntryNames } from "../../performance";
import UIStore, { UI_EVENTS } from "../../stores/UIStore"; import UIStore, { UI_EVENTS } from "../../stores/UIStore";
import SoftLogout from './auth/SoftLogout'; import SoftLogout from './auth/SoftLogout';
import { makeRoomPermalink } from "../../utils/permalinks/Permalinks";
import { copyPlaintext } from "../../utils/strings";
/** constants for MatrixChat.state.view */ /** constants for MatrixChat.state.view */
export enum Views { export enum Views {
@ -153,7 +155,7 @@ const ONBOARDING_FLOW_STARTERS = [
interface IScreen { interface IScreen {
screen: string; screen: string;
params?: object; params?: QueryDict;
} }
/* eslint-disable camelcase */ /* eslint-disable camelcase */
@ -183,9 +185,9 @@ interface IProps { // TODO type things better
onNewScreen: (screen: string, replaceLast: boolean) => void; onNewScreen: (screen: string, replaceLast: boolean) => void;
enableGuest?: boolean; enableGuest?: boolean;
// the queryParams extracted from the [real] query-string of the URI // the queryParams extracted from the [real] query-string of the URI
realQueryParams?: Record<string, string>; realQueryParams?: QueryDict;
// the initial queryParams extracted from the hash-fragment of the URI // the initial queryParams extracted from the hash-fragment of the URI
startingFragmentQueryParams?: Record<string, string>; startingFragmentQueryParams?: QueryDict;
// called when we have completed a token login // called when we have completed a token login
onTokenLoginCompleted?: () => void; onTokenLoginCompleted?: () => void;
// Represents the screen to display as a result of parsing the initial window.location // Represents the screen to display as a result of parsing the initial window.location
@ -193,7 +195,7 @@ interface IProps { // TODO type things better
// displayname, if any, to set on the device when logging in/registering. // displayname, if any, to set on the device when logging in/registering.
defaultDeviceDisplayName?: string; defaultDeviceDisplayName?: string;
// A function that makes a registration URL // A function that makes a registration URL
makeRegistrationUrl: (object) => string; makeRegistrationUrl: (params: QueryDict) => string;
} }
interface IState { interface IState {
@ -251,7 +253,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
private pageChanging: boolean; private pageChanging: boolean;
private tokenLogin?: boolean; private tokenLogin?: boolean;
private accountPassword?: string; private accountPassword?: string;
private accountPasswordTimer?: NodeJS.Timeout; private accountPasswordTimer?: number;
private focusComposer: boolean; private focusComposer: boolean;
private subTitleStatus: string; private subTitleStatus: string;
private prevWindowWidth: number; private prevWindowWidth: number;
@ -296,7 +298,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
if (this.screenAfterLogin.screen.startsWith("room/") && params['signurl'] && params['email']) { if (this.screenAfterLogin.screen.startsWith("room/") && params['signurl'] && params['email']) {
// probably a threepid invite - try to store it // probably a threepid invite - try to store it
const roomId = this.screenAfterLogin.screen.substring("room/".length); const roomId = this.screenAfterLogin.screen.substring("room/".length);
ThreepidInviteStore.instance.storeInvite(roomId, params as IThreepidInviteWireFormat); ThreepidInviteStore.instance.storeInvite(roomId, params as unknown as IThreepidInviteWireFormat);
} }
} }
@ -561,7 +563,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
switch (payload.action) { switch (payload.action) {
case 'MatrixActions.accountData': case 'MatrixActions.accountData':
// XXX: This is a collection of several hacks to solve a minor problem. We want to // XXX: This is a collection of several hacks to solve a minor problem. We want to
// update our local state when the ID server changes, but don't want to put that in // update our local state when the identity server changes, but don't want to put that in
// the js-sdk as we'd be then dictating how all consumers need to behave. However, // the js-sdk as we'd be then dictating how all consumers need to behave. However,
// this component is already bloated and we probably don't want this tiny logic in // this component is already bloated and we probably don't want this tiny logic in
// here, but there's no better place in the react-sdk for it. Additionally, we're // here, but there's no better place in the react-sdk for it. Additionally, we're
@ -627,6 +629,9 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
case 'forget_room': case 'forget_room':
this.forgetRoom(payload.room_id); this.forgetRoom(payload.room_id);
break; break;
case 'copy_room':
this.copyRoom(payload.room_id);
break;
case 'reject_invite': case 'reject_invite':
Modal.createTrackedDialog('Reject invitation', '', QuestionDialog, { Modal.createTrackedDialog('Reject invitation', '', QuestionDialog, {
title: _t('Reject invitation'), title: _t('Reject invitation'),
@ -1099,7 +1104,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
private leaveRoomWarnings(roomId: string) { private leaveRoomWarnings(roomId: string) {
const roomToLeave = MatrixClientPeg.get().getRoom(roomId); const roomToLeave = MatrixClientPeg.get().getRoom(roomId);
const isSpace = SettingsStore.getValue("feature_spaces") && roomToLeave?.isSpaceRoom(); const isSpace = SpaceStore.spacesEnabled && roomToLeave?.isSpaceRoom();
// Show a warning if there are additional complications. // Show a warning if there are additional complications.
const warnings = []; const warnings = [];
@ -1137,7 +1142,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
const roomToLeave = MatrixClientPeg.get().getRoom(roomId); const roomToLeave = MatrixClientPeg.get().getRoom(roomId);
const warnings = this.leaveRoomWarnings(roomId); const warnings = this.leaveRoomWarnings(roomId);
const isSpace = SettingsStore.getValue("feature_spaces") && roomToLeave?.isSpaceRoom(); const isSpace = SpaceStore.spacesEnabled && roomToLeave?.isSpaceRoom();
Modal.createTrackedDialog(isSpace ? "Leave space" : "Leave room", '', QuestionDialog, { Modal.createTrackedDialog(isSpace ? "Leave space" : "Leave room", '', QuestionDialog, {
title: isSpace ? _t("Leave space") : _t("Leave room"), title: isSpace ? _t("Leave space") : _t("Leave room"),
description: ( description: (
@ -1193,6 +1198,17 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
}); });
} }
private async copyRoom(roomId: string) {
const roomLink = makeRoomPermalink(roomId);
const success = await copyPlaintext(roomLink);
if (!success) {
Modal.createTrackedDialog("Unable to copy room link", "", ErrorDialog, {
title: _t("Unable to copy room link"),
description: _t("Unable to copy a link to the room to the clipboard."),
});
}
}
/** /**
* Starts a chat with the welcome user, if the user doesn't already have one * Starts a chat with the welcome user, if the user doesn't already have one
* @returns {string} The room ID of the new room, or null if no room was created * @returns {string} The room ID of the new room, or null if no room was created
@ -1687,7 +1703,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
const type = screen === "start_sso" ? "sso" : "cas"; const type = screen === "start_sso" ? "sso" : "cas";
PlatformPeg.get().startSingleSignOn(cli, type, this.getFragmentAfterLogin()); PlatformPeg.get().startSingleSignOn(cli, type, this.getFragmentAfterLogin());
} else if (screen === 'groups') { } else if (screen === 'groups') {
if (SettingsStore.getValue("feature_spaces")) { if (SpaceStore.spacesEnabled) {
dis.dispatch({ action: "view_home_page" }); dis.dispatch({ action: "view_home_page" });
return; return;
} }
@ -1774,7 +1790,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
subAction: params.action, subAction: params.action,
}); });
} else if (screen.indexOf('group/') === 0) { } else if (screen.indexOf('group/') === 0) {
if (SettingsStore.getValue("feature_spaces")) { if (SpaceStore.spacesEnabled) {
dis.dispatch({ action: "view_home_page" }); dis.dispatch({ action: "view_home_page" });
return; return;
} }
@ -1936,7 +1952,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
this.setState({ serverConfig }); this.setState({ serverConfig });
}; };
private makeRegistrationUrl = (params: {[key: string]: string}) => { private makeRegistrationUrl = (params: QueryDict) => {
if (this.props.startingFragmentQueryParams.referrer) { if (this.props.startingFragmentQueryParams.referrer) {
params.referrer = this.props.startingFragmentQueryParams.referrer; params.referrer = this.props.startingFragmentQueryParams.referrer;
} }
@ -2091,7 +2107,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
onForgotPasswordClick={showPasswordReset ? this.onForgotPasswordClick : undefined} onForgotPasswordClick={showPasswordReset ? this.onForgotPasswordClick : undefined}
onServerConfigChange={this.onServerConfigChange} onServerConfigChange={this.onServerConfigChange}
fragmentAfterLogin={fragmentAfterLogin} fragmentAfterLogin={fragmentAfterLogin}
defaultUsername={this.props.startingFragmentQueryParams.defaultUsername} defaultUsername={this.props.startingFragmentQueryParams.defaultUsername as string}
{...this.getServerProperties()} {...this.getServerProperties()}
/> />
); );

View file

@ -54,7 +54,11 @@ const membershipTypes = [EventType.RoomMember, EventType.RoomThirdPartyInvite, E
// check if there is a previous event and it has the same sender as this event // 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 // and the types are the same/is in continuedTypes and the time between them is <= CONTINUATION_MAX_INTERVAL
export function shouldFormContinuation(prevEvent: MatrixEvent, mxEvent: MatrixEvent): boolean { export function shouldFormContinuation(
prevEvent: MatrixEvent,
mxEvent: MatrixEvent,
showHiddenEvents: boolean,
): boolean {
// sanity check inputs // sanity check inputs
if (!prevEvent || !prevEvent.sender || !mxEvent.sender) return false; if (!prevEvent || !prevEvent.sender || !mxEvent.sender) return false;
// check if within the max continuation period // check if within the max continuation period
@ -74,7 +78,7 @@ export function shouldFormContinuation(prevEvent: MatrixEvent, mxEvent: MatrixEv
mxEvent.sender.getMxcAvatarUrl() !== prevEvent.sender.getMxcAvatarUrl()) return false; 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 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; return true;
} }
@ -239,7 +243,8 @@ export default class MessagePanel extends React.Component<IProps, IState> {
}; };
// Cache hidden events setting on mount since Settings is expensive to // 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.showHiddenEventsInTimeline = SettingsStore.getValue("showHiddenEventsInTimeline");
this.showTypingNotificationsWatcherRef = this.showTypingNotificationsWatcherRef =
@ -399,17 +404,21 @@ export default class MessagePanel extends React.Component<IProps, IState> {
return !this.isMounted; return !this.isMounted;
}; };
private get showHiddenEvents(): boolean {
return this.context?.showHiddenEventsInTimeline ?? this.showHiddenEventsInTimeline;
}
// TODO: Implement granular (per-room) hide options // TODO: Implement granular (per-room) hide options
public shouldShowEvent(mxEv: MatrixEvent): boolean { public shouldShowEvent(mxEv: MatrixEvent): boolean {
if (mxEv.sender && MatrixClientPeg.get().isUserIgnored(mxEv.sender.userId)) { if (MatrixClientPeg.get().isUserIgnored(mxEv.getSender())) {
return false; // ignored = no show (only happens if the ignore happens after an event was received) return false; // ignored = no show (only happens if the ignore happens after an event was received)
} }
if (this.showHiddenEventsInTimeline) { if (this.showHiddenEvents) {
return true; return true;
} }
if (!haveTileForEvent(mxEv)) { if (!haveTileForEvent(mxEv, this.showHiddenEvents)) {
return false; // no tile = no show return false; // no tile = no show
} }
@ -569,7 +578,7 @@ export default class MessagePanel extends React.Component<IProps, IState> {
if (grouper) { if (grouper) {
if (grouper.shouldGroup(mxEv)) { if (grouper.shouldGroup(mxEv)) {
grouper.add(mxEv); grouper.add(mxEv, this.showHiddenEvents);
continue; continue;
} else { } else {
// not part of group, so get the group tiles, close the // not part of group, so get the group tiles, close the
@ -649,7 +658,8 @@ export default class MessagePanel extends React.Component<IProps, IState> {
} }
// is this a continuation of the previous message? // 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 eventId = mxEv.getId();
const highlight = (eventId === this.props.highlightedEventId); const highlight = (eventId === this.props.highlightedEventId);
@ -946,7 +956,7 @@ abstract class BaseGrouper {
} }
public abstract shouldGroup(ev: MatrixEvent): boolean; 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 getTiles(): ReactNode[];
public abstract getNewPrevEvent(): MatrixEvent; public abstract getNewPrevEvent(): MatrixEvent;
} }
@ -1200,10 +1210,10 @@ class MemberGrouper extends BaseGrouper {
return membershipTypes.includes(ev.getType() as EventType); return membershipTypes.includes(ev.getType() as EventType);
} }
public add(ev: MatrixEvent): void { public add(ev: MatrixEvent, showHiddenEvents?: boolean): void {
if (ev.getType() === EventType.RoomMember) { if (ev.getType() === EventType.RoomMember) {
// We can ignore any events that don't actually have a message to display // 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( this.readMarker = this.readMarker || this.panel.readMarkerForEvent(
ev.getId(), ev.getId(),

View file

@ -48,6 +48,7 @@ import NotificationPanel from "./NotificationPanel";
import ResizeNotifier from "../../utils/ResizeNotifier"; import ResizeNotifier from "../../utils/ResizeNotifier";
import PinnedMessagesCard from "../views/right_panel/PinnedMessagesCard"; import PinnedMessagesCard from "../views/right_panel/PinnedMessagesCard";
import { throttle } from 'lodash'; import { throttle } from 'lodash';
import SpaceStore from "../../stores/SpaceStore";
interface IProps { interface IProps {
room?: Room; // if showing panels for a given room, this is set room?: Room; // if showing panels for a given room, this is set
@ -107,7 +108,7 @@ export default class RightPanel extends React.Component<IProps, IState> {
return RightPanelPhases.GroupMemberList; return RightPanelPhases.GroupMemberList;
} }
return rps.groupPanelPhase; return rps.groupPanelPhase;
} else if (SettingsStore.getValue("feature_spaces") && this.props.room?.isSpaceRoom() } else if (SpaceStore.spacesEnabled && this.props.room?.isSpaceRoom()
&& !RIGHT_PANEL_SPACE_PHASES.includes(rps.roomPanelPhase) && !RIGHT_PANEL_SPACE_PHASES.includes(rps.roomPanelPhase)
) { ) {
return RightPanelPhases.SpaceMemberList; return RightPanelPhases.SpaceMemberList;

View file

@ -16,6 +16,9 @@ limitations under the License.
*/ */
import React from "react"; import React from "react";
import { IFieldType, IInstance, IProtocol, IPublicRoomsChunkRoom } from "matrix-js-sdk/src/client";
import { Visibility } from "matrix-js-sdk/src/@types/partials";
import { IRoomDirectoryOptions } from "matrix-js-sdk/src/@types/requests";
import { MatrixClientPeg } from "../../MatrixClientPeg"; import { MatrixClientPeg } from "../../MatrixClientPeg";
import dis from "../../dispatcher/dispatcher"; import dis from "../../dispatcher/dispatcher";
@ -25,7 +28,7 @@ import { _t } from '../../languageHandler';
import SdkConfig from '../../SdkConfig'; import SdkConfig from '../../SdkConfig';
import { instanceForInstanceId, protocolNameForInstanceId } from '../../utils/DirectoryUtils'; import { instanceForInstanceId, protocolNameForInstanceId } from '../../utils/DirectoryUtils';
import Analytics from '../../Analytics'; import Analytics from '../../Analytics';
import { ALL_ROOMS, IFieldType, IInstance, IProtocol, Protocols } from "../views/directory/NetworkDropdown"; import NetworkDropdown, { ALL_ROOMS, Protocols } from "../views/directory/NetworkDropdown";
import SettingsStore from "../../settings/SettingsStore"; import SettingsStore from "../../settings/SettingsStore";
import GroupFilterOrderStore from "../../stores/GroupFilterOrderStore"; import GroupFilterOrderStore from "../../stores/GroupFilterOrderStore";
import GroupStore from "../../stores/GroupStore"; import GroupStore from "../../stores/GroupStore";
@ -40,10 +43,10 @@ import ErrorDialog from "../views/dialogs/ErrorDialog";
import QuestionDialog from "../views/dialogs/QuestionDialog"; import QuestionDialog from "../views/dialogs/QuestionDialog";
import BaseDialog from "../views/dialogs/BaseDialog"; import BaseDialog from "../views/dialogs/BaseDialog";
import DirectorySearchBox from "../views/elements/DirectorySearchBox"; import DirectorySearchBox from "../views/elements/DirectorySearchBox";
import NetworkDropdown from "../views/directory/NetworkDropdown";
import ScrollPanel from "./ScrollPanel"; import ScrollPanel from "./ScrollPanel";
import Spinner from "../views/elements/Spinner"; import Spinner from "../views/elements/Spinner";
import { ActionPayload } from "../../dispatcher/payloads"; import { ActionPayload } from "../../dispatcher/payloads";
import { getDisplayAliasForAliasSet } from "../../Rooms";
const MAX_NAME_LENGTH = 80; const MAX_NAME_LENGTH = 80;
const MAX_TOPIC_LENGTH = 800; const MAX_TOPIC_LENGTH = 800;
@ -60,7 +63,7 @@ interface IProps extends IDialogProps {
} }
interface IState { interface IState {
publicRooms: IRoom[]; publicRooms: IPublicRoomsChunkRoom[];
loading: boolean; loading: boolean;
protocolsLoading: boolean; protocolsLoading: boolean;
error?: string; error?: string;
@ -71,35 +74,12 @@ interface IState {
communityName?: string; communityName?: string;
} }
/* eslint-disable camelcase */
interface IRoom {
room_id: string;
name?: string;
avatar_url?: string;
topic?: string;
canonical_alias?: string;
aliases?: string[];
world_readable: boolean;
guest_can_join: boolean;
num_joined_members: number;
}
interface IPublicRoomsRequest {
limit?: number;
since?: string;
server?: string;
filter?: object;
include_all_networks?: boolean;
third_party_instance_id?: string;
}
/* eslint-enable camelcase */
@replaceableComponent("structures.RoomDirectory") @replaceableComponent("structures.RoomDirectory")
export default class RoomDirectory extends React.Component<IProps, IState> { export default class RoomDirectory extends React.Component<IProps, IState> {
private readonly startTime: number; private readonly startTime: number;
private unmounted = false; private unmounted = false;
private nextBatch: string = null; private nextBatch: string = null;
private filterTimeout: NodeJS.Timeout; private filterTimeout: number;
private protocols: Protocols; private protocols: Protocols;
constructor(props) { constructor(props) {
@ -252,7 +232,7 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
// remember the next batch token when we sent the request // remember the next batch token when we sent the request
// too. If it's changed, appending to the list will corrupt it. // too. If it's changed, appending to the list will corrupt it.
const nextBatch = this.nextBatch; const nextBatch = this.nextBatch;
const opts: IPublicRoomsRequest = { limit: 20 }; const opts: IRoomDirectoryOptions = { limit: 20 };
if (roomServer != MatrixClientPeg.getHomeserverName()) { if (roomServer != MatrixClientPeg.getHomeserverName()) {
opts.server = roomServer; opts.server = roomServer;
} }
@ -325,7 +305,7 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
* HS admins to do this through the RoomSettings interface, but * HS admins to do this through the RoomSettings interface, but
* this needs SPEC-417. * this needs SPEC-417.
*/ */
private removeFromDirectory(room: IRoom) { private removeFromDirectory(room: IPublicRoomsChunkRoom) {
const alias = getDisplayAliasForRoom(room); const alias = getDisplayAliasForRoom(room);
const name = room.name || alias || _t('Unnamed room'); const name = room.name || alias || _t('Unnamed room');
@ -345,7 +325,7 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
const modal = Modal.createDialog(Spinner); const modal = Modal.createDialog(Spinner);
let step = _t('remove %(name)s from the directory.', { name: name }); let step = _t('remove %(name)s from the directory.', { name: name });
MatrixClientPeg.get().setRoomDirectoryVisibility(room.room_id, 'private').then(() => { MatrixClientPeg.get().setRoomDirectoryVisibility(room.room_id, Visibility.Private).then(() => {
if (!alias) return; if (!alias) return;
step = _t('delete the address.'); step = _t('delete the address.');
return MatrixClientPeg.get().deleteAlias(alias); return MatrixClientPeg.get().deleteAlias(alias);
@ -367,7 +347,7 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
}); });
} }
private onRoomClicked = (room: IRoom, ev: ButtonEvent) => { private onRoomClicked = (room: IPublicRoomsChunkRoom, ev: ButtonEvent) => {
// If room was shift-clicked, remove it from the room directory // If room was shift-clicked, remove it from the room directory
if (ev.shiftKey && !this.state.selectedCommunityId) { if (ev.shiftKey && !this.state.selectedCommunityId) {
ev.preventDefault(); ev.preventDefault();
@ -480,17 +460,17 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
} }
}; };
private onPreviewClick = (ev: ButtonEvent, room: IRoom) => { private onPreviewClick = (ev: ButtonEvent, room: IPublicRoomsChunkRoom) => {
this.showRoom(room, null, false, true); this.showRoom(room, null, false, true);
ev.stopPropagation(); ev.stopPropagation();
}; };
private onViewClick = (ev: ButtonEvent, room: IRoom) => { private onViewClick = (ev: ButtonEvent, room: IPublicRoomsChunkRoom) => {
this.showRoom(room); this.showRoom(room);
ev.stopPropagation(); ev.stopPropagation();
}; };
private onJoinClick = (ev: ButtonEvent, room: IRoom) => { private onJoinClick = (ev: ButtonEvent, room: IPublicRoomsChunkRoom) => {
this.showRoom(room, null, true); this.showRoom(room, null, true);
ev.stopPropagation(); ev.stopPropagation();
}; };
@ -508,7 +488,7 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
this.showRoom(null, alias, autoJoin); this.showRoom(null, alias, autoJoin);
} }
private showRoom(room: IRoom, roomAlias?: string, autoJoin = false, shouldPeek = false) { private showRoom(room: IPublicRoomsChunkRoom, roomAlias?: string, autoJoin = false, shouldPeek = false) {
this.onFinished(); this.onFinished();
const payload: ActionPayload = { const payload: ActionPayload = {
action: 'view_room', action: 'view_room',
@ -557,7 +537,7 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
dis.dispatch(payload); dis.dispatch(payload);
} }
private createRoomCells(room: IRoom) { private createRoomCells(room: IPublicRoomsChunkRoom) {
const client = MatrixClientPeg.get(); const client = MatrixClientPeg.get();
const clientRoom = client.getRoom(room.room_id); const clientRoom = client.getRoom(room.room_id);
const hasJoinedRoom = clientRoom && clientRoom.getMyMembership() === "join"; const hasJoinedRoom = clientRoom && clientRoom.getMyMembership() === "join";
@ -853,6 +833,6 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
// Similar to matrix-react-sdk's MatrixTools.getDisplayAliasForRoom // Similar to matrix-react-sdk's MatrixTools.getDisplayAliasForRoom
// but works with the objects we get from the public room list // but works with the objects we get from the public room list
function getDisplayAliasForRoom(room: IRoom) { function getDisplayAliasForRoom(room: IPublicRoomsChunkRoom) {
return room.canonical_alias || room.aliases?.[0] || ""; return getDisplayAliasForAliasSet(room.canonical_alias, room.aliases);
} }

View file

@ -25,8 +25,8 @@ import React, { createRef } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { IRecommendedVersion, NotificationCountType, Room } from "matrix-js-sdk/src/models/room"; import { IRecommendedVersion, NotificationCountType, Room } from "matrix-js-sdk/src/models/room";
import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { SearchResult } from "matrix-js-sdk/src/models/search-result";
import { EventSubscription } from "fbemitter"; import { EventSubscription } from "fbemitter";
import { ISearchResults } from 'matrix-js-sdk/src/@types/search';
import shouldHideEvent from '../../shouldHideEvent'; import shouldHideEvent from '../../shouldHideEvent';
import { _t } from '../../languageHandler'; import { _t } from '../../languageHandler';
@ -89,6 +89,7 @@ import RoomStatusBar from "./RoomStatusBar";
import MessageComposer from '../views/rooms/MessageComposer'; import MessageComposer from '../views/rooms/MessageComposer';
import JumpToBottomButton from "../views/rooms/JumpToBottomButton"; import JumpToBottomButton from "../views/rooms/JumpToBottomButton";
import TopUnreadMessagesBar from "../views/rooms/TopUnreadMessagesBar"; import TopUnreadMessagesBar from "../views/rooms/TopUnreadMessagesBar";
import SpaceStore from "../../stores/SpaceStore";
const DEBUG = false; const DEBUG = false;
let debuglog = function(msg: string) {}; let debuglog = function(msg: string) {};
@ -133,12 +134,7 @@ export interface IState {
searching: boolean; searching: boolean;
searchTerm?: string; searchTerm?: string;
searchScope?: SearchScope; searchScope?: SearchScope;
searchResults?: XOR<{}, { searchResults?: XOR<{}, ISearchResults>;
count: number;
highlights: string[];
results: SearchResult[];
next_batch: string; // eslint-disable-line camelcase
}>;
searchHighlights?: string[]; searchHighlights?: string[];
searchInProgress?: boolean; searchInProgress?: boolean;
callState?: CallState; callState?: CallState;
@ -170,6 +166,7 @@ export interface IState {
canReply: boolean; canReply: boolean;
layout: Layout; layout: Layout;
lowBandwidth: boolean; lowBandwidth: boolean;
showHiddenEventsInTimeline: boolean;
showReadReceipts: boolean; showReadReceipts: boolean;
showRedactions: boolean; showRedactions: boolean;
showJoinLeaves: boolean; showJoinLeaves: boolean;
@ -234,6 +231,7 @@ export default class RoomView extends React.Component<IProps, IState> {
canReply: false, canReply: false,
layout: SettingsStore.getValue("layout"), layout: SettingsStore.getValue("layout"),
lowBandwidth: SettingsStore.getValue("lowBandwidth"), lowBandwidth: SettingsStore.getValue("lowBandwidth"),
showHiddenEventsInTimeline: SettingsStore.getValue("showHiddenEventsInTimeline"),
showReadReceipts: true, showReadReceipts: true,
showRedactions: true, showRedactions: true,
showJoinLeaves: true, showJoinLeaves: true,
@ -257,7 +255,6 @@ export default class RoomView extends React.Component<IProps, IState> {
this.context.on("userTrustStatusChanged", this.onUserVerificationChanged); this.context.on("userTrustStatusChanged", this.onUserVerificationChanged);
this.context.on("crossSigning.keysChanged", this.onCrossSigningKeysChanged); this.context.on("crossSigning.keysChanged", this.onCrossSigningKeysChanged);
this.context.on("Event.decrypted", this.onEventDecrypted); this.context.on("Event.decrypted", this.onEventDecrypted);
this.context.on("event", this.onEvent);
// Start listening for RoomViewStore updates // Start listening for RoomViewStore updates
this.roomStoreToken = RoomViewStore.addListener(this.onRoomViewStoreUpdate); this.roomStoreToken = RoomViewStore.addListener(this.onRoomViewStoreUpdate);
this.rightPanelStoreToken = RightPanelStore.getSharedInstance().addListener(this.onRightPanelStoreUpdate); this.rightPanelStoreToken = RightPanelStore.getSharedInstance().addListener(this.onRightPanelStoreUpdate);
@ -272,6 +269,9 @@ export default class RoomView extends React.Component<IProps, IState> {
SettingsStore.watchSetting("lowBandwidth", null, () => SettingsStore.watchSetting("lowBandwidth", null, () =>
this.setState({ lowBandwidth: SettingsStore.getValue("lowBandwidth") }), this.setState({ lowBandwidth: SettingsStore.getValue("lowBandwidth") }),
), ),
SettingsStore.watchSetting("showHiddenEventsInTimeline", null, () =>
this.setState({ showHiddenEventsInTimeline: SettingsStore.getValue("showHiddenEventsInTimeline") }),
),
]; ];
} }
@ -641,7 +641,6 @@ export default class RoomView extends React.Component<IProps, IState> {
this.context.removeListener("userTrustStatusChanged", this.onUserVerificationChanged); this.context.removeListener("userTrustStatusChanged", this.onUserVerificationChanged);
this.context.removeListener("crossSigning.keysChanged", this.onCrossSigningKeysChanged); this.context.removeListener("crossSigning.keysChanged", this.onCrossSigningKeysChanged);
this.context.removeListener("Event.decrypted", this.onEventDecrypted); this.context.removeListener("Event.decrypted", this.onEventDecrypted);
this.context.removeListener("event", this.onEvent);
} }
window.removeEventListener('beforeunload', this.onPageUnload); window.removeEventListener('beforeunload', this.onPageUnload);
@ -841,8 +840,7 @@ export default class RoomView extends React.Component<IProps, IState> {
if (this.unmounted) return; if (this.unmounted) return;
// ignore events for other rooms // ignore events for other rooms
if (!room) return; if (!room || room.roomId !== this.state.room?.roomId) return;
if (!this.state.room || room.roomId != this.state.room.roomId) return;
// ignore events from filtered timelines // ignore events from filtered timelines
if (data.timeline.getTimelineSet() !== room.getUnfilteredTimelineSet()) return; if (data.timeline.getTimelineSet() !== room.getUnfilteredTimelineSet()) return;
@ -863,6 +861,10 @@ export default class RoomView extends React.Component<IProps, IState> {
// we'll only be showing a spinner. // we'll only be showing a spinner.
if (this.state.joining) return; if (this.state.joining) return;
if (!ev.isBeingDecrypted() && !ev.isDecryptionFailure()) {
this.handleEffects(ev);
}
if (ev.getSender() !== this.context.credentials.userId) { if (ev.getSender() !== this.context.credentials.userId) {
// update unread count when scrolled up // update unread count when scrolled up
if (!this.state.searchResults && this.state.atEndOfLiveTimeline) { if (!this.state.searchResults && this.state.atEndOfLiveTimeline) {
@ -875,20 +877,14 @@ export default class RoomView extends React.Component<IProps, IState> {
} }
}; };
private onEventDecrypted = (ev) => { private onEventDecrypted = (ev: MatrixEvent) => {
if (!this.state.room || !this.state.matrixClientIsReady) return; // not ready at all
if (ev.getRoomId() !== this.state.room.roomId) return; // not for us
if (ev.isDecryptionFailure()) return; if (ev.isDecryptionFailure()) return;
this.handleEffects(ev); this.handleEffects(ev);
}; };
private onEvent = (ev) => { private handleEffects = (ev: MatrixEvent) => {
if (ev.isBeingDecrypted() || ev.isDecryptionFailure()) return;
this.handleEffects(ev);
};
private handleEffects = (ev) => {
if (!this.state.room || !this.state.matrixClientIsReady) return; // not ready at all
if (ev.getRoomId() !== this.state.room.roomId) return; // not for us
const notifState = RoomNotificationStateStore.instance.getRoomState(this.state.room); const notifState = RoomNotificationStateStore.instance.getRoomState(this.state.room);
if (!notifState.isUnread) return; if (!notifState.isUnread) return;
@ -921,6 +917,7 @@ export default class RoomView extends React.Component<IProps, IState> {
// called when state.room is first initialised (either at initial load, // called when state.room is first initialised (either at initial load,
// after a successful peek, or after we join the room). // after a successful peek, or after we join the room).
private onRoomLoaded = (room: Room) => { private onRoomLoaded = (room: Room) => {
if (this.unmounted) return;
// Attach a widget store listener only when we get a room // Attach a widget store listener only when we get a room
WidgetLayoutStore.instance.on(WidgetLayoutStore.emissionForRoom(room), this.onWidgetLayoutChange); WidgetLayoutStore.instance.on(WidgetLayoutStore.emissionForRoom(room), this.onWidgetLayoutChange);
this.onWidgetLayoutChange(); // provoke an update this.onWidgetLayoutChange(); // provoke an update
@ -935,9 +932,9 @@ export default class RoomView extends React.Component<IProps, IState> {
}; };
private async calculateRecommendedVersion(room: Room) { private async calculateRecommendedVersion(room: Room) {
this.setState({ const upgradeRecommendation = await room.getRecommendedVersion();
upgradeRecommendation: await room.getRecommendedVersion(), if (this.unmounted) return;
}); this.setState({ upgradeRecommendation });
} }
private async loadMembersIfJoined(room: Room) { private async loadMembersIfJoined(room: Room) {
@ -1027,23 +1024,19 @@ export default class RoomView extends React.Component<IProps, IState> {
}; };
private async updateE2EStatus(room: Room) { private async updateE2EStatus(room: Room) {
if (!this.context.isRoomEncrypted(room.roomId)) { if (!this.context.isRoomEncrypted(room.roomId)) return;
return;
} // If crypto is not currently enabled, we aren't tracking devices at all,
if (!this.context.isCryptoEnabled()) { // so we don't know what the answer is. Let's error on the safe side and show
// If crypto is not currently enabled, we aren't tracking devices at all, // a warning for this case.
// so we don't know what the answer is. Let's error on the safe side and show let e2eStatus = E2EStatus.Warning;
// a warning for this case. if (this.context.isCryptoEnabled()) {
this.setState({ /* At this point, the user has encryption on and cross-signing on */
e2eStatus: E2EStatus.Warning, e2eStatus = await shieldStatusForRoom(this.context, room);
});
return;
} }
/* At this point, the user has encryption on and cross-signing on */ if (this.unmounted) return;
this.setState({ this.setState({ e2eStatus });
e2eStatus: await shieldStatusForRoom(this.context, room),
});
} }
private onAccountData = (event: MatrixEvent) => { private onAccountData = (event: MatrixEvent) => {
@ -1137,7 +1130,7 @@ export default class RoomView extends React.Component<IProps, IState> {
if (this.state.searchResults.next_batch) { if (this.state.searchResults.next_batch) {
debuglog("requesting more search results"); debuglog("requesting more search results");
const searchPromise = searchPagination(this.state.searchResults); const searchPromise = searchPagination(this.state.searchResults as ISearchResults);
return this.handleSearchResult(searchPromise); return this.handleSearchResult(searchPromise);
} else { } else {
debuglog("no more search results"); debuglog("no more search results");
@ -1400,7 +1393,7 @@ export default class RoomView extends React.Component<IProps, IState> {
continue; continue;
} }
if (!haveTileForEvent(mxEv)) { if (!haveTileForEvent(mxEv, this.state.showHiddenEventsInTimeline)) {
// XXX: can this ever happen? It will make the result count // XXX: can this ever happen? It will make the result count
// not match the displayed count. // not match the displayed count.
continue; continue;
@ -1753,10 +1746,8 @@ export default class RoomView extends React.Component<IProps, IState> {
} }
const myMembership = this.state.room.getMyMembership(); const myMembership = this.state.room.getMyMembership();
if (myMembership === "invite" // SpaceRoomView handles invites itself
// SpaceRoomView handles invites itself if (myMembership === "invite" && (!SpaceStore.spacesEnabled || !this.state.room.isSpaceRoom())) {
&& (!SettingsStore.getValue("feature_spaces") || !this.state.room.isSpaceRoom())
) {
if (this.state.joining || this.state.rejecting) { if (this.state.joining || this.state.rejecting) {
return ( return (
<ErrorBoundary> <ErrorBoundary>
@ -1887,7 +1878,7 @@ export default class RoomView extends React.Component<IProps, IState> {
room={this.state.room} room={this.state.room}
/> />
); );
if (!this.state.canPeek && (!SettingsStore.getValue("feature_spaces") || !this.state.room?.isSpaceRoom())) { if (!this.state.canPeek && (!SpaceStore.spacesEnabled || !this.state.room?.isSpaceRoom())) {
return ( return (
<div className="mx_RoomView"> <div className="mx_RoomView">
{ previewBar } { previewBar }

View file

@ -187,7 +187,7 @@ export default class ScrollPanel extends React.Component<IProps> {
private fillRequestWhileRunning: boolean; private fillRequestWhileRunning: boolean;
private scrollState: IScrollState; private scrollState: IScrollState;
private preventShrinkingState: IPreventShrinkingState; private preventShrinkingState: IPreventShrinkingState;
private unfillDebouncer: NodeJS.Timeout; private unfillDebouncer: number;
private bottomGrowth: number; private bottomGrowth: number;
private pages: number; private pages: number;
private heightUpdateInProgress: boolean; private heightUpdateInProgress: boolean;

View file

@ -18,6 +18,7 @@ import React, { ReactNode, useMemo, useState } from "react";
import { Room } from "matrix-js-sdk/src/models/room"; import { Room } from "matrix-js-sdk/src/models/room";
import { MatrixClient } from "matrix-js-sdk/src/client"; import { MatrixClient } from "matrix-js-sdk/src/client";
import { EventType, RoomType } from "matrix-js-sdk/src/@types/event"; import { EventType, RoomType } from "matrix-js-sdk/src/@types/event";
import { ISpaceSummaryRoom, ISpaceSummaryEvent } from "matrix-js-sdk/src/@types/spaces";
import classNames from "classnames"; import classNames from "classnames";
import { sortBy } from "lodash"; import { sortBy } from "lodash";
@ -42,6 +43,7 @@ import { useStateToggle } from "../../hooks/useStateToggle";
import { getChildOrder } from "../../stores/SpaceStore"; import { getChildOrder } from "../../stores/SpaceStore";
import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton"; import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton";
import { linkifyElement } from "../../HtmlUtils"; import { linkifyElement } from "../../HtmlUtils";
import { getDisplayAliasForAliasSet } from "../../Rooms";
interface IHierarchyProps { interface IHierarchyProps {
space: Room; space: Room;
@ -51,36 +53,6 @@ interface IHierarchyProps {
showRoom(room: ISpaceSummaryRoom, viaServers?: string[], autoJoin?: boolean): void; showRoom(room: ISpaceSummaryRoom, viaServers?: string[], autoJoin?: boolean): void;
} }
/* eslint-disable camelcase */
export interface ISpaceSummaryRoom {
canonical_alias?: string;
aliases: string[];
avatar_url?: string;
guest_can_join: boolean;
name?: string;
num_joined_members: number;
room_id: string;
topic?: string;
world_readable: boolean;
num_refs: number;
room_type: string;
}
export interface ISpaceSummaryEvent {
room_id: string;
event_id: string;
origin_server_ts: number;
type: string;
state_key: string;
content: {
order?: string;
suggested?: boolean;
auto_join?: boolean;
via?: string[];
};
}
/* eslint-enable camelcase */
interface ITileProps { interface ITileProps {
room: ISpaceSummaryRoom; room: ISpaceSummaryRoom;
suggested?: boolean; suggested?: boolean;
@ -666,5 +638,5 @@ export default SpaceRoomDirectory;
// Similar to matrix-react-sdk's MatrixTools.getDisplayAliasForRoom // Similar to matrix-react-sdk's MatrixTools.getDisplayAliasForRoom
// but works with the objects we get from the public room list // but works with the objects we get from the public room list
function getDisplayAliasForRoom(room: ISpaceSummaryRoom) { function getDisplayAliasForRoom(room: ISpaceSummaryRoom) {
return room.canonical_alias || (room.aliases ? room.aliases[0] : ""); return getDisplayAliasForAliasSet(room.canonical_alias, room.aliases);
} }

View file

@ -62,7 +62,6 @@ import IconizedContextMenu, {
import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton"; import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton";
import { BetaPill } from "../views/beta/BetaCard"; import { BetaPill } from "../views/beta/BetaCard";
import { UserTab } from "../views/dialogs/UserSettingsDialog"; import { UserTab } from "../views/dialogs/UserSettingsDialog";
import SettingsStore from "../../settings/SettingsStore";
import Modal from "../../Modal"; import Modal from "../../Modal";
import BetaFeedbackDialog from "../views/dialogs/BetaFeedbackDialog"; import BetaFeedbackDialog from "../views/dialogs/BetaFeedbackDialog";
import SdkConfig from "../../SdkConfig"; import SdkConfig from "../../SdkConfig";
@ -178,7 +177,7 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) =>
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const spacesEnabled = SettingsStore.getValue("feature_spaces"); const spacesEnabled = SpaceStore.spacesEnabled;
const cannotJoin = getEffectiveMembership(myMembership) === EffectiveMembership.Leave const cannotJoin = getEffectiveMembership(myMembership) === EffectiveMembership.Leave
&& space.getJoinRule() !== JoinRule.Public; && space.getJoinRule() !== JoinRule.Public;
@ -854,7 +853,7 @@ export default class SpaceRoomView extends React.PureComponent<IProps, IState> {
private renderBody() { private renderBody() {
switch (this.state.phase) { switch (this.state.phase) {
case Phase.Landing: case Phase.Landing:
if (this.state.myMembership === "join" && SettingsStore.getValue("feature_spaces")) { if (this.state.myMembership === "join" && SpaceStore.spacesEnabled) {
return <SpaceLanding space={this.props.space} />; return <SpaceLanding space={this.props.space} />;
} else { } else {
return <SpacePreview return <SpacePreview

View file

@ -20,6 +20,7 @@ import * as React from "react";
import { _t } from '../../languageHandler'; import { _t } from '../../languageHandler';
import AutoHideScrollbar from './AutoHideScrollbar'; import AutoHideScrollbar from './AutoHideScrollbar';
import { replaceableComponent } from "../../utils/replaceableComponent"; import { replaceableComponent } from "../../utils/replaceableComponent";
import classNames from "classnames";
import AccessibleButton from "../views/elements/AccessibleButton"; import AccessibleButton from "../views/elements/AccessibleButton";
/** /**
@ -37,9 +38,16 @@ export class Tab {
} }
} }
export enum TabLocation {
LEFT = 'left',
TOP = 'top',
}
interface IProps { interface IProps {
tabs: Tab[]; tabs: Tab[];
initialTabId?: string; initialTabId?: string;
tabLocation: TabLocation;
onChange?: (tabId: string) => void;
} }
interface IState { interface IState {
@ -62,6 +70,10 @@ export default class TabbedView extends React.Component<IProps, IState> {
}; };
} }
static defaultProps = {
tabLocation: TabLocation.LEFT,
};
private _getActiveTabIndex() { private _getActiveTabIndex() {
if (!this.state || !this.state.activeTabIndex) return 0; if (!this.state || !this.state.activeTabIndex) return 0;
return this.state.activeTabIndex; return this.state.activeTabIndex;
@ -75,6 +87,7 @@ export default class TabbedView extends React.Component<IProps, IState> {
private _setActiveTab(tab: Tab) { private _setActiveTab(tab: Tab) {
const idx = this.props.tabs.indexOf(tab); const idx = this.props.tabs.indexOf(tab);
if (idx !== -1) { if (idx !== -1) {
if (this.props.onChange) this.props.onChange(tab.id);
this.setState({ activeTabIndex: idx }); this.setState({ activeTabIndex: idx });
} else { } else {
console.error("Could not find tab " + tab.label + " in tabs"); console.error("Could not find tab " + tab.label + " in tabs");
@ -119,8 +132,14 @@ export default class TabbedView extends React.Component<IProps, IState> {
const labels = this.props.tabs.map(tab => this._renderTabLabel(tab)); const labels = this.props.tabs.map(tab => this._renderTabLabel(tab));
const panel = this._renderTabPanel(this.props.tabs[this._getActiveTabIndex()]); const panel = this._renderTabPanel(this.props.tabs[this._getActiveTabIndex()]);
const tabbedViewClasses = classNames({
'mx_TabbedView': true,
'mx_TabbedView_tabsOnLeft': this.props.tabLocation == TabLocation.LEFT,
'mx_TabbedView_tabsOnTop': this.props.tabLocation == TabLocation.TOP,
});
return ( return (
<div className="mx_TabbedView"> <div className={tabbedViewClasses}>
<div className="mx_TabbedView_tabLabels"> <div className="mx_TabbedView_tabLabels">
{labels} {labels}
</div> </div>

View file

@ -555,9 +555,8 @@ class TimelinePanel extends React.Component<IProps, IState> {
// more than the timeout on userActiveRecently. // more than the timeout on userActiveRecently.
// //
const myUserId = MatrixClientPeg.get().credentials.userId; const myUserId = MatrixClientPeg.get().credentials.userId;
const sender = ev.sender ? ev.sender.userId : null;
callRMUpdated = false; callRMUpdated = false;
if (sender != myUserId && !UserActivity.sharedInstance().userActiveRecently()) { if (ev.getSender() !== myUserId && !UserActivity.sharedInstance().userActiveRecently()) {
updatedState.readMarkerVisible = true; updatedState.readMarkerVisible = true;
} else if (lastLiveEvent && this.getReadMarkerPosition() === 0) { } else if (lastLiveEvent && this.getReadMarkerPosition() === 0) {
// we know we're stuckAtBottom, so we can advance the RM // we know we're stuckAtBottom, so we can advance the RM
@ -863,7 +862,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
const myUserId = MatrixClientPeg.get().credentials.userId; const myUserId = MatrixClientPeg.get().credentials.userId;
for (i++; i < events.length; i++) { for (i++; i < events.length; i++) {
const ev = events[i]; const ev = events[i];
if (!ev.sender || ev.sender.userId != myUserId) { if (ev.getSender() !== myUserId) {
break; break;
} }
} }
@ -1051,6 +1050,8 @@ class TimelinePanel extends React.Component<IProps, IState> {
{ windowLimit: this.props.timelineCap }); { windowLimit: this.props.timelineCap });
const onLoaded = () => { const onLoaded = () => {
if (this.unmounted) return;
// clear the timeline min-height when // clear the timeline min-height when
// (re)loading the timeline // (re)loading the timeline
if (this.messagePanel.current) { if (this.messagePanel.current) {
@ -1092,6 +1093,8 @@ class TimelinePanel extends React.Component<IProps, IState> {
}; };
const onError = (error) => { const onError = (error) => {
if (this.unmounted) return;
this.setState({ timelineLoading: false }); this.setState({ timelineLoading: false });
console.error( console.error(
`Error loading timeline panel at ${eventId}: ${error}`, `Error loading timeline panel at ${eventId}: ${error}`,
@ -1333,8 +1336,9 @@ class TimelinePanel extends React.Component<IProps, IState> {
} }
const shouldIgnore = !!ev.status || // local echo const shouldIgnore = !!ev.status || // local echo
(ignoreOwn && ev.sender && ev.sender.userId == myUserId); // own message (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) { if (isWithoutTile || !node) {
// don't start counting if the event should be ignored, // don't start counting if the event should be ignored,

View file

@ -90,7 +90,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
}; };
OwnProfileStore.instance.on(UPDATE_EVENT, this.onProfileUpdate); OwnProfileStore.instance.on(UPDATE_EVENT, this.onProfileUpdate);
if (SettingsStore.getValue("feature_spaces")) { if (SpaceStore.spacesEnabled) {
SpaceStore.instance.on(UPDATE_SELECTED_SPACE, this.onSelectedSpaceUpdate); SpaceStore.instance.on(UPDATE_SELECTED_SPACE, this.onSelectedSpaceUpdate);
} }
@ -115,7 +115,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
if (this.dispatcherRef) defaultDispatcher.unregister(this.dispatcherRef); if (this.dispatcherRef) defaultDispatcher.unregister(this.dispatcherRef);
OwnProfileStore.instance.off(UPDATE_EVENT, this.onProfileUpdate); OwnProfileStore.instance.off(UPDATE_EVENT, this.onProfileUpdate);
this.tagStoreRef.remove(); this.tagStoreRef.remove();
if (SettingsStore.getValue("feature_spaces")) { if (SpaceStore.spacesEnabled) {
SpaceStore.instance.off(UPDATE_SELECTED_SPACE, this.onSelectedSpaceUpdate); SpaceStore.instance.off(UPDATE_SELECTED_SPACE, this.onSelectedSpaceUpdate);
} }
MatrixClientPeg.get().removeListener("Room", this.onRoom); MatrixClientPeg.get().removeListener("Room", this.onRoom);

View file

@ -17,15 +17,18 @@ limitations under the License.
import { Playback, PlaybackState } from "../../../voice/Playback"; import { Playback, PlaybackState } from "../../../voice/Playback";
import React, { ReactNode } from "react"; import React, { ReactNode } from "react";
import { UPDATE_EVENT } from "../../../stores/AsyncStore"; import { UPDATE_EVENT } from "../../../stores/AsyncStore";
import PlaybackWaveform from "./PlaybackWaveform";
import PlayPauseButton from "./PlayPauseButton"; import PlayPauseButton from "./PlayPauseButton";
import PlaybackClock from "./PlaybackClock"; import PlaybackClock from "./PlaybackClock";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import { TileShape } from "../rooms/EventTile";
import PlaybackWaveform from "./PlaybackWaveform";
interface IProps { interface IProps {
// Playback instance to render. Cannot change during component lifecycle: create // Playback instance to render. Cannot change during component lifecycle: create
// an all-new component instead. // an all-new component instead.
playback: Playback; playback: Playback;
tileShape?: TileShape;
} }
interface IState { interface IState {
@ -50,15 +53,22 @@ export default class RecordingPlayback extends React.PureComponent<IProps, IStat
this.props.playback.prepare(); this.props.playback.prepare();
} }
private get isWaveformable(): boolean {
return this.props.tileShape !== TileShape.Notif
&& this.props.tileShape !== TileShape.FileGrid
&& this.props.tileShape !== TileShape.Pinned;
}
private onPlaybackUpdate = (ev: PlaybackState) => { private onPlaybackUpdate = (ev: PlaybackState) => {
this.setState({ playbackPhase: ev }); this.setState({ playbackPhase: ev });
}; };
public render(): ReactNode { public render(): ReactNode {
return <div className='mx_MediaBody mx_VoiceMessagePrimaryContainer'> const shapeClass = !this.isWaveformable ? 'mx_VoiceMessagePrimaryContainer_noWaveform' : '';
return <div className={'mx_MediaBody mx_VoiceMessagePrimaryContainer ' + shapeClass}>
<PlayPauseButton playback={this.props.playback} playbackPhase={this.state.playbackPhase} /> <PlayPauseButton playback={this.props.playback} playbackPhase={this.state.playbackPhase} />
<PlaybackClock playback={this.props.playback} /> <PlaybackClock playback={this.props.playback} />
<PlaybackWaveform playback={this.props.playback} /> { this.isWaveformable && <PlaybackWaveform playback={this.props.playback} /> }
</div>; </div>;
} }
} }

View file

@ -41,7 +41,7 @@ import CaptchaForm from "./CaptchaForm";
* one HS whilst beign a guest on another). * one HS whilst beign a guest on another).
* loginType: the login type of the auth stage being attempted * loginType: the login type of the auth stage being attempted
* authSessionId: session id from the server * authSessionId: session id from the server
* clientSecret: The client secret in use for ID server auth sessions * clientSecret: The client secret in use for identity server auth sessions
* stageParams: params from the server for the stage being attempted * stageParams: params from the server for the stage being attempted
* errorText: error message from a previous attempt to authenticate * errorText: error message from a previous attempt to authenticate
* submitAuthDict: a function which will be called with the new auth dict * submitAuthDict: a function which will be called with the new auth dict
@ -54,8 +54,8 @@ import CaptchaForm from "./CaptchaForm";
* Defined keys for stages are: * Defined keys for stages are:
* m.login.email.identity: * m.login.email.identity:
* * emailSid: string representing the sid of the active * * emailSid: string representing the sid of the active
* verification session from the ID server, or * verification session from the identity server,
* null if no session is active. * or null if no session is active.
* fail: a function which should be called with an error object if an * fail: a function which should be called with an error object if an
* error occurred during the auth stage. This will cause the auth * error occurred during the auth stage. This will cause the auth
* session to be failed and the process to go back to the start. * session to be failed and the process to go back to the start.

View file

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

View file

@ -105,7 +105,7 @@ const BetaCard = ({ title: titleOverride, featureId }: IProps) => {
</div> </div>
<img src={image} alt="" /> <img src={image} alt="" />
</div> </div>
{ extraSettings && <div className="mx_BetaCard_relatedSettings"> { extraSettings && value && <div className="mx_BetaCard_relatedSettings">
{ extraSettings.map(key => ( { extraSettings.map(key => (
<SettingsFlag key={key} name={key} level={SettingLevel.DEVICE} /> <SettingsFlag key={key} name={key} level={SettingLevel.DEVICE} />
)) } )) }

View file

@ -53,7 +53,7 @@ export default class CallContextMenu extends React.Component<IProps> {
onTransferClick = () => { onTransferClick = () => {
Modal.createTrackedDialog( Modal.createTrackedDialog(
'Transfer Call', '', InviteDialog, { kind: KIND_CALL_TRANSFER, call: this.props.call }, 'Transfer Call', '', InviteDialog, { kind: KIND_CALL_TRANSFER, call: this.props.call },
/*className=*/null, /*isPriority=*/false, /*isStatic=*/true, /*className=*/"mx_InviteDialog_transferWrapper", /*isPriority=*/false, /*isStatic=*/true,
); );
this.props.onFinished(); this.props.onFinished();
}; };

View file

@ -15,11 +15,11 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import { _t } from '../../../languageHandler'; import AccessibleButton from "../elements/AccessibleButton";
import { ContextMenu, IProps as IContextMenuProps } from '../../structures/ContextMenu'; import { ContextMenu, IProps as IContextMenuProps } from '../../structures/ContextMenu';
import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call'; import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
import Field from "../elements/Field"; import Field from "../elements/Field";
import Dialpad from '../voip/DialPad'; import DialPad from '../voip/DialPad';
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
interface IProps extends IContextMenuProps { interface IProps extends IContextMenuProps {
@ -45,24 +45,29 @@ export default class DialpadContextMenu extends React.Component<IProps, IState>
this.setState({ value: this.state.value + digit }); this.setState({ value: this.state.value + digit });
}; };
onCancelClick = () => {
this.props.onFinished();
};
onChange = (ev) => { onChange = (ev) => {
this.setState({ value: ev.target.value }); this.setState({ value: ev.target.value });
}; };
render() { render() {
return <ContextMenu {...this.props}> return <ContextMenu {...this.props}>
<div className="mx_DialPadContextMenu_header"> <div className="mx_DialPadContextMenuWrapper">
<div> <div>
<span className="mx_DialPadContextMenu_title">{_t("Dial pad")}</span> <AccessibleButton className="mx_DialPadContextMenu_cancel" onClick={this.onCancelClick} />
</div>
<div className="mx_DialPadContextMenu_header">
<Field className="mx_DialPadContextMenu_dialled"
value={this.state.value} autoFocus={true}
onChange={this.onChange}
/>
</div>
<div className="mx_DialPadContextMenu_dialPad">
<DialPad onDigitPress={this.onDigitPress} hasDial={false} />
</div> </div>
<Field className="mx_DialPadContextMenu_dialled"
value={this.state.value} autoFocus={true}
onChange={this.onChange}
/>
</div>
<div className="mx_DialPadContextMenu_horizSep" />
<div className="mx_DialPadContextMenu_dialPad">
<Dialpad onDigitPress={this.onDigitPress} hasDialAndDelete={false} />
</div> </div>
</ContextMenu>; </ContextMenu>;
} }

View file

@ -43,6 +43,7 @@ import QueryMatcher from "../../../autocomplete/QueryMatcher";
import TruncatedList from "../elements/TruncatedList"; import TruncatedList from "../elements/TruncatedList";
import EntityTile from "../rooms/EntityTile"; import EntityTile from "../rooms/EntityTile";
import BaseAvatar from "../avatars/BaseAvatar"; import BaseAvatar from "../avatars/BaseAvatar";
import SpaceStore from "../../../stores/SpaceStore";
const AVATAR_SIZE = 30; const AVATAR_SIZE = 30;
@ -180,7 +181,7 @@ const ForwardDialog: React.FC<IProps> = ({ matrixClient: cli, event, permalinkCr
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const lcQuery = query.toLowerCase(); const lcQuery = query.toLowerCase();
const spacesEnabled = useFeatureEnabled("feature_spaces"); const spacesEnabled = SpaceStore.spacesEnabled;
const flairEnabled = useFeatureEnabled(UIFeature.Flair); const flairEnabled = useFeatureEnabled(UIFeature.Flair);
const previewLayout = useSettingValue<Layout>("layout"); const previewLayout = useSettingValue<Layout>("layout");

View file

@ -46,7 +46,7 @@ export default class IntegrationsImpossibleDialog extends React.Component {
<div className='mx_IntegrationsImpossibleDialog_content'> <div className='mx_IntegrationsImpossibleDialog_content'>
<p> <p>
{_t( {_t(
"Your %(brand)s doesn't allow you to use an Integration Manager to do this. " + "Your %(brand)s doesn't allow you to use an integration manager to do this. " +
"Please contact an admin.", "Please contact an admin.",
{ brand }, { brand },
)} )}

View file

@ -32,7 +32,6 @@ import Modal from "../../../Modal";
import { humanizeTime } from "../../../utils/humanize"; import { humanizeTime } from "../../../utils/humanize";
import createRoom, { import createRoom, {
canEncryptToAllUsers, canEncryptToAllUsers,
ensureDMExists,
findDMForUser, findDMForUser,
privateShouldBeEncrypted, privateShouldBeEncrypted,
} from "../../../createRoom"; } from "../../../createRoom";
@ -64,9 +63,15 @@ import { copyPlaintext, selectText } from "../../../utils/strings";
import * as ContextMenu from "../../structures/ContextMenu"; import * as ContextMenu from "../../structures/ContextMenu";
import { toRightOf } from "../../structures/ContextMenu"; import { toRightOf } from "../../structures/ContextMenu";
import GenericTextContextMenu from "../context_menus/GenericTextContextMenu"; import GenericTextContextMenu from "../context_menus/GenericTextContextMenu";
import { TransferCallPayload } from '../../../dispatcher/payloads/TransferCallPayload';
import Field from '../elements/Field';
import TabbedView, { Tab, TabLocation } from '../../structures/TabbedView';
import Dialpad from '../voip/DialPad';
import QuestionDialog from "./QuestionDialog"; import QuestionDialog from "./QuestionDialog";
import Spinner from "../elements/Spinner"; import Spinner from "../elements/Spinner";
import BaseDialog from "./BaseDialog"; import BaseDialog from "./BaseDialog";
import DialPadBackspaceButton from "../elements/DialPadBackspaceButton";
import SpaceStore from "../../../stores/SpaceStore";
// we have a number of types defined from the Matrix spec which can't reasonably be altered here. // we have a number of types defined from the Matrix spec which can't reasonably be altered here.
/* eslint-disable camelcase */ /* eslint-disable camelcase */
@ -79,11 +84,19 @@ interface IRecentUser {
export const KIND_DM = "dm"; export const KIND_DM = "dm";
export const KIND_INVITE = "invite"; export const KIND_INVITE = "invite";
// NB. This dialog needs the 'mx_InviteDialog_transferWrapper' wrapper class to have the correct
// padding on the bottom (because all modals have 24px padding on all sides), so this needs to
// be passed when creating the modal
export const KIND_CALL_TRANSFER = "call_transfer"; export const KIND_CALL_TRANSFER = "call_transfer";
const INITIAL_ROOMS_SHOWN = 3; // Number of rooms to show at first const INITIAL_ROOMS_SHOWN = 3; // Number of rooms to show at first
const INCREMENT_ROOMS_SHOWN = 5; // Number of rooms to add when 'show more' is clicked const INCREMENT_ROOMS_SHOWN = 5; // Number of rooms to add when 'show more' is clicked
enum TabId {
UserDirectory = 'users',
DialPad = 'dialpad',
}
// This is the interface that is expected by various components in the Invite Dialog and RoomInvite. // This is the interface that is expected by various components in the Invite Dialog and RoomInvite.
// It is a bit awkward because it also matches the RoomMember class from the js-sdk with some extra support // It is a bit awkward because it also matches the RoomMember class from the js-sdk with some extra support
// for 3PIDs/email addresses. // for 3PIDs/email addresses.
@ -109,11 +122,11 @@ export abstract class Member {
class DirectoryMember extends Member { class DirectoryMember extends Member {
private readonly _userId: string; private readonly _userId: string;
private readonly displayName: string; private readonly displayName?: string;
private readonly avatarUrl: string; private readonly avatarUrl?: string;
// eslint-disable-next-line camelcase // eslint-disable-next-line camelcase
constructor(userDirResult: { user_id: string, display_name: string, avatar_url: string }) { constructor(userDirResult: { user_id: string, display_name?: string, avatar_url?: string }) {
super(); super();
this._userId = userDirResult.user_id; this._userId = userDirResult.user_id;
this.displayName = userDirResult.display_name; this.displayName = userDirResult.display_name;
@ -356,6 +369,8 @@ interface IInviteDialogState {
canUseIdentityServer: boolean; canUseIdentityServer: boolean;
tryingIdentityServer: boolean; tryingIdentityServer: boolean;
consultFirst: boolean; consultFirst: boolean;
dialPadValue: string;
currentTabId: TabId;
// These two flags are used for the 'Go' button to communicate what is going on. // These two flags are used for the 'Go' button to communicate what is going on.
busy: boolean; busy: boolean;
@ -370,7 +385,7 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
}; };
private closeCopiedTooltip: () => void; private closeCopiedTooltip: () => void;
private debounceTimer: NodeJS.Timeout = null; // actually number because we're in the browser private debounceTimer: number = null; // actually number because we're in the browser
private editorRef = createRef<HTMLInputElement>(); private editorRef = createRef<HTMLInputElement>();
private unmounted = false; private unmounted = false;
@ -407,6 +422,8 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
canUseIdentityServer: !!MatrixClientPeg.get().getIdentityServerUrl(), canUseIdentityServer: !!MatrixClientPeg.get().getIdentityServerUrl(),
tryingIdentityServer: false, tryingIdentityServer: false,
consultFirst: false, consultFirst: false,
dialPadValue: '',
currentTabId: TabId.UserDirectory,
// These two flags are used for the 'Go' button to communicate what is going on. // These two flags are used for the 'Go' button to communicate what is going on.
busy: false, busy: false,
@ -768,44 +785,32 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
}; };
private transferCall = async () => { private transferCall = async () => {
this.convertFilter(); if (this.state.currentTabId == TabId.UserDirectory) {
const targets = this.convertFilter(); this.convertFilter();
const targetIds = targets.map(t => t.userId); const targets = this.convertFilter();
if (targetIds.length > 1) { const targetIds = targets.map(t => t.userId);
this.setState({ if (targetIds.length > 1) {
errorText: _t("A call can only be transferred to a single user."),
});
}
if (this.state.consultFirst) {
const dmRoomId = await ensureDMExists(MatrixClientPeg.get(), targetIds[0]);
dis.dispatch({
action: 'place_call',
type: this.props.call.type,
room_id: dmRoomId,
transferee: this.props.call,
});
dis.dispatch({
action: 'view_room',
room_id: dmRoomId,
should_peek: false,
joining: false,
});
this.props.onFinished();
} else {
this.setState({ busy: true });
try {
await this.props.call.transfer(targetIds[0]);
this.setState({ busy: false });
this.props.onFinished();
} catch (e) {
this.setState({ this.setState({
busy: false, errorText: _t("A call can only be transferred to a single user."),
errorText: _t("Failed to transfer call"),
}); });
return;
} }
dis.dispatch({
action: Action.TransferCallToMatrixID,
call: this.props.call,
destination: targetIds[0],
consultFirst: this.state.consultFirst,
} as TransferCallPayload);
} else {
dis.dispatch({
action: Action.TransferCallToPhoneNumber,
call: this.props.call,
destination: this.state.dialPadValue,
consultFirst: this.state.consultFirst,
} as TransferCallPayload);
} }
this.props.onFinished();
}; };
private onKeyDown = (e) => { private onKeyDown = (e) => {
@ -827,6 +832,10 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
} }
}; };
private onCancel = () => {
this.props.onFinished([]);
};
private updateSuggestions = async (term) => { private updateSuggestions = async (term) => {
MatrixClientPeg.get().searchUserDirectory({ term }).then(async r => { MatrixClientPeg.get().searchUserDirectory({ term }).then(async r => {
if (term !== this.state.filterText) { if (term !== this.state.filterText) {
@ -962,11 +971,14 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
private toggleMember = (member: Member) => { private toggleMember = (member: Member) => {
if (!this.state.busy) { if (!this.state.busy) {
let filterText = this.state.filterText; let filterText = this.state.filterText;
const targets = this.state.targets.map(t => t); // cheap clone for mutation let targets = this.state.targets.map(t => t); // cheap clone for mutation
const idx = targets.indexOf(member); const idx = targets.indexOf(member);
if (idx >= 0) { if (idx >= 0) {
targets.splice(idx, 1); targets.splice(idx, 1);
} else { } else {
if (this.props.kind === KIND_CALL_TRANSFER && targets.length > 0) {
targets = [];
}
targets.push(member); targets.push(member);
filterText = ""; // clear the filter when the user accepts a suggestion filterText = ""; // clear the filter when the user accepts a suggestion
} }
@ -1189,6 +1201,11 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
} }
private renderEditor() { private renderEditor() {
const hasPlaceholder = (
this.props.kind == KIND_CALL_TRANSFER &&
this.state.targets.length === 0 &&
this.state.filterText.length === 0
);
const targets = this.state.targets.map(t => ( const targets = this.state.targets.map(t => (
<DMUserTile member={t} onRemove={!this.state.busy && this.removeMember} key={t.userId} /> <DMUserTile member={t} onRemove={!this.state.busy && this.removeMember} key={t.userId} />
)); ));
@ -1201,8 +1218,9 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
ref={this.editorRef} ref={this.editorRef}
onPaste={this.onPaste} onPaste={this.onPaste}
autoFocus={true} autoFocus={true}
disabled={this.state.busy} disabled={this.state.busy || (this.props.kind == KIND_CALL_TRANSFER && this.state.targets.length > 0)}
autoComplete="off" autoComplete="off"
placeholder={hasPlaceholder ? _t("Search") : null}
/> />
); );
return ( return (
@ -1249,6 +1267,28 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
} }
} }
private onDialFormSubmit = ev => {
ev.preventDefault();
this.transferCall();
};
private onDialChange = ev => {
this.setState({ dialPadValue: ev.currentTarget.value });
};
private onDigitPress = digit => {
this.setState({ dialPadValue: this.state.dialPadValue + digit });
};
private onDeletePress = () => {
if (this.state.dialPadValue.length === 0) return;
this.setState({ dialPadValue: this.state.dialPadValue.slice(0, -1) });
};
private onTabChange = (tabId: TabId) => {
this.setState({ currentTabId: tabId });
};
private async onLinkClick(e) { private async onLinkClick(e) {
e.preventDefault(); e.preventDefault();
selectText(e.target); selectText(e.target);
@ -1278,12 +1318,16 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
let helpText; let helpText;
let buttonText; let buttonText;
let goButtonFn; let goButtonFn;
let consultConnectSection;
let extraSection; let extraSection;
let footer; let footer;
let keySharingWarning = <span />; let keySharingWarning = <span />;
const identityServersEnabled = SettingsStore.getValue(UIFeature.IdentityServer); const identityServersEnabled = SettingsStore.getValue(UIFeature.IdentityServer);
const hasSelection = this.state.targets.length > 0
|| (this.state.filterText && this.state.filterText.includes('@'));
const cli = MatrixClientPeg.get(); const cli = MatrixClientPeg.get();
const userId = cli.getUserId(); const userId = cli.getUserId();
if (this.props.kind === KIND_DM) { if (this.props.kind === KIND_DM) {
@ -1364,7 +1408,7 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
</div>; </div>;
} else if (this.props.kind === KIND_INVITE) { } else if (this.props.kind === KIND_INVITE) {
const room = MatrixClientPeg.get()?.getRoom(this.props.roomId); const room = MatrixClientPeg.get()?.getRoom(this.props.roomId);
const isSpace = SettingsStore.getValue("feature_spaces") && room?.isSpaceRoom(); const isSpace = SpaceStore.spacesEnabled && room?.isSpaceRoom();
title = isSpace title = isSpace
? _t("Invite to %(spaceName)s", { ? _t("Invite to %(spaceName)s", {
spaceName: room.name || _t("Unnamed Space"), spaceName: room.name || _t("Unnamed Space"),
@ -1421,23 +1465,116 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
} }
} else if (this.props.kind === KIND_CALL_TRANSFER) { } else if (this.props.kind === KIND_CALL_TRANSFER) {
title = _t("Transfer"); title = _t("Transfer");
buttonText = _t("Transfer");
goButtonFn = this.transferCall; consultConnectSection = <div className="mx_InviteDialog_transferConsultConnect">
footer = <div>
<label> <label>
<input type="checkbox" checked={this.state.consultFirst} onChange={this.onConsultFirstChange} /> <input type="checkbox" checked={this.state.consultFirst} onChange={this.onConsultFirstChange} />
{_t("Consult first")} {_t("Consult first")}
</label> </label>
<AccessibleButton
kind="secondary"
onClick={this.onCancel}
className='mx_InviteDialog_transferConsultConnect_pushRight'
>
{_t("Cancel")}
</AccessibleButton>
<AccessibleButton
kind="primary"
onClick={this.transferCall}
className='mx_InviteDialog_transferButton'
disabled={!hasSelection && this.state.dialPadValue === ''}
>
{_t("Transfer")}
</AccessibleButton>
</div>; </div>;
} else { } else {
console.error("Unknown kind of InviteDialog: " + this.props.kind); console.error("Unknown kind of InviteDialog: " + this.props.kind);
} }
const hasSelection = this.state.targets.length > 0 const goButton = this.props.kind == KIND_CALL_TRANSFER ? null : <AccessibleButton
|| (this.state.filterText && this.state.filterText.includes('@')); kind="primary"
onClick={goButtonFn}
className='mx_InviteDialog_goButton'
disabled={this.state.busy || !hasSelection}
>
{buttonText}
</AccessibleButton>;
const usersSection = <React.Fragment>
<p className='mx_InviteDialog_helpText'>{helpText}</p>
<div className='mx_InviteDialog_addressBar'>
{this.renderEditor()}
<div className='mx_InviteDialog_buttonAndSpinner'>
{goButton}
{spinner}
</div>
</div>
{keySharingWarning}
{this.renderIdentityServerWarning()}
<div className='error'>{this.state.errorText}</div>
<div className='mx_InviteDialog_userSections'>
{this.renderSection('recents')}
{this.renderSection('suggestions')}
{extraSection}
</div>
{footer}
</React.Fragment>;
let dialogContent;
if (this.props.kind === KIND_CALL_TRANSFER) {
const tabs = [];
tabs.push(new Tab(
TabId.UserDirectory, _td("User Directory"), 'mx_InviteDialog_userDirectoryIcon', usersSection,
));
const backspaceButton = (
<DialPadBackspaceButton onBackspacePress={this.onDeletePress} />
);
// Only show the backspace button if the field has content
let dialPadField;
if (this.state.dialPadValue.length !== 0) {
dialPadField = <Field className="mx_InviteDialog_dialPadField" id="dialpad_number"
value={this.state.dialPadValue}
autoFocus={true}
onChange={this.onDialChange}
postfixComponent={backspaceButton}
/>;
} else {
dialPadField = <Field className="mx_InviteDialog_dialPadField" id="dialpad_number"
value={this.state.dialPadValue}
autoFocus={true}
onChange={this.onDialChange}
/>;
}
const dialPadSection = <div className="mx_InviteDialog_dialPad">
<form onSubmit={this.onDialFormSubmit}>
{dialPadField}
</form>
<Dialpad hasDial={false}
onDigitPress={this.onDigitPress} onDeletePress={this.onDeletePress}
/>
</div>;
tabs.push(new Tab(TabId.DialPad, _td("Dial pad"), 'mx_InviteDialog_dialPadIcon', dialPadSection));
dialogContent = <React.Fragment>
<TabbedView tabs={tabs} initialTabId={this.state.currentTabId}
tabLocation={TabLocation.TOP} onChange={this.onTabChange}
/>
{consultConnectSection}
</React.Fragment>;
} else {
dialogContent = <React.Fragment>
{usersSection}
{consultConnectSection}
</React.Fragment>;
}
return ( return (
<BaseDialog <BaseDialog
className={classNames("mx_InviteDialog", { className={classNames({
mx_InviteDialog_transfer: this.props.kind === KIND_CALL_TRANSFER,
mx_InviteDialog_other: this.props.kind !== KIND_CALL_TRANSFER,
mx_InviteDialog_hasFooter: !!footer, mx_InviteDialog_hasFooter: !!footer,
})} })}
hasCancel={true} hasCancel={true}
@ -1445,30 +1582,7 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
title={title} title={title}
> >
<div className='mx_InviteDialog_content'> <div className='mx_InviteDialog_content'>
<p className='mx_InviteDialog_helpText'>{helpText}</p> {dialogContent}
<div className='mx_InviteDialog_addressBar'>
{this.renderEditor()}
<div className='mx_InviteDialog_buttonAndSpinner'>
<AccessibleButton
kind="primary"
onClick={goButtonFn}
className='mx_InviteDialog_goButton'
disabled={this.state.busy || !hasSelection}
>
{buttonText}
</AccessibleButton>
{spinner}
</div>
</div>
{keySharingWarning}
{this.renderIdentityServerWarning()}
<div className='error'>{this.state.errorText}</div>
<div className='mx_InviteDialog_userSections'>
{this.renderSection('recents')}
{this.renderSection('suggestions')}
{extraSection}
</div>
{footer}
</div> </div>
</BaseDialog> </BaseDialog>
); );

View file

@ -35,7 +35,7 @@ import SettingsStore from "../../../settings/SettingsStore";
import { UIFeature } from "../../../settings/UIFeature"; import { UIFeature } from "../../../settings/UIFeature";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import BaseDialog from "./BaseDialog"; import BaseDialog from "./BaseDialog";
import GenericTextContextMenu from "../context_menus/GenericTextContextMenu.js"; import GenericTextContextMenu from "../context_menus/GenericTextContextMenu";
const socials = [ const socials = [
{ {

View file

@ -90,9 +90,9 @@ export default class TermsDialog extends React.PureComponent<ITermsDialogProps,
private nameForServiceType(serviceType: SERVICE_TYPES, host: string): JSX.Element { private nameForServiceType(serviceType: SERVICE_TYPES, host: string): JSX.Element {
switch (serviceType) { switch (serviceType) {
case SERVICE_TYPES.IS: case SERVICE_TYPES.IS:
return <div>{_t("Identity Server")}<br />({host})</div>; return <div>{_t("Identity server")}<br />({host})</div>;
case SERVICE_TYPES.IM: case SERVICE_TYPES.IM:
return <div>{_t("Integration Manager")}<br />({host})</div>; return <div>{_t("Integration manager")}<br />({host})</div>;
} }
} }

View file

@ -21,7 +21,7 @@ import { replaceableComponent } from "../../../utils/replaceableComponent";
import { VerificationRequest } from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest"; import { VerificationRequest } from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest";
import BaseDialog from "./BaseDialog"; import BaseDialog from "./BaseDialog";
import EncryptionPanel from "../right_panel/EncryptionPanel"; import EncryptionPanel from "../right_panel/EncryptionPanel";
import { User } from 'matrix-js-sdk'; import { User } from 'matrix-js-sdk/src/models/user';
interface IProps { interface IProps {
verificationRequest: VerificationRequest; verificationRequest: VerificationRequest;

View file

@ -17,6 +17,7 @@ limitations under the License.
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { MatrixError } from "matrix-js-sdk/src/http-api"; import { MatrixError } from "matrix-js-sdk/src/http-api";
import { IProtocol } from "matrix-js-sdk/src/client";
import { MatrixClientPeg } from '../../../MatrixClientPeg'; import { MatrixClientPeg } from '../../../MatrixClientPeg';
import { instanceForInstanceId } from '../../../utils/DirectoryUtils'; import { instanceForInstanceId } from '../../../utils/DirectoryUtils';
@ -83,30 +84,6 @@ const validServer = withValidation<undefined, { error?: MatrixError }>({
], ],
}); });
/* eslint-disable camelcase */
export interface IFieldType {
regexp: string;
placeholder: string;
}
export interface IInstance {
desc: string;
icon?: string;
fields: object;
network_id: string;
// XXX: this is undocumented but we rely on it.
instance_id: string;
}
export interface IProtocol {
user_fields: string[];
location_fields: string[];
icon: string;
field_types: Record<string, IFieldType>;
instances: IInstance[];
}
/* eslint-enable camelcase */
export type Protocols = Record<string, IProtocol>; export type Protocols = Record<string, IProtocol>;
interface IProps { interface IProps {

View file

@ -114,7 +114,7 @@ export default class AppPermission extends React.Component {
// Due to i18n limitations, we can't dedupe the code for variables in these two messages. // Due to i18n limitations, we can't dedupe the code for variables in these two messages.
const warning = this.state.isWrapped const warning = this.state.isWrapped
? _t("Using this widget may share data <helpIcon /> with %(widgetDomain)s & your Integration Manager.", ? _t("Using this widget may share data <helpIcon /> with %(widgetDomain)s & your integration manager.",
{ widgetDomain: this.state.widgetDomain }, { helpIcon: () => warningTooltip }) { widgetDomain: this.state.widgetDomain }, { helpIcon: () => warningTooltip })
: _t("Using this widget may share data <helpIcon /> with %(widgetDomain)s.", : _t("Using this widget may share data <helpIcon /> with %(widgetDomain)s.",
{ widgetDomain: this.state.widgetDomain }, { helpIcon: () => warningTooltip }); { widgetDomain: this.state.widgetDomain }, { helpIcon: () => warningTooltip });

View file

@ -0,0 +1,31 @@
/*
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 * as React from "react";
import AccessibleButton from "./AccessibleButton";
interface IProps {
// Callback for when the button is pressed
onBackspacePress: () => void;
}
export default class DialPadBackspaceButton extends React.PureComponent<IProps> {
render() {
return <div className="mx_DialPadBackspaceButtonWrapper">
<AccessibleButton className="mx_DialPadBackspaceButton" onClick={this.props.onBackspacePress} />
</div>;
}
}

View file

@ -33,6 +33,7 @@ import { replaceableComponent } from "../../../utils/replaceableComponent";
import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks"; import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { normalizeWheelEvent } from "../../../utils/Mouse"; import { normalizeWheelEvent } from "../../../utils/Mouse";
import { IDialogProps } from '../dialogs/IDialogProps';
// Max scale to keep gaps around the image // Max scale to keep gaps around the image
const MAX_SCALE = 0.95; const MAX_SCALE = 0.95;
@ -43,14 +44,13 @@ const ZOOM_COEFFICIENT = 0.0025;
// If we have moved only this much we can zoom // If we have moved only this much we can zoom
const ZOOM_DISTANCE = 10; const ZOOM_DISTANCE = 10;
interface IProps { interface IProps extends IDialogProps {
src: string; // the source of the image being displayed src: string; // the source of the image being displayed
name?: string; // the main title ('name') for the image name?: string; // the main title ('name') for the image
link?: string; // the link (if any) applied to the name of the image link?: string; // the link (if any) applied to the name of the image
width?: number; // width of the image src in pixels width?: number; // width of the image src in pixels
height?: number; // height of the image src in pixels height?: number; // height of the image src in pixels
fileSize?: number; // size of the image src in bytes fileSize?: number; // size of the image src in bytes
onFinished(): void; // callback when the lightbox is dismissed
// the event (if any) that the Image is displaying. Used for event-specific stuff like // the event (if any) that the Image is displaying. Used for event-specific stuff like
// redactions, senders, timestamps etc. Other descriptors are taken from the explicit // redactions, senders, timestamps etc. Other descriptors are taken from the explicit
@ -452,6 +452,8 @@ export default class ImageView extends React.Component<IProps, IState> {
<div className="mx_ImageView_panel"> <div className="mx_ImageView_panel">
{ info } { info }
<div className="mx_ImageView_toolbar"> <div className="mx_ImageView_toolbar">
{ zoomOutButton }
{ zoomInButton }
<AccessibleTooltipButton <AccessibleTooltipButton
className="mx_ImageView_button mx_ImageView_button_rotateCCW" className="mx_ImageView_button mx_ImageView_button_rotateCCW"
title={_t("Rotate Left")} title={_t("Rotate Left")}
@ -462,8 +464,6 @@ export default class ImageView extends React.Component<IProps, IState> {
title={_t("Rotate Right")} title={_t("Rotate Right")}
onClick={this.onRotateClockwiseClick}> onClick={this.onRotateClockwiseClick}>
</AccessibleTooltipButton> </AccessibleTooltipButton>
{ zoomOutButton }
{ zoomInButton }
<AccessibleTooltipButton <AccessibleTooltipButton
className="mx_ImageView_button mx_ImageView_button_download" className="mx_ImageView_button mx_ImageView_button_download"
title={_t("Download")} title={_t("Download")}
@ -488,8 +488,8 @@ export default class ImageView extends React.Component<IProps, IState> {
> >
<img <img
src={this.props.src} src={this.props.src}
title={this.props.name}
style={style} style={style}
alt={this.props.name}
ref={this.image} ref={this.image}
className="mx_ImageView_image" className="mx_ImageView_image"
draggable={true} draggable={true}

View file

@ -32,7 +32,7 @@ interface IProps {
hasAvatar: boolean; hasAvatar: boolean;
noAvatarLabel?: string; noAvatarLabel?: string;
hasAvatarLabel?: string; hasAvatarLabel?: string;
setAvatarUrl(url: string): Promise<void>; setAvatarUrl(url: string): Promise<unknown>;
} }
const MiniAvatarUploader: React.FC<IProps> = ({ hasAvatar, hasAvatarLabel, noAvatarLabel, setAvatarUrl, children }) => { const MiniAvatarUploader: React.FC<IProps> = ({ hasAvatar, hasAvatarLabel, noAvatarLabel, setAvatarUrl, children }) => {

View file

@ -1,7 +1,6 @@
/* /*
Copyright 2017 New Vector Ltd Copyright 2017 - 2021 The Matrix.org Foundation C.I.C.
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
Copyright 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -15,77 +14,73 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import * as sdk from '../../../index';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import PropTypes from 'prop-types';
import dis from '../../../dispatcher/dispatcher'; import dis from '../../../dispatcher/dispatcher';
import { wantsDateSeparator } from '../../../DateUtils';
import { MatrixEvent } from 'matrix-js-sdk/src/models/event'; import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
import { makeUserPermalink, RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks"; import { makeUserPermalink, RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
import SettingsStore from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
import { LayoutPropType } from "../../../settings/Layout"; import { Layout } from "../../../settings/Layout";
import escapeHtml from "escape-html"; import escapeHtml from "escape-html";
import MatrixClientContext from "../../../contexts/MatrixClientContext"; import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { getUserNameColorClass } from "../../../utils/FormattingUtils";
import { Action } from "../../../dispatcher/actions"; import { Action } from "../../../dispatcher/actions";
import sanitizeHtml from "sanitize-html"; import sanitizeHtml from "sanitize-html";
import { UIFeature } from "../../../settings/UIFeature";
import { PERMITTED_URL_SCHEMES } from "../../../HtmlUtils"; import { PERMITTED_URL_SCHEMES } from "../../../HtmlUtils";
import { replaceableComponent } from "../../../utils/replaceableComponent"; 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;
forExport?: 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 // 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 // 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. // be low as each event being loaded (after the first) is triggered by an explicit user action.
@replaceableComponent("views.elements.ReplyThread") @replaceableComponent("views.elements.ReplyThread")
export default class ReplyThread extends React.Component { export default class ReplyThread extends React.Component<IProps, IState> {
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,
forExport: PropTypes.bool,
// Whether to always show a timestamp
alwaysShowTimestamps: PropTypes.bool,
};
static contextType = MatrixClientContext; static contextType = MatrixClientContext;
private unmounted = false;
private room: Room;
constructor(props, context) { constructor(props, context) {
super(props, context); super(props, context);
this.state = { this.state = {
// The loaded events to be rendered as linear-replies
events: [], events: [],
// The latest loaded event which has not yet been shown
loadedEv: null, loadedEv: null,
// Whether the component is still loading more events
loading: true, loading: true,
// Whether as error was encountered fetching a replied to event.
err: false, err: false,
}; };
this.unmounted = false; this.room = this.context.getRoom(this.props.parentEv.getRoomId());
if (!this.props.forExport) {
this.context.on("Event.replaced", this.onEventReplaced);
this.room = this.context.getRoom(this.props.parentEv.getRoomId());
this.room.on("Room.redaction", this.onRoomRedaction);
this.room.on("Room.redactionCancelled", this.onRoomRedaction);
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; if (!ev || ev.isRedacted()) return;
// XXX: For newer relations (annotations, replacements, etc.), we now // XXX: For newer relations (annotations, replacements, etc.), we now
@ -101,7 +96,7 @@ export default class ReplyThread extends React.Component {
} }
// Part of Replies fallback support // 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. // Removes lines beginning with `> ` until you reach one that doesn't.
const lines = body.split('\n'); const lines = body.split('\n');
while (lines.length && lines[0].startsWith('> ')) lines.shift(); while (lines.length && lines[0].startsWith('> ')) lines.shift();
@ -111,7 +106,7 @@ export default class ReplyThread extends React.Component {
} }
// Part of Replies fallback support // 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 // Sanitize the original HTML for inclusion in <mx-reply>. We allow
// any HTML, since the original sender could use special tags that we // 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 // don't recognize, but want to pass along to any recipients who do
@ -133,7 +128,10 @@ export default class ReplyThread extends React.Component {
} }
// Part of Replies fallback support // Part of Replies fallback support
static getNestedReplyText(ev, permalinkCreator) { public static getNestedReplyText(
ev: MatrixEvent,
permalinkCreator: RoomPermalinkCreator,
): { body: string, html: string } {
if (!ev) return null; if (!ev) return null;
let { body, formatted_body: html } = ev.getContent(); let { body, formatted_body: html } = ev.getContent();
@ -209,7 +207,7 @@ export default class ReplyThread extends React.Component {
return { body, html }; return { body, html };
} }
static makeReplyMixIn(ev) { public static makeReplyMixIn(ev: MatrixEvent) {
if (!ev) return {}; if (!ev) return {};
return { return {
'm.relates_to': { 'm.relates_to': {
@ -220,10 +218,16 @@ export default class ReplyThread extends React.Component {
}; };
} }
static makeThread(parentEv, onHeightChanged, permalinkCreator, ref, layout, forExport, alwaysShowTimestamps) { public static makeThread(
if (!ReplyThread.getParentEventId(parentEv)) { parentEv: MatrixEvent,
return null; onHeightChanged: () => void,
} permalinkCreator: RoomPermalinkCreator,
ref: React.RefObject<ReplyThread>,
layout: Layout,
forExport: boolean,
alwaysShowTimestamps: boolean,
): JSX.Element {
if (!ReplyThread.getParentEventId(parentEv)) return null;
return <ReplyThread return <ReplyThread
parentEv={parentEv} parentEv={parentEv}
forExport={forExport} forExport={forExport}
@ -245,37 +249,9 @@ export default class ReplyThread extends React.Component {
componentWillUnmount() { componentWillUnmount() {
this.unmounted = true; this.unmounted = true;
this.context.removeListener("Event.replaced", this.onEventReplaced);
if (this.room) {
this.room.removeListener("Room.redaction", this.onRoomRedaction);
this.room.removeListener("Room.redactionCancelled", this.onRoomRedaction);
}
} }
updateForEventId = (eventId) => { private async initialize(): Promise<void> {
if (this.state.events.some(event => event.getId() === eventId)) {
this.forceUpdate();
}
};
onEventReplaced = (ev) => {
if (this.unmounted) return;
// If one of the events we are rendering gets replaced, force a re-render
this.updateForEventId(ev.getId());
};
onRoomRedaction = (ev) => {
if (this.unmounted) return;
const eventId = ev.getAssociatedId();
if (!eventId) return;
// If one of the events we are rendering gets redacted, force a re-render
this.updateForEventId(eventId);
};
async initialize() {
const { parentEv } = this.props; const { parentEv } = this.props;
// at time of making this component we checked that props.parentEv has a parentEventId // at time of making this component we checked that props.parentEv has a parentEventId
const ev = await this.getEvent(ReplyThread.getParentEventId(parentEv)); const ev = await this.getEvent(ReplyThread.getParentEventId(parentEv));
@ -294,7 +270,7 @@ export default class ReplyThread extends React.Component {
} }
} }
async getNextEvent(ev) { private async getNextEvent(ev: MatrixEvent): Promise<MatrixEvent> {
try { try {
const inReplyToEventId = ReplyThread.getParentEventId(ev); const inReplyToEventId = ReplyThread.getParentEventId(ev);
return await this.getEvent(inReplyToEventId); return await this.getEvent(inReplyToEventId);
@ -303,7 +279,7 @@ export default class ReplyThread extends React.Component {
} }
} }
async getEvent(eventId) { private async getEvent(eventId: string): Promise<MatrixEvent> {
if (!eventId) return null; if (!eventId) return null;
const event = this.room.findEventById(eventId); const event = this.room.findEventById(eventId);
if (event) return event; if (event) return event;
@ -320,15 +296,15 @@ export default class ReplyThread extends React.Component {
return this.room.findEventById(eventId); return this.room.findEventById(eventId);
} }
canCollapse() { public canCollapse = (): boolean => {
return this.state.events.length > 1; return this.state.events.length > 1;
} };
collapse() { public collapse = (): void => {
this.initialize(); this.initialize();
} };
async onQuoteClick() { private onQuoteClick = async (): Promise<void> => {
const events = [this.state.loadedEv, ...this.state.events]; const events = [this.state.loadedEv, ...this.state.events];
let loadedEv = null; let loadedEv = null;
@ -342,6 +318,10 @@ export default class ReplyThread extends React.Component {
}); });
dis.fire(Action.FocusSendMessageComposer); dis.fire(Action.FocusSendMessageComposer);
};
private getReplyThreadColorClass(ev: MatrixEvent): string {
return getUserNameColorClass(ev.getSender()).replace("Username", "ReplyThread");
} }
render() { render() {
@ -356,9 +336,8 @@ export default class ReplyThread extends React.Component {
</blockquote>; </blockquote>;
} else if (this.state.loadedEv) { } else if (this.state.loadedEv) {
const ev = this.state.loadedEv; const ev = this.state.loadedEv;
const Pill = sdk.getComponent('elements.Pill');
const room = this.context.getRoom(ev.getRoomId()); const room = this.context.getRoom(ev.getRoomId());
header = <blockquote className="mx_ReplyThread"> header = <blockquote className={`mx_ReplyThread ${this.getReplyThreadColorClass(ev)}`}>
{ {
_t('<a>In reply to</a> <pill>', {}, { _t('<a>In reply to</a> <pill>', {}, {
'a': (sub) => <a onClick={this.onQuoteClick} className="mx_ReplyThread_show">{ sub }</a>, 'a': (sub) => <a onClick={this.onQuoteClick} className="mx_ReplyThread_show">{ sub }</a>,
@ -379,33 +358,15 @@ export default class ReplyThread extends React.Component {
In reply to <a className="mx_reply_anchor" href={`#${eventId}`} scroll-to={eventId}>this message</a> In reply to <a className="mx_reply_anchor" href={`#${eventId}`} scroll-to={eventId}>this message</a>
</p>; </p>;
} else if (this.state.loading) { } else if (this.state.loading) {
const Spinner = sdk.getComponent("elements.Spinner");
header = <Spinner w={16} h={16} />; header = <Spinner w={16} h={16} />;
} }
const EventTile = sdk.getComponent('views.rooms.EventTile');
const DateSeparator = sdk.getComponent('messages.DateSeparator');
const evTiles = this.state.events.map((ev) => { const evTiles = this.state.events.map((ev) => {
let dateSep = null; return <blockquote className={`mx_ReplyThread ${this.getReplyThreadColorClass(ev)}`} key={ev.getId()}>
<ReplyTile
if (wantsDateSeparator(this.props.parentEv.getDate(), ev.getDate())) {
dateSep = <a href={this.props.url}><DateSeparator ts={ev.getTs()} /></a>;
}
return <blockquote className="mx_ReplyThread" key={ev.getId()}>
{ dateSep }
<EventTile
mxEvent={ev} mxEvent={ev}
tileShape="reply"
onHeightChanged={this.props.onHeightChanged} onHeightChanged={this.props.onHeightChanged}
permalinkCreator={this.props.permalinkCreator} permalinkCreator={this.props.permalinkCreator}
isRedacted={ev.isRedacted()}
isTwelveHour={SettingsStore.getValue("showTwelveHourTimestamps")}
layout={this.props.layout}
alwaysShowTimestamps={this.props.alwaysShowTimestamps}
enableFlair={SettingsStore.getValue(UIFeature.Flair)}
replacingEventId={ev.replacingEventId()}
as="div"
/> />
</blockquote>; </blockquote>;
}); });

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

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

@ -1,5 +1,5 @@
/* /*
Copyright 2015, 2016, 2018, 2021 The Matrix.org Foundation C.I.C. Copyright 2015 - 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -24,6 +24,7 @@ import AccessibleButton from "../elements/AccessibleButton";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import { mediaFromContent } from "../../../customisations/Media"; import { mediaFromContent } from "../../../customisations/Media";
import ErrorDialog from "../dialogs/ErrorDialog"; import ErrorDialog from "../dialogs/ErrorDialog";
import { TileShape } from "../rooms/EventTile";
let downloadIconUrl; // cached copy of the download.svg asset for the sandboxed iframe later on let downloadIconUrl; // cached copy of the download.svg asset for the sandboxed iframe later on
@ -89,6 +90,35 @@ function computedStyle(element) {
return cssText; return cssText;
} }
/**
* Extracts a human readable label for the file attachment to use as
* link text.
*
* @param {Object} content The "content" key of the matrix event.
* @param {boolean} withSize Whether to include size information. Default true.
* @return {string} the human readable link text for the attachment.
*/
export function presentableTextForFile(content, withSize = true) {
let linkText = _t("Attachment");
if (content.body && content.body.length > 0) {
// The content body should be the name of the file including a
// file extension.
linkText = content.body;
}
if (content.info && content.info.size && withSize) {
// If we know the size of the file then add it as human readable
// string to the end of the link text so that the user knows how
// big a file they are downloading.
// The content.info also contains a MIME-type but we don't display
// it since it is "ugly", users generally aren't aware what it
// means and the type of the attachment can usually be inferrered
// from the file extension.
linkText += ' (' + filesize(content.info.size) + ')';
}
return linkText;
}
@replaceableComponent("views.messages.MFileBody") @replaceableComponent("views.messages.MFileBody")
export default class MFileBody extends React.Component { export default class MFileBody extends React.Component {
static propTypes = { static propTypes = {
@ -121,35 +151,6 @@ export default class MFileBody extends React.Component {
this._dummyLink = createRef(); this._dummyLink = createRef();
} }
/**
* Extracts a human readable label for the file attachment to use as
* link text.
*
* @param {Object} content The "content" key of the matrix event.
* @param {boolean} withSize Whether to include size information. Default true.
* @return {string} the human readable link text for the attachment.
*/
presentableTextForFile(content, withSize = true) {
let linkText = _t("Attachment");
if (content.body && content.body.length > 0) {
// The content body should be the name of the file including a
// file extension.
linkText = content.body;
}
if (content.info && content.info.size && withSize) {
// If we know the size of the file then add it as human readable
// string to the end of the link text so that the user knows how
// big a file they are downloading.
// The content.info also contains a MIME-type but we don't display
// it since it is "ugly", users generally aren't aware what it
// means and the type of the attachment can usually be inferrered
// from the file extension.
linkText += ' (' + filesize(content.info.size) + ')';
}
return linkText;
}
_getContentUrl() { _getContentUrl() {
if (this.props.forExport) return null; if (this.props.forExport) return null;
const media = mediaFromContent(this.props.mxEvent.getContent()); const media = mediaFromContent(this.props.mxEvent.getContent());
@ -164,7 +165,7 @@ export default class MFileBody extends React.Component {
render() { render() {
const content = this.props.mxEvent.getContent(); const content = this.props.mxEvent.getContent();
const text = this.presentableTextForFile(content); const text = presentableTextForFile(content);
const isEncrypted = content.file !== undefined; const isEncrypted = content.file !== undefined;
const fileName = content.body && content.body.length > 0 ? content.body : _t("Attachment"); const fileName = content.body && content.body.length > 0 ? content.body : _t("Attachment");
const contentUrl = this._getContentUrl(); const contentUrl = this._getContentUrl();
@ -180,7 +181,9 @@ export default class MFileBody extends React.Component {
<img alt="Attachment" className="mx_export_attach_icon" src="icons/attach.svg" /> <img alt="Attachment" className="mx_export_attach_icon" src="icons/attach.svg" />
: null} : null}
</span> </span>
<span className="mx_MFileBody_info_filename">{this.presentableTextForFile(content, false)}</span> <span className="mx_MFileBody_info_filename">
{ presentableTextForFile(content, false) }
</span>
</div> </div>
); );
} }
@ -320,7 +323,7 @@ export default class MFileBody extends React.Component {
// If the attachment is not encrypted then we check whether we // If the attachment is not encrypted then we check whether we
// are being displayed in the room timeline or in a list of // are being displayed in the room timeline or in a list of
// files in the right hand side of the screen. // files in the right hand side of the screen.
if (this.props.tileShape === "file_grid") { if (this.props.tileShape === TileShape.FileGrid) {
return ( return (
<span className="mx_MFileBody"> <span className="mx_MFileBody">
{ placeholder } { placeholder }

View file

@ -16,13 +16,11 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React, { createRef } from 'react'; import React, { ComponentProps, createRef } from 'react';
import PropTypes from 'prop-types';
import { Blurhash } from "react-blurhash"; import { Blurhash } from "react-blurhash";
import MFileBody from './MFileBody'; import MFileBody from './MFileBody';
import Modal from '../../../Modal'; import Modal from '../../../Modal';
import * as sdk from '../../../index';
import { decryptFile } from '../../../utils/DecryptFile'; import { decryptFile } from '../../../utils/DecryptFile';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import SettingsStore from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
@ -31,36 +29,50 @@ import InlineSpinner from '../elements/InlineSpinner';
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import { mediaFromContent } from "../../../customisations/Media"; import { mediaFromContent } from "../../../customisations/Media";
import { BLURHASH_FIELD } from "../../../ContentMessages"; import { BLURHASH_FIELD } from "../../../ContentMessages";
import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
import { RoomPermalinkCreator } from '../../../utils/permalinks/Permalinks';
import { IMediaEventContent } from '../../../customisations/models/IMediaEventContent';
import ImageView from '../elements/ImageView';
import { SyncState } from 'matrix-js-sdk/src/sync.api';
export interface IProps {
/* the MatrixEvent to show */
mxEvent: MatrixEvent;
/* called when the image has loaded */
onHeightChanged(): void;
/* the maximum image height to use */
maxImageHeight?: number;
/* the permalinkCreator */
permalinkCreator?: RoomPermalinkCreator;
forExport?: boolean;
}
interface IState {
decryptedUrl?: string;
decryptedThumbnailUrl?: string;
decryptedBlob?: Blob;
error;
imgError: boolean;
imgLoaded: boolean;
loadedImageDimensions?: {
naturalWidth: number;
naturalHeight: number;
};
hover: boolean;
showImage: boolean;
}
@replaceableComponent("views.messages.MImageBody") @replaceableComponent("views.messages.MImageBody")
export default class MImageBody extends React.Component { export default class MImageBody extends React.Component<IProps, IState> {
static propTypes = {
/* the MatrixEvent to show */
mxEvent: PropTypes.object.isRequired,
/* called when the image has loaded */
onHeightChanged: PropTypes.func.isRequired,
/* the maximum image height to use */
maxImageHeight: PropTypes.number,
/* the permalinkCreator */
permalinkCreator: PropTypes.object,
};
static contextType = MatrixClientContext; static contextType = MatrixClientContext;
private unmounted = true;
private image = createRef<HTMLImageElement>();
constructor(props) { constructor(props: IProps) {
super(props); super(props);
this.onImageError = this.onImageError.bind(this);
this.onImageLoad = this.onImageLoad.bind(this);
this.onImageEnter = this.onImageEnter.bind(this);
this.onImageLeave = this.onImageLeave.bind(this);
this.onClientSync = this.onClientSync.bind(this);
this.onClick = this.onClick.bind(this);
this._isGif = this._isGif.bind(this);
this.state = { this.state = {
decryptedUrl: null, decryptedUrl: null,
decryptedThumbnailUrl: null, decryptedThumbnailUrl: null,
@ -72,12 +84,10 @@ export default class MImageBody extends React.Component {
hover: false, hover: false,
showImage: SettingsStore.getValue("showImages"), showImage: SettingsStore.getValue("showImages"),
}; };
this._image = createRef();
} }
// FIXME: factor this out and apply it to MVideoBody and MAudioBody too! // FIXME: factor this out and apply it to MVideoBody and MAudioBody too!
onClientSync(syncState, prevState) { private onClientSync = (syncState: SyncState, prevState: SyncState): void => {
if (this.unmounted) return; if (this.unmounted) return;
// Consider the client reconnected if there is no error with syncing. // Consider the client reconnected if there is no error with syncing.
// This means the state could be RECONNECTING, SYNCING, PREPARED or CATCHUP. // This means the state could be RECONNECTING, SYNCING, PREPARED or CATCHUP.
@ -88,15 +98,15 @@ export default class MImageBody extends React.Component {
imgError: false, imgError: false,
}); });
} }
} };
showImage() { protected showImage(): void {
localStorage.setItem("mx_ShowImage_" + this.props.mxEvent.getId(), "true"); localStorage.setItem("mx_ShowImage_" + this.props.mxEvent.getId(), "true");
this.setState({ showImage: true }); this.setState({ showImage: true });
this._downloadImage(); this.downloadImage();
} }
onClick(ev) { protected onClick = (ev: React.MouseEvent): void => {
if (ev.button === 0 && !ev.metaKey) { if (ev.button === 0 && !ev.metaKey) {
ev.preventDefault(); ev.preventDefault();
if (!this.state.showImage) { if (!this.state.showImage) {
@ -104,12 +114,11 @@ export default class MImageBody extends React.Component {
return; return;
} }
const content = this.props.mxEvent.getContent(); const content = this.props.mxEvent.getContent<IMediaEventContent>();
const httpUrl = this._getContentUrl(); const httpUrl = this.getContentUrl();
const ImageView = sdk.getComponent("elements.ImageView"); const params: Omit<ComponentProps<typeof ImageView>, "onFinished"> = {
const params = {
src: httpUrl, src: httpUrl,
name: content.body && content.body.length > 0 ? content.body : _t('Attachment'), name: content.body?.length > 0 ? content.body : _t('Attachment'),
mxEvent: this.props.mxEvent, mxEvent: this.props.mxEvent,
permalinkCreator: this.props.permalinkCreator, permalinkCreator: this.props.permalinkCreator,
}; };
@ -122,58 +131,54 @@ export default class MImageBody extends React.Component {
Modal.createDialog(ImageView, params, "mx_Dialog_lightbox", null, true); Modal.createDialog(ImageView, params, "mx_Dialog_lightbox", null, true);
} }
} };
_isGif() { private isGif = (): boolean => {
const content = this.props.mxEvent.getContent(); const content = this.props.mxEvent.getContent();
return ( return content.info?.mimetype === "image/gif";
content && };
content.info &&
content.info.mimetype === "image/gif"
);
}
onImageEnter(e) { private onImageEnter = (e: React.MouseEvent<HTMLImageElement>): void => {
this.setState({ hover: true }); this.setState({ hover: true });
if (!this.state.showImage || !this._isGif() || SettingsStore.getValue("autoplayGifsAndVideos")) { if (!this.state.showImage || !this.isGif() || SettingsStore.getValue("autoplayGifsAndVideos")) {
return; return;
} }
const imgElement = e.target; const imgElement = e.currentTarget;
imgElement.src = this._getContentUrl(); imgElement.src = this.getContentUrl();
} };
onImageLeave(e) { private onImageLeave = (e: React.MouseEvent<HTMLImageElement>): void => {
this.setState({ hover: false }); this.setState({ hover: false });
if (!this.state.showImage || !this._isGif() || SettingsStore.getValue("autoplayGifsAndVideos")) { if (!this.state.showImage || !this.isGif() || SettingsStore.getValue("autoplayGifsAndVideos")) {
return; return;
} }
const imgElement = e.target; const imgElement = e.currentTarget;
imgElement.src = this._getThumbUrl(); imgElement.src = this.getThumbUrl();
} };
onImageError() { private onImageError = (): void => {
this.setState({ this.setState({
imgError: true, imgError: true,
}); });
} };
onImageLoad() { private onImageLoad = (): void => {
this.props.onHeightChanged(); this.props.onHeightChanged();
let loadedImageDimensions; let loadedImageDimensions;
if (this._image.current) { if (this.image.current) {
const { naturalWidth, naturalHeight } = this._image.current; const { naturalWidth, naturalHeight } = this.image.current;
// this is only used as a fallback in case content.info.w/h is missing // this is only used as a fallback in case content.info.w/h is missing
loadedImageDimensions = { naturalWidth, naturalHeight }; loadedImageDimensions = { naturalWidth, naturalHeight };
} }
this.setState({ imgLoaded: true, loadedImageDimensions }); this.setState({ imgLoaded: true, loadedImageDimensions });
} };
_getContentUrl() { protected getContentUrl(): string {
const content = this.props.mxEvent.getContent(); const content = this.props.mxEvent.getContent();
if (this.props.forExport) return content.url || content.file.url; if (this.props.forExport) return content.url || content.file.url;
const media = mediaFromContent(content); const media = mediaFromContent(content);
@ -184,7 +189,7 @@ export default class MImageBody extends React.Component {
} }
} }
_getThumbUrl() { protected getThumbUrl(): string {
// FIXME: we let images grow as wide as you like, rather than capped to 800x600. // FIXME: we let images grow as wide as you like, rather than capped to 800x600.
// So either we need to support custom timeline widths here, or reimpose the cap, otherwise the // So either we need to support custom timeline widths here, or reimpose the cap, otherwise the
// thumbnail resolution will be unnecessarily reduced. // thumbnail resolution will be unnecessarily reduced.
@ -192,7 +197,7 @@ export default class MImageBody extends React.Component {
const thumbWidth = 800; const thumbWidth = 800;
const thumbHeight = 600; const thumbHeight = 600;
const content = this.props.mxEvent.getContent(); const content = this.props.mxEvent.getContent<IMediaEventContent>();
const media = mediaFromContent(content); const media = mediaFromContent(content);
if (media.isEncrypted) { if (media.isEncrypted) {
@ -220,7 +225,7 @@ export default class MImageBody extends React.Component {
// - If there's no sizing info in the event, default to thumbnail // - If there's no sizing info in the event, default to thumbnail
const info = content.info; const info = content.info;
if ( if (
this._isGif() || this.isGif() ||
window.devicePixelRatio === 1.0 || window.devicePixelRatio === 1.0 ||
(!info || !info.w || !info.h || !info.size) (!info || !info.w || !info.h || !info.size)
) { ) {
@ -255,7 +260,7 @@ export default class MImageBody extends React.Component {
} }
} }
_downloadImage() { private downloadImage(): void {
const content = this.props.mxEvent.getContent(); const content = this.props.mxEvent.getContent();
if (content.file !== undefined && this.state.decryptedUrl === null) { if (content.file !== undefined && this.state.decryptedUrl === null) {
let thumbnailPromise = Promise.resolve(null); let thumbnailPromise = Promise.resolve(null);
@ -299,7 +304,7 @@ export default class MImageBody extends React.Component {
if (showImage) { if (showImage) {
// Don't download anything becaue we don't want to display anything. // Don't download anything becaue we don't want to display anything.
this._downloadImage(); this.downloadImage();
this.setState({ showImage: true }); this.setState({ showImage: true });
} }
@ -314,7 +319,6 @@ export default class MImageBody extends React.Component {
componentWillUnmount() { componentWillUnmount() {
this.unmounted = true; this.unmounted = true;
this.context.removeListener('sync', this.onClientSync); this.context.removeListener('sync', this.onClientSync);
this._afterComponentWillUnmount();
if (this.state.decryptedUrl) { if (this.state.decryptedUrl) {
URL.revokeObjectURL(this.state.decryptedUrl); URL.revokeObjectURL(this.state.decryptedUrl);
@ -324,12 +328,12 @@ export default class MImageBody extends React.Component {
} }
} }
// To be overridden by subclasses (e.g. MStickerBody) for further protected messageContent(
// cleanup after componentWillUnmount contentUrl: string,
_afterComponentWillUnmount() { thumbUrl: string,
} content: IMediaEventContent,
forcedHeight?: number,
_messageContent(contentUrl, thumbUrl, content) { ): JSX.Element {
let infoWidth; let infoWidth;
let infoHeight; let infoHeight;
@ -350,7 +354,7 @@ export default class MImageBody extends React.Component {
imageElement = <HiddenImagePlaceholder />; imageElement = <HiddenImagePlaceholder />;
} else { } else {
imageElement = ( imageElement = (
<img style={{ display: 'none' }} src={thumbUrl} ref={this._image} <img style={{ display: 'none' }} src={thumbUrl} ref={this.image}
alt={content.body} alt={content.body}
onError={this.onImageError} onError={this.onImageError}
onLoad={this.onImageLoad} onLoad={this.onImageLoad}
@ -364,7 +368,7 @@ export default class MImageBody extends React.Component {
} }
// The maximum height of the thumbnail as it is rendered as an <img> // The maximum height of the thumbnail as it is rendered as an <img>
const maxHeight = Math.min(this.props.maxImageHeight || 600, infoHeight); const maxHeight = forcedHeight || Math.min((this.props.maxImageHeight || 600), infoHeight);
// The maximum width of the thumbnail, as dictated by its natural // The maximum width of the thumbnail, as dictated by its natural
// maximum height. // maximum height.
const maxWidth = infoWidth * maxHeight / infoHeight; const maxWidth = infoWidth * maxHeight / infoHeight;
@ -384,7 +388,7 @@ export default class MImageBody extends React.Component {
// which has the same width as the timeline // which has the same width as the timeline
// mx_MImageBody_thumbnail resizes img to exactly container size // mx_MImageBody_thumbnail resizes img to exactly container size
img = ( img = (
<img className="mx_MImageBody_thumbnail" src={thumbUrl} ref={this._image} <img className="mx_MImageBody_thumbnail" src={thumbUrl} ref={this.image}
style={{ maxWidth: maxWidth + "px" }} style={{ maxWidth: maxWidth + "px" }}
alt={content.body} alt={content.body}
onError={this.onImageError} onError={this.onImageError}
@ -395,19 +399,19 @@ export default class MImageBody extends React.Component {
} }
if (!this.state.showImage) { if (!this.state.showImage) {
img = <HiddenImagePlaceholder style={{ maxWidth: maxWidth + "px" }} />; img = <HiddenImagePlaceholder maxWidth={maxWidth} />;
showPlaceholder = false; // because we're hiding the image, so don't show the placeholder. showPlaceholder = false; // because we're hiding the image, so don't show the placeholder.
} }
if (this._isGif() && !SettingsStore.getValue("autoplayGifsAndVideos") && !this.state.hover) { if (this.isGif() && !SettingsStore.getValue("autoplayGifsAndVideos") && !this.state.hover) {
gifLabel = <p className="mx_MImageBody_gifLabel">GIF</p>; gifLabel = <p className="mx_MImageBody_gifLabel">GIF</p>;
} }
const thumbnail = ( const thumbnail = (
<div className="mx_MImageBody_thumbnail_container" style={{ maxHeight: maxHeight + "px" }} > <div className="mx_MImageBody_thumbnail_container" style={{ maxHeight: maxHeight + "px", maxWidth: maxWidth + "px" }} >
{ /* Calculate aspect ratio, using %padding will size _container correctly */ } { /* Calculate aspect ratio, using %padding will size _container correctly */ }
<div style={{ paddingBottom: (100 * infoHeight / infoWidth) + '%' }} /> <div style={{ paddingBottom: forcedHeight ? (forcedHeight + "px") : ((100 * infoHeight / infoWidth) + '%') }} />
{showPlaceholder && { showPlaceholder &&
<div className="mx_MImageBody_thumbnail" style={{ <div className="mx_MImageBody_thumbnail" style={{
// Constrain width here so that spinner appears central to the loaded thumbnail // Constrain width here so that spinner appears central to the loaded thumbnail
maxWidth: infoWidth + "px", maxWidth: infoWidth + "px",
@ -429,14 +433,14 @@ export default class MImageBody extends React.Component {
} }
// Overidden by MStickerBody // Overidden by MStickerBody
wrapImage(contentUrl, children) { protected wrapImage(contentUrl: string, children: JSX.Element): JSX.Element {
return <a href={contentUrl} target={this.props.forExport ? "__blank" : undefined} onClick={this.onClick}> return <a href={contentUrl} target={this.props.forExport ? "__blank" : undefined} onClick={this.onClick}>
{children} {children}
</a>; </a>;
} }
// Overidden by MStickerBody // Overidden by MStickerBody
getPlaceholder(width, height) { protected getPlaceholder(width: number, height: number): JSX.Element {
const blurhash = this.props.mxEvent.getContent().info[BLURHASH_FIELD]; const blurhash = this.props.mxEvent.getContent().info[BLURHASH_FIELD];
if (blurhash) return <Blurhash hash={blurhash} width={width} height={height} />; if (blurhash) return <Blurhash hash={blurhash} width={width} height={height} />;
return <div className="mx_MImageBody_thumbnail_spinner"> return <div className="mx_MImageBody_thumbnail_spinner">
@ -445,18 +449,18 @@ export default class MImageBody extends React.Component {
} }
// Overidden by MStickerBody // Overidden by MStickerBody
getTooltip() { protected getTooltip(): JSX.Element {
return null; return null;
} }
// Overidden by MStickerBody // Overidden by MStickerBody
getFileBody() { protected getFileBody(): JSX.Element {
if (this.props.forExport) return null; if (this.props.forExport) return null;
return <MFileBody {...this.props} decryptedBlob={this.state.decryptedBlob} showGenericPlaceholder={false} />; return <MFileBody {...this.props} decryptedBlob={this.state.decryptedBlob} showGenericPlaceholder={false} />;
} }
render() { render() {
const content = this.props.mxEvent.getContent(); const content = this.props.mxEvent.getContent<IMediaEventContent>();
if (this.state.error !== null) { if (this.state.error !== null) {
return ( return (
@ -467,15 +471,15 @@ export default class MImageBody extends React.Component {
); );
} }
const contentUrl = this._getContentUrl(); const contentUrl = this.getContentUrl();
let thumbUrl; let thumbUrl;
if (this.props.forExport || (this._isGif() && SettingsStore.getValue("autoplayGifsAndVideos"))) { if (this.props.forExport || (this.isGif() && SettingsStore.getValue("autoplayGifsAndVideos"))) {
thumbUrl = contentUrl; thumbUrl = contentUrl;
} else { } else {
thumbUrl = this._getThumbUrl(); thumbUrl = this.getThumbUrl();
} }
const thumbnail = this._messageContent(contentUrl, thumbUrl, content); const thumbnail = this.messageContent(contentUrl, thumbUrl, content);
const fileBody = this.getFileBody(); const fileBody = this.getFileBody();
return <span className="mx_MImageBody"> return <span className="mx_MImageBody">
@ -485,16 +489,18 @@ export default class MImageBody extends React.Component {
} }
} }
export class HiddenImagePlaceholder extends React.PureComponent { interface PlaceholderIProps {
static propTypes = { hover?: boolean;
hover: PropTypes.bool, maxWidth?: number;
}; }
export class HiddenImagePlaceholder extends React.PureComponent<PlaceholderIProps> {
render() { render() {
const maxWidth = this.props.maxWidth ? this.props.maxWidth + "px" : null;
let className = 'mx_HiddenImagePlaceholder'; let className = 'mx_HiddenImagePlaceholder';
if (this.props.hover) className += ' mx_HiddenImagePlaceholder_hover'; if (this.props.hover) className += ' mx_HiddenImagePlaceholder_hover';
return ( return (
<div className={className}> <div className={className} style={{ maxWidth: maxWidth }}>
<div className='mx_HiddenImagePlaceholder_button'> <div className='mx_HiddenImagePlaceholder_button'>
<span className='mx_HiddenImagePlaceholder_eye' /> <span className='mx_HiddenImagePlaceholder_eye' />
<span>{_t("Show image")}</span> <span>{_t("Show image")}</span>

View file

@ -0,0 +1,62 @@
/*
Copyright 2020-2021 Tulir Asokan <tulir@maunium.net>
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 MImageBody from "./MImageBody";
import { presentableTextForFile } from "./MFileBody";
import { IMediaEventContent } from "../../../customisations/models/IMediaEventContent";
import SenderProfile from "./SenderProfile";
const FORCED_IMAGE_HEIGHT = 44;
export default class MImageReplyBody extends MImageBody {
public onClick = (ev: React.MouseEvent): void => {
ev.preventDefault();
};
public wrapImage(contentUrl: string, children: JSX.Element): JSX.Element {
return children;
}
// Don't show "Download this_file.png ..."
public getFileBody(): JSX.Element {
return presentableTextForFile(this.props.mxEvent.getContent());
}
render() {
if (this.state.error !== null) {
return super.render();
}
const content = this.props.mxEvent.getContent<IMediaEventContent>();
const contentUrl = this.getContentUrl();
const thumbnail = this.messageContent(contentUrl, this.getThumbUrl(), content, FORCED_IMAGE_HEIGHT);
const fileBody = this.getFileBody();
const sender = <SenderProfile
mxEvent={this.props.mxEvent}
enableFlair={false}
/>;
return <div className="mx_MImageReplyBody">
{ thumbnail }
<div className="mx_MImageReplyBody_info">
<div className="mx_MImageReplyBody_sender">{ sender }</div>
<div className="mx_MImageReplyBody_filename">{ fileBody }</div>
</div>
</div>;
}
}

View file

@ -25,9 +25,11 @@ import { mediaFromContent } from "../../../customisations/Media";
import { decryptFile } from "../../../utils/DecryptFile"; import { decryptFile } from "../../../utils/DecryptFile";
import RecordingPlayback from "../audio_messages/RecordingPlayback"; import RecordingPlayback from "../audio_messages/RecordingPlayback";
import { IMediaEventContent } from "../../../customisations/models/IMediaEventContent"; import { IMediaEventContent } from "../../../customisations/models/IMediaEventContent";
import { TileShape } from "../rooms/EventTile";
interface IProps { interface IProps {
mxEvent: MatrixEvent; mxEvent: MatrixEvent;
tileShape?: TileShape;
} }
interface IState { interface IState {
@ -103,7 +105,7 @@ export default class MVoiceMessageBody extends React.PureComponent<IProps, IStat
// At this point we should have a playable state // At this point we should have a playable state
return ( return (
<span className="mx_MVoiceMessageBody"> <span className="mx_MVoiceMessageBody">
<RecordingPlayback playback={this.state.playback} /> <RecordingPlayback playback={this.state.playback} tileShape={this.props.tileShape} />
<MFileBody {...this.props} decryptedBlob={this.state.decryptedBlob} showGenericPlaceholder={false} /> <MFileBody {...this.props} decryptedBlob={this.state.decryptedBlob} showGenericPlaceholder={false} />
</span> </span>
); );

View file

@ -42,7 +42,7 @@ export default class MessageEvent extends React.Component {
onHeightChanged: PropTypes.func, onHeightChanged: PropTypes.func,
/* the shape of the tile, used */ /* the shape of the tile, used */
tileShape: PropTypes.string, tileShape: PropTypes.string, // TODO: Use TileShape enum
/* to set source to local file path during export */ /* to set source to local file path during export */
forExport: PropTypes.bool, forExport: PropTypes.bool,
@ -50,6 +50,10 @@ export default class MessageEvent extends React.Component {
/* the maximum image height to use, if the event is an image */ /* the maximum image height to use, if the event is an image */
maxImageHeight: PropTypes.number, maxImageHeight: PropTypes.number,
/* overrides for the msgtype-specific components, used by ReplyTile to override file rendering */
overrideBodyTypes: PropTypes.object,
overrideEventTypes: PropTypes.object,
/* the permalinkCreator */ /* the permalinkCreator */
permalinkCreator: PropTypes.object, permalinkCreator: PropTypes.object,
}; };
@ -77,9 +81,12 @@ export default class MessageEvent extends React.Component {
'm.file': sdk.getComponent('messages.MFileBody'), 'm.file': sdk.getComponent('messages.MFileBody'),
'm.audio': sdk.getComponent('messages.MVoiceOrAudioBody'), 'm.audio': sdk.getComponent('messages.MVoiceOrAudioBody'),
'm.video': sdk.getComponent('messages.MVideoBody'), 'm.video': sdk.getComponent('messages.MVideoBody'),
...(this.props.overrideBodyTypes || {}),
}; };
const evTypes = { const evTypes = {
'm.sticker': sdk.getComponent('messages.MStickerBody'), 'm.sticker': sdk.getComponent('messages.MStickerBody'),
...(this.props.overrideEventTypes || {}),
}; };
const content = this.props.mxEvent.getContent(); const content = this.props.mxEvent.getContent();
@ -116,7 +123,7 @@ export default class MessageEvent extends React.Component {
} }
} }
return <BodyType return BodyType ? <BodyType
ref={this._body} ref={this._body}
mxEvent={this.props.mxEvent} mxEvent={this.props.mxEvent}
highlights={this.props.highlights} highlights={this.props.highlights}
@ -130,6 +137,6 @@ export default class MessageEvent extends React.Component {
onHeightChanged={this.props.onHeightChanged} onHeightChanged={this.props.onHeightChanged}
onMessageAllowed={this.onTileUpdate} onMessageAllowed={this.onTileUpdate}
permalinkCreator={this.props.permalinkCreator} permalinkCreator={this.props.permalinkCreator}
/>; /> : null;
} }
} }

View file

@ -15,12 +15,14 @@
*/ */
import React from 'react'; import React from 'react';
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { MsgType } from "matrix-js-sdk/src/@types/event";
import Flair from '../elements/Flair'; import Flair from '../elements/Flair';
import FlairStore from '../../../stores/FlairStore'; import FlairStore from '../../../stores/FlairStore';
import { getUserNameColorClass } from '../../../utils/FormattingUtils'; import { getUserNameColorClass } from '../../../utils/FormattingUtils';
import MatrixClientContext from "../../../contexts/MatrixClientContext"; import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
interface IProps { interface IProps {
mxEvent: MatrixEvent; mxEvent: MatrixEvent;
@ -36,7 +38,7 @@ interface IState {
@replaceableComponent("views.messages.SenderProfile") @replaceableComponent("views.messages.SenderProfile")
export default class SenderProfile extends React.Component<IProps, IState> { export default class SenderProfile extends React.Component<IProps, IState> {
static contextType = MatrixClientContext; static contextType = MatrixClientContext;
private unmounted: boolean; private unmounted = false;
constructor(props: IProps) { constructor(props: IProps) {
super(props); super(props);
@ -49,8 +51,7 @@ export default class SenderProfile extends React.Component<IProps, IState> {
} }
componentDidMount() { componentDidMount() {
this.unmounted = false; this.updateRelatedGroups();
this._updateRelatedGroups();
if (this.state.userGroups.length === 0) { if (this.state.userGroups.length === 0) {
this.getPublicisedGroups(); this.getPublicisedGroups();
@ -64,35 +65,29 @@ export default class SenderProfile extends React.Component<IProps, IState> {
this.context.removeListener('RoomState.events', this.onRoomStateEvents); this.context.removeListener('RoomState.events', this.onRoomStateEvents);
} }
async getPublicisedGroups() { private async getPublicisedGroups() {
if (!this.unmounted) { const userGroups = await FlairStore.getPublicisedGroupsCached(this.context, this.props.mxEvent.getSender());
const userGroups = await FlairStore.getPublicisedGroupsCached( if (this.unmounted) return;
this.context, this.props.mxEvent.getSender(), this.setState({ userGroups });
);
this.setState({ userGroups });
}
} }
onRoomStateEvents = event => { private onRoomStateEvents = (event: MatrixEvent) => {
if (event.getType() === 'm.room.related_groups' && if (event.getType() === 'm.room.related_groups' && event.getRoomId() === this.props.mxEvent.getRoomId()) {
event.getRoomId() === this.props.mxEvent.getRoomId() this.updateRelatedGroups();
) {
this._updateRelatedGroups();
} }
}; };
_updateRelatedGroups() { private updateRelatedGroups() {
if (this.unmounted) return;
const room = this.context.getRoom(this.props.mxEvent.getRoomId()); const room = this.context.getRoom(this.props.mxEvent.getRoomId());
if (!room) return; if (!room) return;
const relatedGroupsEvent = room.currentState.getStateEvents('m.room.related_groups', ''); const relatedGroupsEvent = room.currentState.getStateEvents('m.room.related_groups', '');
this.setState({ this.setState({
relatedGroups: relatedGroupsEvent ? relatedGroupsEvent.getContent().groups || [] : [], relatedGroups: relatedGroupsEvent?.getContent().groups || [],
}); });
} }
_getDisplayedGroups(userGroups, relatedGroups) { private getDisplayedGroups(userGroups?: string[], relatedGroups?: string[]) {
let displayedGroups = userGroups || []; let displayedGroups = userGroups || [];
if (relatedGroups && relatedGroups.length > 0) { if (relatedGroups && relatedGroups.length > 0) {
displayedGroups = relatedGroups.filter((groupId) => { displayedGroups = relatedGroups.filter((groupId) => {
@ -113,7 +108,7 @@ export default class SenderProfile extends React.Component<IProps, IState> {
const displayName = mxEvent.sender?.rawDisplayName || mxEvent.getSender() || ""; const displayName = mxEvent.sender?.rawDisplayName || mxEvent.getSender() || "";
const mxid = mxEvent.sender?.userId || mxEvent.getSender() || ""; const mxid = mxEvent.sender?.userId || mxEvent.getSender() || "";
if (msgtype === 'm.emote') { if (msgtype === MsgType.Emote) {
return null; // emote message must include the name so don't duplicate it return null; // emote message must include the name so don't duplicate it
} }
@ -128,7 +123,7 @@ export default class SenderProfile extends React.Component<IProps, IState> {
let flair; let flair;
if (this.props.enableFlair) { if (this.props.enableFlair) {
const displayedGroups = this._getDisplayedGroups( const displayedGroups = this.getDisplayedGroups(
this.state.userGroups, this.state.relatedGroups, this.state.userGroups, this.state.relatedGroups,
); );

View file

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

View file

@ -69,6 +69,7 @@ import RoomName from "../elements/RoomName";
import { mediaFromMxc } from "../../../customisations/Media"; import { mediaFromMxc } from "../../../customisations/Media";
import UIStore from "../../../stores/UIStore"; import UIStore from "../../../stores/UIStore";
import { ComposerInsertPayload } from "../../../dispatcher/payloads/ComposerInsertPayload"; import { ComposerInsertPayload } from "../../../dispatcher/payloads/ComposerInsertPayload";
import SpaceStore from "../../../stores/SpaceStore";
export interface IDevice { export interface IDevice {
deviceId: string; deviceId: string;
@ -728,7 +729,7 @@ const MuteToggleButton: React.FC<IBaseRoomProps> = ({ member, room, powerLevels,
// if muting self, warn as it may be irreversible // if muting self, warn as it may be irreversible
if (target === cli.getUserId()) { if (target === cli.getUserId()) {
try { try {
if (!(await warnSelfDemote(SettingsStore.getValue("feature_spaces") && room?.isSpaceRoom()))) return; if (!(await warnSelfDemote(SpaceStore.spacesEnabled && room?.isSpaceRoom()))) return;
} catch (e) { } catch (e) {
console.error("Failed to warn about self demotion: ", e); console.error("Failed to warn about self demotion: ", e);
return; return;
@ -817,7 +818,7 @@ const RoomAdminToolsContainer: React.FC<IBaseRoomProps> = ({
if (canAffectUser && me.powerLevel >= kickPowerLevel) { if (canAffectUser && me.powerLevel >= kickPowerLevel) {
kickButton = <RoomKickButton member={member} startUpdating={startUpdating} stopUpdating={stopUpdating} />; kickButton = <RoomKickButton member={member} startUpdating={startUpdating} stopUpdating={stopUpdating} />;
} }
if (me.powerLevel >= redactPowerLevel && (!SettingsStore.getValue("feature_spaces") || !room.isSpaceRoom())) { if (me.powerLevel >= redactPowerLevel && (!SpaceStore.spacesEnabled || !room.isSpaceRoom())) {
redactButton = ( redactButton = (
<RedactMessagesButton member={member} startUpdating={startUpdating} stopUpdating={stopUpdating} /> <RedactMessagesButton member={member} startUpdating={startUpdating} stopUpdating={stopUpdating} />
); );
@ -1096,7 +1097,7 @@ const PowerLevelEditor: React.FC<{
} else if (myUserId === target) { } else if (myUserId === target) {
// If we are changing our own PL it can only ever be decreasing, which we cannot reverse. // If we are changing our own PL it can only ever be decreasing, which we cannot reverse.
try { try {
if (!(await warnSelfDemote(SettingsStore.getValue("feature_spaces") && room?.isSpaceRoom()))) return; if (!(await warnSelfDemote(SpaceStore.spacesEnabled && room?.isSpaceRoom()))) return;
} catch (e) { } catch (e) {
console.error("Failed to warn about self demotion: ", e); console.error("Failed to warn about self demotion: ", e);
} }
@ -1326,10 +1327,10 @@ const BasicUserInfo: React.FC<{
if (!isRoomEncrypted) { if (!isRoomEncrypted) {
if (!cryptoEnabled) { if (!cryptoEnabled) {
text = _t("This client does not support end-to-end encryption."); text = _t("This client does not support end-to-end encryption.");
} else if (room && (!SettingsStore.getValue("feature_spaces") || !room.isSpaceRoom())) { } else if (room && (!SpaceStore.spacesEnabled || !room.isSpaceRoom())) {
text = _t("Messages in this room are not end-to-end encrypted."); text = _t("Messages in this room are not end-to-end encrypted.");
} }
} else if (!SettingsStore.getValue("feature_spaces") || !room.isSpaceRoom()) { } else if (!SpaceStore.spacesEnabled || !room.isSpaceRoom()) {
text = _t("Messages in this room are end-to-end encrypted."); text = _t("Messages in this room are end-to-end encrypted.");
} }
@ -1405,7 +1406,7 @@ const BasicUserInfo: React.FC<{
canInvite={roomPermissions.canInvite} canInvite={roomPermissions.canInvite}
isIgnored={isIgnored} isIgnored={isIgnored}
member={member as RoomMember} member={member as RoomMember}
isSpace={SettingsStore.getValue("feature_spaces") && room?.isSpaceRoom()} isSpace={SpaceStore.spacesEnabled && room?.isSpaceRoom()}
/> />
{ adminToolsContainer } { adminToolsContainer }
@ -1568,7 +1569,7 @@ const UserInfo: React.FC<IProps> = ({
previousPhase = RightPanelPhases.RoomMemberInfo; previousPhase = RightPanelPhases.RoomMemberInfo;
refireParams = { member: member }; refireParams = { member: member };
} else if (room) { } else if (room) {
previousPhase = previousPhase = SettingsStore.getValue("feature_spaces") && room.isSpaceRoom() previousPhase = previousPhase = SpaceStore.spacesEnabled && room.isSpaceRoom()
? RightPanelPhases.SpaceMemberList ? RightPanelPhases.SpaceMemberList
: RightPanelPhases.RoomMemberList; : RightPanelPhases.RoomMemberList;
} }
@ -1617,7 +1618,7 @@ const UserInfo: React.FC<IProps> = ({
} }
let scopeHeader; let scopeHeader;
if (SettingsStore.getValue("feature_spaces") && room?.isSpaceRoom()) { if (SpaceStore.spacesEnabled && room?.isSpaceRoom()) {
scopeHeader = <div className="mx_RightPanel_scopeHeader"> scopeHeader = <div className="mx_RightPanel_scopeHeader">
<RoomAvatar room={room} height={32} width={32} /> <RoomAvatar room={room} height={32} width={32} />
<RoomName room={room} /> <RoomName room={room} />

View file

@ -15,11 +15,13 @@ limitations under the License.
*/ */
import React from "react"; import React from "react";
import { Visibility } from "matrix-js-sdk/src/@types/partials";
import LabelledToggleSwitch from "../elements/LabelledToggleSwitch"; import LabelledToggleSwitch from "../elements/LabelledToggleSwitch";
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import DirectoryCustomisations from '../../../customisations/Directory';
interface IProps { interface IProps {
roomId: string; roomId: string;
@ -49,7 +51,7 @@ export default class RoomPublishSetting extends React.PureComponent<IProps, ISta
client.setRoomDirectoryVisibility( client.setRoomDirectoryVisibility(
this.props.roomId, this.props.roomId,
newValue ? 'public' : 'private', newValue ? Visibility.Public : Visibility.Private,
).catch(() => { ).catch(() => {
// Roll back the local echo on the change // Roll back the local echo on the change
this.setState({ isRoomPublished: valueBefore }); this.setState({ isRoomPublished: valueBefore });
@ -66,10 +68,15 @@ export default class RoomPublishSetting extends React.PureComponent<IProps, ISta
render() { render() {
const client = MatrixClientPeg.get(); const client = MatrixClientPeg.get();
const enabled = (
DirectoryCustomisations.requireCanonicalAliasAccessToPublish?.() === false ||
this.props.canSetCanonicalAlias
);
return ( return (
<LabelledToggleSwitch value={this.state.isRoomPublished} <LabelledToggleSwitch value={this.state.isRoomPublished}
onChange={this.onRoomPublishChange} onChange={this.onRoomPublishChange}
disabled={!this.props.canSetCanonicalAlias} disabled={!enabled}
label={_t("Publish this room to the public in %(domain)s's room directory?", { label={_t("Publish this room to the public in %(domain)s's room directory?", {
domain: client.getDomain(), domain: client.getDomain(),
})} })}

View file

@ -55,7 +55,7 @@ interface IState {
export default class Autocomplete extends React.PureComponent<IProps, IState> { export default class Autocomplete extends React.PureComponent<IProps, IState> {
autocompleter: Autocompleter; autocompleter: Autocompleter;
queryRequested: string; queryRequested: string;
debounceCompletionsRequest: NodeJS.Timeout; debounceCompletionsRequest: number;
private containerRef = createRef<HTMLDivElement>(); private containerRef = createRef<HTMLDivElement>();
constructor(props) { constructor(props) {

View file

@ -27,7 +27,6 @@ import { _t } from '../../../languageHandler';
import { hasText } from "../../../TextForEvent"; import { hasText } from "../../../TextForEvent";
import * as sdk from "../../../index"; import * as sdk from "../../../index";
import dis from '../../../dispatcher/dispatcher'; import dis from '../../../dispatcher/dispatcher';
import SettingsStore from "../../../settings/SettingsStore";
import { Layout } from "../../../settings/Layout"; import { Layout } from "../../../settings/Layout";
import { formatTime } from "../../../DateUtils"; import { formatTime } from "../../../DateUtils";
import { MatrixClientPeg } from '../../../MatrixClientPeg'; import { MatrixClientPeg } from '../../../MatrixClientPeg';
@ -54,6 +53,7 @@ import TooltipButton from '../elements/TooltipButton';
import ReadReceiptMarker from "./ReadReceiptMarker"; import ReadReceiptMarker from "./ReadReceiptMarker";
import MessageActionBar from "../messages/MessageActionBar"; import MessageActionBar from "../messages/MessageActionBar";
import ReactionsRow from '../messages/ReactionsRow'; import ReactionsRow from '../messages/ReactionsRow';
import { getEventDisplayInfo } from '../../../utils/EventUtils';
const eventTileTypes = { const eventTileTypes = {
[EventType.RoomMessage]: 'messages.MessageEvent', [EventType.RoomMessage]: 'messages.MessageEvent',
@ -192,8 +192,7 @@ export interface IReadReceiptProps {
export enum TileShape { export enum TileShape {
Notif = "notif", Notif = "notif",
FileGrid = "file_grid", FileGrid = "file_grid",
Reply = "reply", Pinned = "pinned",
ReplyPreview = "reply_preview",
} }
interface IProps { interface IProps {
@ -323,7 +322,7 @@ export default class EventTile extends React.Component<IProps, IState> {
private suppressReadReceiptAnimation: boolean; private suppressReadReceiptAnimation: boolean;
private isListeningForReceipts: boolean; private isListeningForReceipts: boolean;
private tile = React.createRef(); private tile = React.createRef();
private replyThread = React.createRef(); private replyThread = React.createRef<ReplyThread>();
public readonly ref = createRef<HTMLElement>(); public readonly ref = createRef<HTMLElement>();
@ -851,35 +850,9 @@ export default class EventTile extends React.Component<IProps, IState> {
}; };
render() { render() {
//console.info("EventTile showUrlPreview for %s is %s", this.props.mxEvent.getId(), this.props.showUrlPreview); const msgtype = this.props.mxEvent.getContent().msgtype;
const { tileHandler, isBubbleMessage, isInfoMessage } = getEventDisplayInfo(this.props.mxEvent);
const content = this.props.mxEvent.getContent();
const msgtype = content.msgtype;
const eventType = this.props.mxEvent.getType();
let tileHandler = getHandlerTile(this.props.mxEvent);
// Info messages are basically information about commands processed on a room
let isBubbleMessage = eventType.startsWith("m.key.verification") ||
(eventType === EventType.RoomMessage && msgtype && msgtype.startsWith("m.key.verification")) ||
(eventType === EventType.RoomCreate) ||
(eventType === EventType.RoomEncryption) ||
(tileHandler === "messages.MJitsiWidgetEvent");
let isInfoMessage = (
!isBubbleMessage && eventType !== EventType.RoomMessage &&
eventType !== EventType.Sticker && eventType !== EventType.RoomCreate
);
// If we're showing hidden events in the timeline, we should use the
// source tile when there's no regular tile for an event and also for
// replace relations (which otherwise would display as a confusing
// duplicate of the thing they are replacing).
if (SettingsStore.getValue("showHiddenEventsInTimeline") && !haveTileForEvent(this.props.mxEvent)) {
tileHandler = "messages.ViewSourceEvent";
isBubbleMessage = false;
// Reuse info message avatar and sender profile styling
isInfoMessage = true;
}
// This shouldn't happen: the caller should check we support this type // This shouldn't happen: the caller should check we support this type
// before trying to instantiate us // before trying to instantiate us
if (!tileHandler) { if (!tileHandler) {
@ -906,7 +879,7 @@ export default class EventTile extends React.Component<IProps, IState> {
mx_EventTile_12hr: this.props.isTwelveHour, mx_EventTile_12hr: this.props.isTwelveHour,
// Note: we keep the `sending` state class for tests, not for our styles // Note: we keep the `sending` state class for tests, not for our styles
mx_EventTile_sending: !isEditing && isSending, mx_EventTile_sending: !isEditing && isSending,
mx_EventTile_highlight: this.props.tileShape === 'notif' ? false : this.shouldHighlight(), mx_EventTile_highlight: this.props.tileShape === TileShape.Notif ? false : this.shouldHighlight(),
mx_EventTile_selected: this.props.isSelectedEvent, mx_EventTile_selected: this.props.isSelectedEvent,
mx_EventTile_continuation: this.props.tileShape ? '' : this.props.continuation, mx_EventTile_continuation: this.props.tileShape ? '' : this.props.continuation,
mx_EventTile_last: this.props.last, mx_EventTile_last: this.props.last,
@ -939,7 +912,7 @@ export default class EventTile extends React.Component<IProps, IState> {
let avatarSize; let avatarSize;
let needsSenderProfile; let needsSenderProfile;
if (this.props.tileShape === "notif") { if (this.props.tileShape === TileShape.Notif) {
avatarSize = 24; avatarSize = 24;
needsSenderProfile = true; needsSenderProfile = true;
} else if (tileHandler === 'messages.RoomCreate' || isBubbleMessage) { } else if (tileHandler === 'messages.RoomCreate' || isBubbleMessage) {
@ -953,7 +926,7 @@ export default class EventTile extends React.Component<IProps, IState> {
} else if (this.props.layout == Layout.IRC) { } else if (this.props.layout == Layout.IRC) {
avatarSize = 14; avatarSize = 14;
needsSenderProfile = true; needsSenderProfile = true;
} else if (this.props.continuation && this.props.tileShape !== "file_grid") { } else if (this.props.continuation && this.props.tileShape !== TileShape.FileGrid) {
// no avatar or sender profile for continuation messages // no avatar or sender profile for continuation messages
avatarSize = 0; avatarSize = 0;
needsSenderProfile = false; needsSenderProfile = false;
@ -986,7 +959,7 @@ export default class EventTile extends React.Component<IProps, IState> {
} }
if (needsSenderProfile) { if (needsSenderProfile) {
if (!this.props.tileShape || this.props.tileShape === 'reply' || this.props.tileShape === 'reply_preview') { if (!this.props.tileShape) {
sender = <SenderProfile onClick={this.onSenderProfileClick} sender = <SenderProfile onClick={this.onSenderProfileClick}
mxEvent={this.props.mxEvent} mxEvent={this.props.mxEvent}
enableFlair={this.props.enableFlair} enableFlair={this.props.enableFlair}
@ -1073,7 +1046,7 @@ export default class EventTile extends React.Component<IProps, IState> {
} }
switch (this.props.tileShape) { switch (this.props.tileShape) {
case 'notif': { case TileShape.Notif: {
const room = this.context.getRoom(this.props.mxEvent.getRoomId()); const room = this.context.getRoom(this.props.mxEvent.getRoomId());
return React.createElement(this.props.as || "li", { return React.createElement(this.props.as || "li", {
"className": classes, "className": classes,
@ -1101,11 +1074,12 @@ export default class EventTile extends React.Component<IProps, IState> {
highlightLink={this.props.highlightLink} highlightLink={this.props.highlightLink}
showUrlPreview={this.props.showUrlPreview} showUrlPreview={this.props.showUrlPreview}
onHeightChanged={this.props.onHeightChanged} onHeightChanged={this.props.onHeightChanged}
tileShape={this.props.tileShape}
/> />
</div>, </div>,
]); ]);
} }
case 'file_grid': { case TileShape.FileGrid: {
return React.createElement(this.props.as || "li", { return React.createElement(this.props.as || "li", {
"className": classes, "className": classes,
"aria-live": ariaLive, "aria-live": ariaLive,
@ -1136,44 +1110,6 @@ export default class EventTile extends React.Component<IProps, IState> {
]); ]);
} }
case 'reply':
case 'reply_preview': {
let thread;
if (this.props.tileShape === 'reply_preview') {
thread = ReplyThread.makeThread(
this.props.mxEvent,
this.props.onHeightChanged,
this.props.permalinkCreator,
this.replyThread,
null,
this.props.alwaysShowTimestamps || this.state.hover,
);
}
return React.createElement(this.props.as || "li", {
"className": classes,
"aria-live": ariaLive,
"aria-atomic": true,
"data-scroll-tokens": scrollToken,
}, [
ircTimestamp,
avatar,
sender,
ircPadlock,
<div className="mx_EventTile_reply" key="mx_EventTile_reply">
{ groupTimestamp }
{ groupPadlock }
{ thread }
<EventTileType ref={this.tile}
mxEvent={this.props.mxEvent}
highlights={this.props.highlights}
highlightLink={this.props.highlightLink}
onHeightChanged={this.props.onHeightChanged}
replacingEventId={this.props.replacingEventId}
showUrlPreview={false}
/>
</div>,
]);
}
default: { default: {
const thread = ReplyThread.makeThread( const thread = ReplyThread.makeThread(
this.props.mxEvent, this.props.mxEvent,
@ -1196,10 +1132,10 @@ export default class EventTile extends React.Component<IProps, IState> {
"data-scroll-tokens": scrollToken, "data-scroll-tokens": scrollToken,
"onMouseEnter": () => this.setState({ hover: true }), "onMouseEnter": () => this.setState({ hover: true }),
"onMouseLeave": () => this.setState({ hover: false }), "onMouseLeave": () => this.setState({ hover: false }),
}, [ }, <>
ircTimestamp, { ircTimestamp }
sender, { sender }
ircPadlock, { ircPadlock }
<div className="mx_EventTile_line" key="mx_EventTile_line"> <div className="mx_EventTile_line" key="mx_EventTile_line">
{ groupTimestamp } { groupTimestamp }
{ groupPadlock } { groupPadlock }
@ -1218,11 +1154,10 @@ export default class EventTile extends React.Component<IProps, IState> {
{ keyRequestInfo } { keyRequestInfo }
{ reactionsRow } { reactionsRow }
{ actionBar } { actionBar }
</div>, </div>
msgOption, { msgOption }
avatar, { avatar }
</>)
])
); );
} }
} }
@ -1235,7 +1170,7 @@ function isMessageEvent(ev: MatrixEvent): boolean {
return (messageTypes.includes(ev.getType())); return (messageTypes.includes(ev.getType()));
} }
export function haveTileForEvent(e: MatrixEvent): boolean { export function haveTileForEvent(e: MatrixEvent, showHiddenEvents?: boolean) {
// Only messages have a tile (black-rectangle) if redacted // Only messages have a tile (black-rectangle) if redacted
if (e.isRedacted() && !isMessageEvent(e)) return false; if (e.isRedacted() && !isMessageEvent(e)) return false;
@ -1245,7 +1180,7 @@ export function haveTileForEvent(e: MatrixEvent): boolean {
const handler = getHandlerTile(e); const handler = getHandlerTile(e);
if (handler === undefined) return false; if (handler === undefined) return false;
if (handler === 'messages.TextualEvent') { if (handler === 'messages.TextualEvent') {
return hasText(e); return hasText(e, showHiddenEvents);
} else if (handler === 'messages.RoomCreate') { } else if (handler === 'messages.RoomCreate') {
return Boolean(e.getContent()['predecessor']); return Boolean(e.getContent()['predecessor']);
} else { } else {

View file

@ -40,10 +40,12 @@ const LinkPreviewGroup: React.FC<IProps> = ({ links, mxEvent, onCancelClick, onH
const ts = mxEvent.getTs(); const ts = mxEvent.getTs();
const previews = useAsyncMemo<[string, IPreviewUrlResponse][]>(async () => { const previews = useAsyncMemo<[string, IPreviewUrlResponse][]>(async () => {
return Promise.all<[string, IPreviewUrlResponse] | void>(links.map(link => { return Promise.all<[string, IPreviewUrlResponse] | void>(links.map(async link => {
return cli.getUrlPreview(link, ts).then(preview => [link, preview], error => { try {
return [link, await cli.getUrlPreview(link, ts)];
} catch (error) {
console.error("Failed to get URL preview: " + error); console.error("Failed to get URL preview: " + error);
}); }
})).then(a => a.filter(Boolean)) as Promise<[string, IPreviewUrlResponse][]>; })).then(a => a.filter(Boolean)) as Promise<[string, IPreviewUrlResponse][]>;
}, [links, ts], []); }, [links, ts], []);

View file

@ -43,6 +43,7 @@ import EntityTile from "./EntityTile";
import MemberTile from "./MemberTile"; import MemberTile from "./MemberTile";
import BaseAvatar from '../avatars/BaseAvatar'; import BaseAvatar from '../avatars/BaseAvatar';
import { throttle } from 'lodash'; import { throttle } from 'lodash';
import SpaceStore from "../../../stores/SpaceStore";
const INITIAL_LOAD_NUM_MEMBERS = 30; const INITIAL_LOAD_NUM_MEMBERS = 30;
const INITIAL_LOAD_NUM_INVITED = 5; const INITIAL_LOAD_NUM_INVITED = 5;
@ -509,7 +510,7 @@ export default class MemberList extends React.Component<IProps, IState> {
const chat = CommunityPrototypeStore.instance.getSelectedCommunityGeneralChat(); const chat = CommunityPrototypeStore.instance.getSelectedCommunityGeneralChat();
if (chat && chat.roomId === this.props.roomId) { if (chat && chat.roomId === this.props.roomId) {
inviteButtonText = _t("Invite to this community"); inviteButtonText = _t("Invite to this community");
} else if (SettingsStore.getValue("feature_spaces") && room.isSpaceRoom()) { } else if (SpaceStore.spacesEnabled && room.isSpaceRoom()) {
inviteButtonText = _t("Invite to this space"); inviteButtonText = _t("Invite to this space");
} }
@ -549,7 +550,7 @@ export default class MemberList extends React.Component<IProps, IState> {
let previousPhase = RightPanelPhases.RoomSummary; let previousPhase = RightPanelPhases.RoomSummary;
// We have no previousPhase for when viewing a MemberList from a Space // We have no previousPhase for when viewing a MemberList from a Space
let scopeHeader; let scopeHeader;
if (SettingsStore.getValue("feature_spaces") && room?.isSpaceRoom()) { if (SpaceStore.spacesEnabled && room?.isSpaceRoom()) {
previousPhase = undefined; previousPhase = undefined;
scopeHeader = <div className="mx_RightPanel_scopeHeader"> scopeHeader = <div className="mx_RightPanel_scopeHeader">
<RoomAvatar room={room} height={32} width={32} /> <RoomAvatar room={room} height={32} width={32} />

View file

@ -29,6 +29,7 @@ import { replaceableComponent } from "../../../utils/replaceableComponent";
import MatrixClientContext from "../../../contexts/MatrixClientContext"; import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { getUserNameColorClass } from "../../../utils/FormattingUtils"; import { getUserNameColorClass } from "../../../utils/FormattingUtils";
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import { TileShape } from "./EventTile";
interface IProps { interface IProps {
room: Room; room: Room;
@ -87,6 +88,7 @@ export default class PinnedEventTile extends React.Component<IProps> {
className="mx_PinnedEventTile_body" className="mx_PinnedEventTile_body"
maxImageHeight={150} maxImageHeight={150}
onHeightChanged={() => {}} // we need to give this, apparently onHeightChanged={() => {}} // we need to give this, apparently
tileShape={TileShape.Pinned}
/> />
</div> </div>

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2017 New Vector Ltd Copyright 2017 - 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -16,14 +16,13 @@ limitations under the License.
import React from 'react'; import React from 'react';
import dis from '../../../dispatcher/dispatcher'; import dis from '../../../dispatcher/dispatcher';
import * as sdk from '../../../index';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import RoomViewStore from '../../../stores/RoomViewStore'; import RoomViewStore from '../../../stores/RoomViewStore';
import SettingsStore from "../../../settings/SettingsStore";
import PropTypes from "prop-types";
import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks"; import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
import { UIFeature } from "../../../settings/UIFeature";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import ReplyTile from './ReplyTile';
import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
import { EventSubscription } from 'fbemitter';
function cancelQuoting() { function cancelQuoting() {
dis.dispatch({ dis.dispatch({
@ -32,47 +31,50 @@ function cancelQuoting() {
}); });
} }
interface IProps {
permalinkCreator: RoomPermalinkCreator;
}
interface IState {
event: MatrixEvent;
}
@replaceableComponent("views.rooms.ReplyPreview") @replaceableComponent("views.rooms.ReplyPreview")
export default class ReplyPreview extends React.Component { export default class ReplyPreview extends React.Component<IProps, IState> {
static propTypes = { private unmounted = false;
permalinkCreator: PropTypes.instanceOf(RoomPermalinkCreator).isRequired, private readonly roomStoreToken: EventSubscription;
};
constructor(props) { constructor(props) {
super(props); super(props);
this.unmounted = false;
this.state = { this.state = {
event: RoomViewStore.getQuotingEvent(), event: RoomViewStore.getQuotingEvent(),
}; };
this._onRoomViewStoreUpdate = this._onRoomViewStoreUpdate.bind(this); this.roomStoreToken = RoomViewStore.addListener(this.onRoomViewStoreUpdate);
this._roomStoreToken = RoomViewStore.addListener(this._onRoomViewStoreUpdate);
} }
componentWillUnmount() { componentWillUnmount() {
this.unmounted = true; this.unmounted = true;
// Remove RoomStore listener // Remove RoomStore listener
if (this._roomStoreToken) { if (this.roomStoreToken) {
this._roomStoreToken.remove(); this.roomStoreToken.remove();
} }
} }
_onRoomViewStoreUpdate() { private onRoomViewStoreUpdate = (): void => {
if (this.unmounted) return; if (this.unmounted) return;
const event = RoomViewStore.getQuotingEvent(); const event = RoomViewStore.getQuotingEvent();
if (this.state.event !== event) { if (this.state.event !== event) {
this.setState({ event }); this.setState({ event });
} }
} };
render() { render() {
if (!this.state.event) return null; if (!this.state.event) return null;
const EventTile = sdk.getComponent('rooms.EventTile');
return <div className="mx_ReplyPreview"> return <div className="mx_ReplyPreview">
<div className="mx_ReplyPreview_section"> <div className="mx_ReplyPreview_section">
<div className="mx_ReplyPreview_header mx_ReplyPreview_title"> <div className="mx_ReplyPreview_header mx_ReplyPreview_title">
@ -88,15 +90,12 @@ export default class ReplyPreview extends React.Component {
/> />
</div> </div>
<div className="mx_ReplyPreview_clear" /> <div className="mx_ReplyPreview_clear" />
<EventTile <div className="mx_ReplyPreview_tile">
alwaysShowTimestamps={true} <ReplyTile
tileShape="reply_preview" mxEvent={this.state.event}
mxEvent={this.state.event} permalinkCreator={this.props.permalinkCreator}
permalinkCreator={this.props.permalinkCreator} />
isTwelveHour={SettingsStore.getValue("showTwelveHourTimestamps")} </div>
enableFlair={SettingsStore.getValue(UIFeature.Flair)}
as="div"
/>
</div> </div>
</div>; </div>;
} }

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