Merge branch 'develop' into gsouquet/message-bubbles-4635

This commit is contained in:
Germain Souquet 2021-07-07 13:07:43 +02:00
commit 10bdb3cefa
148 changed files with 2580 additions and 2149 deletions

View file

@ -1,16 +0,0 @@
# autogenerated file: run scripts/generate-eslint-error-ignore-file to update.
src/Markdown.js
src/NodeAnimator.js
src/components/structures/RoomDirectory.js
src/components/views/rooms/MemberList.js
src/ratelimitedfunc.js
src/utils/DMRoomMap.js
src/utils/MultiInviter.js
test/components/structures/MessagePanel-test.js
test/components/views/dialogs/InteractiveAuthDialog-test.js
test/mock-clock.js
src/component-index.js
test/end-to-end-tests/node_modules/
test/end-to-end-tests/element/
test/end-to-end-tests/synapse/

View file

@ -24,6 +24,18 @@ module.exports = {
// It's disabled here, but we should using it sparingly. // It's disabled here, but we should using it sparingly.
"react/jsx-no-bind": "off", "react/jsx-no-bind": "off",
"react/jsx-key": ["error"], "react/jsx-key": ["error"],
"no-restricted-properties": [
"error",
...buildRestrictedPropertiesOptions(
["window.innerHeight", "window.innerWidth", "window.visualViewport"],
"Use UIStore to access window dimensions instead.",
),
...buildRestrictedPropertiesOptions(
["*.mxcUrlToHttp", "*.getHttpUriForMxc"],
"Use Media helper instead to centralise access for customisation.",
),
],
}, },
overrides: [{ overrides: [{
files: [ files: [
@ -49,21 +61,16 @@ module.exports = {
"@typescript-eslint/no-explicit-any": "off", "@typescript-eslint/no-explicit-any": "off",
// We'd rather not do this but we do // We'd rather not do this but we do
"@typescript-eslint/ban-ts-comment": "off", "@typescript-eslint/ban-ts-comment": "off",
"no-restricted-properties": [
"error",
...buildRestrictedPropertiesOptions(
["window.innerHeight", "window.innerWidth", "window.visualViewport"],
"Use UIStore to access window dimensions instead",
),
],
}, },
}], }],
}; };
function buildRestrictedPropertiesOptions(properties, message) { function buildRestrictedPropertiesOptions(properties, message) {
return properties.map(prop => { return properties.map(prop => {
const [object, property] = prop.split("."); let [object, property] = prop.split(".");
if (object === "*") {
object = undefined;
}
return { return {
object, object,
property, property,

View file

@ -1,3 +1,174 @@
Changes in [3.25.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.25.0) (2021-07-05)
=====================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.25.0-rc.1...v3.25.0)
* Remove reminescent references to the tinter
[\#6316](https://github.com/matrix-org/matrix-react-sdk/pull/6316)
* Update to released version of js-sdk
Changes in [3.25.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.25.0-rc.1) (2021-06-29)
===============================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.24.0...v3.25.0-rc.1)
* Update to js-sdk v12.0.1-rc.1
* Translations update from Weblate
[\#6286](https://github.com/matrix-org/matrix-react-sdk/pull/6286)
* Fix back button on user info card after clicking a permalink
[\#6277](https://github.com/matrix-org/matrix-react-sdk/pull/6277)
* Group ACLs with MELS
[\#6280](https://github.com/matrix-org/matrix-react-sdk/pull/6280)
* Fix editState not getting passed through
[\#6282](https://github.com/matrix-org/matrix-react-sdk/pull/6282)
* Migrate message context menu to IconizedContextMenu
[\#5671](https://github.com/matrix-org/matrix-react-sdk/pull/5671)
* Improve audio recording performance
[\#6240](https://github.com/matrix-org/matrix-react-sdk/pull/6240)
* Fix multiple timeline panels handling composer and edit events
[\#6278](https://github.com/matrix-org/matrix-react-sdk/pull/6278)
* Let m.notice messages mark a room as unread
[\#6281](https://github.com/matrix-org/matrix-react-sdk/pull/6281)
* Removes the override on the Bubble Container
[\#5953](https://github.com/matrix-org/matrix-react-sdk/pull/5953)
* Fix IRC layout regressions
[\#6193](https://github.com/matrix-org/matrix-react-sdk/pull/6193)
* Fix trashcan.svg by exporting it with its viewbox
[\#6248](https://github.com/matrix-org/matrix-react-sdk/pull/6248)
* Fix tiny scrollbar dot on chrome/electron in Forward Dialog
[\#6276](https://github.com/matrix-org/matrix-react-sdk/pull/6276)
* Upgrade puppeteer to use newer version of Chrome
[\#6268](https://github.com/matrix-org/matrix-react-sdk/pull/6268)
* Make toast dismiss button less prominent
[\#6275](https://github.com/matrix-org/matrix-react-sdk/pull/6275)
* Encrypt the voice message file if needed
[\#6269](https://github.com/matrix-org/matrix-react-sdk/pull/6269)
* Fix hyper-precise presence
[\#6270](https://github.com/matrix-org/matrix-react-sdk/pull/6270)
* Fix issues around private spaces, including previewable
[\#6265](https://github.com/matrix-org/matrix-react-sdk/pull/6265)
* Make _pinned messages_ in `m.room.pinned_events` event clickable
[\#6257](https://github.com/matrix-org/matrix-react-sdk/pull/6257)
* Fix space avatar management layout being broken
[\#6266](https://github.com/matrix-org/matrix-react-sdk/pull/6266)
* Convert EntityTile, MemberTile and PresenceLabel to TS
[\#6251](https://github.com/matrix-org/matrix-react-sdk/pull/6251)
* Fix UserInfo not working when rendered without a room
[\#6260](https://github.com/matrix-org/matrix-react-sdk/pull/6260)
* Update membership reason handling, including leave reason displaying
[\#6253](https://github.com/matrix-org/matrix-react-sdk/pull/6253)
* Consolidate types with js-sdk changes
[\#6220](https://github.com/matrix-org/matrix-react-sdk/pull/6220)
* Fix edit history modal
[\#6258](https://github.com/matrix-org/matrix-react-sdk/pull/6258)
* Convert MemberList to TS
[\#6249](https://github.com/matrix-org/matrix-react-sdk/pull/6249)
* Fix two PRs duplicating the css attribute
[\#6259](https://github.com/matrix-org/matrix-react-sdk/pull/6259)
* Improve invite error messages in InviteDialog for room invites
[\#6201](https://github.com/matrix-org/matrix-react-sdk/pull/6201)
* Fix invite dialog being cut off when it has limited results
[\#6256](https://github.com/matrix-org/matrix-react-sdk/pull/6256)
* Fix pinning event in a room which hasn't had events pinned in before
[\#6255](https://github.com/matrix-org/matrix-react-sdk/pull/6255)
* Allow modal widget buttons to be disabled when the modal opens
[\#6178](https://github.com/matrix-org/matrix-react-sdk/pull/6178)
* Decrease e2e shield fill mask size so that it doesn't overlap
[\#6250](https://github.com/matrix-org/matrix-react-sdk/pull/6250)
* Dial Pad UI bug fixes
[\#5786](https://github.com/matrix-org/matrix-react-sdk/pull/5786)
* Simple handling of mid-call output changes
[\#6247](https://github.com/matrix-org/matrix-react-sdk/pull/6247)
* Improve ForwardDialog performance by using TruncatedList
[\#6228](https://github.com/matrix-org/matrix-react-sdk/pull/6228)
* Fix dependency and lockfile mismatch
[\#6246](https://github.com/matrix-org/matrix-react-sdk/pull/6246)
* Improve room directory click behaviour
[\#6234](https://github.com/matrix-org/matrix-react-sdk/pull/6234)
* Fix keyboard accessibility of the space panel
[\#6239](https://github.com/matrix-org/matrix-react-sdk/pull/6239)
* Add ways to manage addresses for Spaces
[\#6151](https://github.com/matrix-org/matrix-react-sdk/pull/6151)
* Hide communities invites and the community autocompleter when Spaces on
[\#6244](https://github.com/matrix-org/matrix-react-sdk/pull/6244)
* Convert bunch of files to TS
[\#6241](https://github.com/matrix-org/matrix-react-sdk/pull/6241)
* Open local addresses section by default when there are no existing local
addresses
[\#6179](https://github.com/matrix-org/matrix-react-sdk/pull/6179)
* Allow reordering of the space panel via Drag and Drop
[\#6137](https://github.com/matrix-org/matrix-react-sdk/pull/6137)
* Replace drag and drop mechanism in communities with something simpler
[\#6134](https://github.com/matrix-org/matrix-react-sdk/pull/6134)
* EventTilePreview fixes
[\#6000](https://github.com/matrix-org/matrix-react-sdk/pull/6000)
* Upgrade @types/react and @types/react-dom
[\#6233](https://github.com/matrix-org/matrix-react-sdk/pull/6233)
* Fix type error in the SpaceStore
[\#6242](https://github.com/matrix-org/matrix-react-sdk/pull/6242)
* Add experimental options to the Spaces beta
[\#6199](https://github.com/matrix-org/matrix-react-sdk/pull/6199)
* Consolidate types with js-sdk changes
[\#6215](https://github.com/matrix-org/matrix-react-sdk/pull/6215)
* Fix branch matching for Buildkite
[\#6236](https://github.com/matrix-org/matrix-react-sdk/pull/6236)
* Migrate SearchBar to TypeScript
[\#6230](https://github.com/matrix-org/matrix-react-sdk/pull/6230)
* Add support to keyboard shortcuts dialog for [digits]
[\#6088](https://github.com/matrix-org/matrix-react-sdk/pull/6088)
* Fix modal opening race condition
[\#6238](https://github.com/matrix-org/matrix-react-sdk/pull/6238)
* Deprecate FormButton in favour of AccessibleButton
[\#6229](https://github.com/matrix-org/matrix-react-sdk/pull/6229)
* Add PR template
[\#6216](https://github.com/matrix-org/matrix-react-sdk/pull/6216)
* Prefer canonical aliases while autocompleting rooms
[\#6222](https://github.com/matrix-org/matrix-react-sdk/pull/6222)
* Fix quote button
[\#6232](https://github.com/matrix-org/matrix-react-sdk/pull/6232)
* Restore branch matching support for GitHub Actions e2e tests
[\#6224](https://github.com/matrix-org/matrix-react-sdk/pull/6224)
* Fix View Source accessing renamed private field on MatrixEvent
[\#6225](https://github.com/matrix-org/matrix-react-sdk/pull/6225)
* Fix ConfirmUserActionDialog returning an input field rather than text
[\#6219](https://github.com/matrix-org/matrix-react-sdk/pull/6219)
* Revert "Partially restore immutable event objects at the rendering layer"
[\#6221](https://github.com/matrix-org/matrix-react-sdk/pull/6221)
* Add jq to e2e tests Dockerfile
[\#6218](https://github.com/matrix-org/matrix-react-sdk/pull/6218)
* Partially restore immutable event objects at the rendering layer
[\#6196](https://github.com/matrix-org/matrix-react-sdk/pull/6196)
* Update MSC number references for voice messages
[\#6197](https://github.com/matrix-org/matrix-react-sdk/pull/6197)
* Fix phase enum usage in JS modules as well
[\#6214](https://github.com/matrix-org/matrix-react-sdk/pull/6214)
* Migrate some dialogs to TypeScript
[\#6185](https://github.com/matrix-org/matrix-react-sdk/pull/6185)
* Typescript fixes due to MatrixEvent being TSified
[\#6208](https://github.com/matrix-org/matrix-react-sdk/pull/6208)
* Allow click-to-ping, quote & emoji picker for edit composer too
[\#5858](https://github.com/matrix-org/matrix-react-sdk/pull/5858)
* Add call silencing
[\#6082](https://github.com/matrix-org/matrix-react-sdk/pull/6082)
* Fix types in SlashCommands
[\#6207](https://github.com/matrix-org/matrix-react-sdk/pull/6207)
* Benchmark multiple common user scenario
[\#6190](https://github.com/matrix-org/matrix-react-sdk/pull/6190)
* Fix forward dialog message preview display names
[\#6204](https://github.com/matrix-org/matrix-react-sdk/pull/6204)
* Remove stray bullet point in reply preview
[\#6206](https://github.com/matrix-org/matrix-react-sdk/pull/6206)
* Stop requesting null next replies from the server
[\#6203](https://github.com/matrix-org/matrix-react-sdk/pull/6203)
* Fix soft crash caused by a broken shouldComponentUpdate
[\#6202](https://github.com/matrix-org/matrix-react-sdk/pull/6202)
* Keep composer reply when scrolling away from a highlighted event
[\#6200](https://github.com/matrix-org/matrix-react-sdk/pull/6200)
* Cache virtual/native room mappings when they're created
[\#6194](https://github.com/matrix-org/matrix-react-sdk/pull/6194)
* Disable comment-on-alert
[\#6191](https://github.com/matrix-org/matrix-react-sdk/pull/6191)
* Bump postcss from 7.0.35 to 7.0.36
[\#6195](https://github.com/matrix-org/matrix-react-sdk/pull/6195)
Changes in [3.24.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.24.0) (2021-06-21) Changes in [3.24.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.24.0) (2021-06-21)
===================================================================================================== =====================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.24.0-rc.1...v3.24.0) [Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.24.0-rc.1...v3.24.0)

View file

@ -1,6 +1,6 @@
{ {
"name": "matrix-react-sdk", "name": "matrix-react-sdk",
"version": "3.24.0", "version": "3.25.0",
"description": "SDK for matrix.org using React", "description": "SDK for matrix.org using React",
"author": "matrix.org", "author": "matrix.org",
"repository": { "repository": {
@ -45,7 +45,7 @@
"start:all": "concurrently --kill-others-on-fail --prefix \"{time} [{name}]\" -n build,reskindex \"yarn start:build\" \"yarn reskindex:watch\"", "start:all": "concurrently --kill-others-on-fail --prefix \"{time} [{name}]\" -n build,reskindex \"yarn start:build\" \"yarn reskindex:watch\"",
"start:build": "babel src -w -s -d lib --verbose --extensions \".ts,.js\"", "start:build": "babel src -w -s -d lib --verbose --extensions \".ts,.js\"",
"lint": "yarn lint:types && yarn lint:js && yarn lint:style", "lint": "yarn lint:types && yarn lint:js && yarn lint:style",
"lint:js": "eslint --max-warnings 0 --ignore-path .eslintignore.errorfiles src test", "lint:js": "eslint --max-warnings 0 src test",
"lint:types": "tsc --noEmit --jsx react", "lint:types": "tsc --noEmit --jsx react",
"lint:style": "stylelint 'res/css/**/*.scss'", "lint:style": "stylelint 'res/css/**/*.scss'",
"test": "jest", "test": "jest",
@ -54,7 +54,9 @@
}, },
"dependencies": { "dependencies": {
"@babel/runtime": "^7.12.5", "@babel/runtime": "^7.12.5",
"@types/commonmark": "^0.27.4",
"await-lock": "^2.1.0", "await-lock": "^2.1.0",
"blurhash": "^1.1.3",
"browser-encrypt-attachment": "^0.3.0", "browser-encrypt-attachment": "^0.3.0",
"browser-request": "^0.3.3", "browser-request": "^0.3.3",
"cheerio": "^1.0.0-rc.9", "cheerio": "^1.0.0-rc.9",
@ -78,7 +80,7 @@
"katex": "^0.12.0", "katex": "^0.12.0",
"linkifyjs": "^2.1.9", "linkifyjs": "^2.1.9",
"lodash": "^4.17.20", "lodash": "^4.17.20",
"matrix-js-sdk": "12.0.0", "matrix-js-sdk": "12.0.1",
"matrix-widget-api": "^0.1.0-beta.15", "matrix-widget-api": "^0.1.0-beta.15",
"minimist": "^1.2.5", "minimist": "^1.2.5",
"opus-recorder": "^8.0.3", "opus-recorder": "^8.0.3",

View file

@ -37,6 +37,11 @@
@import "./structures/_ViewSource.scss"; @import "./structures/_ViewSource.scss";
@import "./structures/auth/_CompleteSecurity.scss"; @import "./structures/auth/_CompleteSecurity.scss";
@import "./structures/auth/_Login.scss"; @import "./structures/auth/_Login.scss";
@import "./views/audio_messages/_AudioPlayer.scss";
@import "./views/audio_messages/_PlayPauseButton.scss";
@import "./views/audio_messages/_PlaybackContainer.scss";
@import "./views/audio_messages/_SeekBar.scss";
@import "./views/audio_messages/_Waveform.scss";
@import "./views/auth/_AuthBody.scss"; @import "./views/auth/_AuthBody.scss";
@import "./views/auth/_AuthButtons.scss"; @import "./views/auth/_AuthButtons.scss";
@import "./views/auth/_AuthFooter.scss"; @import "./views/auth/_AuthFooter.scss";
@ -52,7 +57,6 @@
@import "./views/avatars/_BaseAvatar.scss"; @import "./views/avatars/_BaseAvatar.scss";
@import "./views/avatars/_DecoratedRoomAvatar.scss"; @import "./views/avatars/_DecoratedRoomAvatar.scss";
@import "./views/avatars/_MemberStatusMessageAvatar.scss"; @import "./views/avatars/_MemberStatusMessageAvatar.scss";
@import "./views/avatars/_PulsedAvatar.scss";
@import "./views/avatars/_WidgetAvatar.scss"; @import "./views/avatars/_WidgetAvatar.scss";
@import "./views/beta/_BetaCard.scss"; @import "./views/beta/_BetaCard.scss";
@import "./views/context_menus/_CallContextMenu.scss"; @import "./views/context_menus/_CallContextMenu.scss";
@ -165,6 +169,7 @@
@import "./views/messages/_MTextBody.scss"; @import "./views/messages/_MTextBody.scss";
@import "./views/messages/_MVideoBody.scss"; @import "./views/messages/_MVideoBody.scss";
@import "./views/messages/_MVoiceMessageBody.scss"; @import "./views/messages/_MVoiceMessageBody.scss";
@import "./views/messages/_MediaBody.scss";
@import "./views/messages/_MessageActionBar.scss"; @import "./views/messages/_MessageActionBar.scss";
@import "./views/messages/_MessageTimestamp.scss"; @import "./views/messages/_MessageTimestamp.scss";
@import "./views/messages/_MjolnirBody.scss"; @import "./views/messages/_MjolnirBody.scss";
@ -254,9 +259,6 @@
@import "./views/toasts/_AnalyticsToast.scss"; @import "./views/toasts/_AnalyticsToast.scss";
@import "./views/toasts/_NonUrgentEchoFailureToast.scss"; @import "./views/toasts/_NonUrgentEchoFailureToast.scss";
@import "./views/verification/_VerificationShowSas.scss"; @import "./views/verification/_VerificationShowSas.scss";
@import "./views/voice_messages/_PlayPauseButton.scss";
@import "./views/voice_messages/_PlaybackContainer.scss";
@import "./views/voice_messages/_Waveform.scss";
@import "./views/voip/_CallContainer.scss"; @import "./views/voip/_CallContainer.scss";
@import "./views/voip/_CallView.scss"; @import "./views/voip/_CallView.scss";
@import "./views/voip/_CallViewForRoom.scss"; @import "./views/voip/_CallViewForRoom.scss";

View file

@ -323,7 +323,7 @@ limitations under the License.
} }
.mx_GroupView_featuredThing .mx_BaseAvatar { .mx_GroupView_featuredThing .mx_BaseAvatar {
/* To prevent misalignment with mx_TintableSvg (in addButton) */ /* To prevent misalignment with img (in addButton) */
vertical-align: initial; vertical-align: initial;
} }

View file

@ -121,23 +121,51 @@ $pulse-color: $pinned-unread-color;
box-shadow: 0 0 0 0 rgba($pulse-color, 1); box-shadow: 0 0 0 0 rgba($pulse-color, 1);
animation: mx_RightPanel_indicator_pulse 2s infinite; animation: mx_RightPanel_indicator_pulse 2s infinite;
animation-iteration-count: 1; animation-iteration-count: 1;
&::after {
content: "";
position: absolute;
width: inherit;
height: inherit;
top: 0;
left: 0;
transform: scale(1);
transform-origin: center center;
animation-name: mx_RightPanel_indicator_pulse_shadow;
animation-duration: inherit;
animation-iteration-count: inherit;
border-radius: 50%;
background: rgba($pulse-color, 1);
}
} }
} }
@keyframes mx_RightPanel_indicator_pulse { @keyframes mx_RightPanel_indicator_pulse {
0% { 0% {
transform: scale(0.95); transform: scale(0.95);
box-shadow: 0 0 0 0 rgba($pulse-color, 0.7);
} }
70% { 70% {
transform: scale(1); transform: scale(1);
box-shadow: 0 0 0 10px rgba($pulse-color, 0);
} }
100% { 100% {
transform: scale(0.95); transform: scale(0.95);
box-shadow: 0 0 0 0 rgba($pulse-color, 0); }
}
@keyframes mx_RightPanel_indicator_pulse_shadow {
0% {
opacity: 0.7;
}
70% {
transform: scale(2.2);
opacity: 0;
}
100% {
opacity: 0;
} }
} }

View file

@ -57,14 +57,15 @@ limitations under the License.
@keyframes mx_RoomView_fileDropTarget_image_animation { @keyframes mx_RoomView_fileDropTarget_image_animation {
from { from {
width: 0px; transform: scaleX(0);
} }
to { to {
width: 32px; transform: scaleX(1);
} }
} }
.mx_RoomView_fileDropTarget_image { .mx_RoomView_fileDropTarget_image {
width: 32px;
animation: mx_RoomView_fileDropTarget_image_animation; animation: mx_RoomView_fileDropTarget_image_animation;
animation-duration: 0.5s; animation-duration: 0.5s;
margin-bottom: 16px; margin-bottom: 16px;

View file

@ -0,0 +1,68 @@
/*
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_AudioPlayer_container {
padding: 16px 12px 12px 12px;
max-width: 267px; // use max to make the control fit in the files/pinned panels
.mx_AudioPlayer_primaryContainer {
display: flex;
.mx_PlayPauseButton {
margin-right: 8px;
}
.mx_AudioPlayer_mediaInfo {
flex: 1;
overflow: hidden; // makes the ellipsis on the file name work
& > * {
display: block;
}
.mx_AudioPlayer_mediaName {
color: $primary-fg-color;
font-size: $font-15px;
line-height: $font-15px;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
padding-bottom: 4px; // mimics the line-height differences in the Figma
}
.mx_AudioPlayer_byline {
font-size: $font-12px;
line-height: $font-12px;
}
}
}
.mx_AudioPlayer_seek {
display: flex;
align-items: center;
.mx_SeekBar {
flex: 1;
}
.mx_Clock {
width: $font-42px; // we're not using a monospace font, so fake it
min-width: $font-42px; // for flexbox
padding-left: 4px; // isolate from seek bar
text-align: right;
}
}
}

View file

@ -18,6 +18,8 @@ limitations under the License.
position: relative; position: relative;
width: 32px; width: 32px;
height: 32px; height: 32px;
min-width: 32px; // for when the button is used in a flexbox
min-height: 32px; // for when the button is used in a flexbox
border-radius: 32px; border-radius: 32px;
background-color: $voice-playback-button-bg-color; background-color: $voice-playback-button-bg-color;

View file

@ -22,17 +22,11 @@ limitations under the License.
// 7px top and bottom for visual design. 12px left & right, but the waveform (right) // 7px top and bottom for visual design. 12px left & right, but the waveform (right)
// has a 1px padding on it that we want to account for. // has a 1px padding on it that we want to account for.
padding: 7px 12px 7px 11px; padding: 7px 12px 7px 11px;
background-color: $voice-record-waveform-bg-color;
border-radius: 12px;
// Cheat at alignment a bit // Cheat at alignment a bit
display: flex; display: flex;
align-items: center; align-items: center;
color: $voice-record-waveform-fg-color;
font-size: $font-14px;
line-height: $font-24px;
contain: content; contain: content;
.mx_Waveform { .mx_Waveform {
@ -45,7 +39,7 @@ limitations under the License.
&.mx_Waveform_bar_100pct { &.mx_Waveform_bar_100pct {
// Small animation to remove the mechanical feel of progress // Small animation to remove the mechanical feel of progress
transition: background-color 250ms ease; transition: background-color 250ms ease;
background-color: $voice-record-waveform-fg-color; background-color: $message-body-panel-fg-color;
} }
} }
} }

View file

@ -0,0 +1,103 @@
/*
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.
*/
// CSS inspiration from:
// * https://www.w3schools.com/howto/howto_js_rangeslider.asp
// * https://stackoverflow.com/a/28283806
// * https://css-tricks.com/styling-cross-browser-compatible-range-inputs-css/
.mx_SeekBar {
// Dev note: we deliberately do not have the -ms-track (and friends) selectors because we don't
// need to support IE.
appearance: none; // default style override
width: 100%;
height: 1px;
background: $quaternary-fg-color;
outline: none; // remove blue selection border
position: relative; // for before+after pseudo elements later on
cursor: pointer;
&::-webkit-slider-thumb {
appearance: none; // default style override
// Dev note: This needs to be duplicated with the -moz-range-thumb selector
// because otherwise Edge (webkit) will fail to see the styles and just refuse
// to apply them.
width: 8px;
height: 8px;
border-radius: 8px;
background-color: $tertiary-fg-color;
cursor: pointer;
}
&::-moz-range-thumb {
width: 8px;
height: 8px;
border-radius: 8px;
background-color: $tertiary-fg-color;
cursor: pointer;
// Firefox adds a border on the thumb
border: none;
}
// This is for webkit support, but we can't limit the functionality of it to just webkit
// browsers. Firefox responds to webkit-prefixed values now, which means we can't use media
// or support queries to selectively apply the rule. An upside is that this CSS doesn't work
// in firefox, so it's just wasted CPU/GPU time.
&::before { // ::before to ensure it ends up under the thumb
content: '';
background-color: $tertiary-fg-color;
// Absolute positioning to ensure it overlaps with the existing bar
position: absolute;
top: 0;
left: 0;
// Sizing to match the bar
width: 100%;
height: 1px;
// And finally dynamic width without overly hurting the rendering engine.
transform-origin: 0 100%;
transform: scaleX(var(--fillTo));
}
// This is firefox's built-in support for the above, with 100% less hacks.
&::-moz-range-progress {
background-color: $tertiary-fg-color;
height: 1px;
}
&:disabled {
opacity: 0.5;
}
// Increase clickable area for the slider (approximately same size as browser default)
// We do it this way to keep the same padding and margins of the element, avoiding margin math.
// Source: https://front-back.com/expand-clickable-areas-for-a-better-touch-experience/
&::after {
content: '';
position: absolute;
top: -6px;
bottom: -6px;
left: 0;
right: 0;
}
}

View file

@ -110,24 +110,52 @@ $dot-size: 12px;
width: $dot-size; width: $dot-size;
transform: scale(1); transform: scale(1);
background: rgba($pulse-color, 1); background: rgba($pulse-color, 1);
box-shadow: 0 0 0 0 rgba($pulse-color, 1);
animation: mx_Beta_bluePulse 2s infinite; animation: mx_Beta_bluePulse 2s infinite;
animation-iteration-count: 20; animation-iteration-count: 20;
position: relative;
&::after {
content: "";
position: absolute;
width: inherit;
height: inherit;
top: 0;
left: 0;
transform: scale(1);
transform-origin: center center;
animation-name: mx_Beta_bluePulse_shadow;
animation-duration: inherit;
animation-iteration-count: inherit;
border-radius: 50%;
background: rgba($pulse-color, 1);
}
} }
@keyframes mx_Beta_bluePulse { @keyframes mx_Beta_bluePulse {
0% { 0% {
transform: scale(0.95); transform: scale(0.95);
box-shadow: 0 0 0 0 rgba($pulse-color, 0.7);
} }
70% { 70% {
transform: scale(1); transform: scale(1);
box-shadow: 0 0 0 10px rgba($pulse-color, 0);
} }
100% { 100% {
transform: scale(0.95); transform: scale(0.95);
box-shadow: 0 0 0 0 rgba($pulse-color, 0); }
}
@keyframes mx_Beta_bluePulse_shadow {
0% {
opacity: 0.7;
}
70% {
transform: scale(2.2);
opacity: 0;
}
100% {
opacity: 0;
} }
} }

View file

@ -28,6 +28,7 @@ limitations under the License.
left: 0; left: 0;
top: 2px; // alignment top: 2px; // alignment
background-image: url("$(res)/img/element-icons/warning-badge.svg"); background-image: url("$(res)/img/element-icons/warning-badge.svg");
background-size: contain;
} }
.mx_AccessSecretStorageDialog_reset_link { .mx_AccessSecretStorageDialog_reset_link {

View file

@ -14,6 +14,8 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
$timelineImageBorderRadius: 4px;
.mx_MImageBody { .mx_MImageBody {
display: block; display: block;
} }
@ -24,7 +26,11 @@ limitations under the License.
height: 100%; height: 100%;
left: 0; left: 0;
top: 0; top: 0;
border-radius: 4px; border-radius: $timelineImageBorderRadius;
> canvas {
border-radius: $timelineImageBorderRadius;
}
} }
.mx_MImageBody_thumbnail_container { .mx_MImageBody_thumbnail_container {
@ -42,7 +48,7 @@ limitations under the License.
top: 50%; top: 50%;
} }
// Inner img and TintableSvg should be centered around 0, 0 // Inner img should be centered around 0, 0
.mx_MImageBody_thumbnail_spinner > * { .mx_MImageBody_thumbnail_spinner > * {
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
} }

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2020 The Matrix.org Foundation C.I.C. 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.
@ -14,17 +14,15 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
.mx_PulsedAvatar { // A "media body" is any file upload looking thing, apart from images and videos (they
@keyframes shadow-pulse { // have unique styles).
0% {
box-shadow: 0 0 0 0px rgba($accent-color, 0.2);
}
100% {
box-shadow: 0 0 0 6px rgba($accent-color, 0);
}
}
img { .mx_MediaBody {
animation: shadow-pulse 1s infinite; background-color: $message-body-panel-bg-color;
} border-radius: 12px;
color: $message-body-panel-fg-color;
font-size: $font-14px;
line-height: $font-24px;
} }

View file

@ -48,6 +48,7 @@ limitations under the License.
.mx_cryptoEvent_buttons { .mx_cryptoEvent_buttons {
align-items: center; align-items: center;
display: flex; display: flex;
gap: 5px;
} }
.mx_cryptoEvent_state { .mx_cryptoEvent_state {

View file

@ -215,8 +215,6 @@ $message-body-panel-icon-fg-color: #21262C; // "Separator"
$message-body-panel-icon-bg-color: $tertiary-fg-color; $message-body-panel-icon-bg-color: $tertiary-fg-color;
$voice-record-stop-border-color: $quaternary-fg-color; $voice-record-stop-border-color: $quaternary-fg-color;
$voice-record-waveform-bg-color: $message-body-panel-bg-color;
$voice-record-waveform-fg-color: $message-body-panel-fg-color;
$voice-record-waveform-incomplete-fg-color: $quaternary-fg-color; $voice-record-waveform-incomplete-fg-color: $quaternary-fg-color;
$voice-record-icon-color: $quaternary-fg-color; $voice-record-icon-color: $quaternary-fg-color;
$voice-playback-button-bg-color: $message-body-panel-icon-bg-color; $voice-playback-button-bg-color: $message-body-panel-icon-bg-color;

View file

@ -20,6 +20,9 @@ $tertiary-fg-color: $primary-fg-color;
$primary-bg-color: $bg-color; $primary-bg-color: $bg-color;
$muted-fg-color: $header-panel-text-primary-color; $muted-fg-color: $header-panel-text-primary-color;
// Legacy theme backports
$quaternary-fg-color: #6F7882;
// used for dialog box text // used for dialog box text
$light-fg-color: $header-panel-text-secondary-color; $light-fg-color: $header-panel-text-secondary-color;
@ -209,8 +212,6 @@ $message-body-panel-icon-bg-color: $secondary-fg-color;
// See non-legacy dark for variable information // See non-legacy dark for variable information
$voice-record-stop-border-color: #6F7882; $voice-record-stop-border-color: #6F7882;
$voice-record-waveform-bg-color: $message-body-panel-bg-color;
$voice-record-waveform-fg-color: $message-body-panel-fg-color;
$voice-record-waveform-incomplete-fg-color: #6F7882; $voice-record-waveform-incomplete-fg-color: #6F7882;
$voice-record-icon-color: #6F7882; $voice-record-icon-color: #6F7882;
$voice-playback-button-bg-color: $tertiary-fg-color; $voice-playback-button-bg-color: $tertiary-fg-color;

View file

@ -28,6 +28,9 @@ $tertiary-fg-color: $primary-fg-color;
$primary-bg-color: #ffffff; $primary-bg-color: #ffffff;
$muted-fg-color: #61708b; // Commonly used in headings and relevant alt text $muted-fg-color: #61708b; // Commonly used in headings and relevant alt text
// Legacy theme backports
$quaternary-fg-color: #C1C6CD;
// used for dialog box text // used for dialog box text
$light-fg-color: #747474; $light-fg-color: #747474;
@ -334,8 +337,6 @@ $message-body-panel-icon-bg-color: $primary-bg-color;
$voice-record-stop-symbol-color: #ff4b55; $voice-record-stop-symbol-color: #ff4b55;
$voice-record-live-circle-color: #ff4b55; $voice-record-live-circle-color: #ff4b55;
$voice-record-stop-border-color: #E3E8F0; $voice-record-stop-border-color: #E3E8F0;
$voice-record-waveform-bg-color: $message-body-panel-bg-color;
$voice-record-waveform-fg-color: $message-body-panel-fg-color;
$voice-record-waveform-incomplete-fg-color: #C1C6CD; $voice-record-waveform-incomplete-fg-color: #C1C6CD;
$voice-record-icon-color: $tertiary-fg-color; $voice-record-icon-color: $tertiary-fg-color;
$voice-playback-button-bg-color: $message-body-panel-icon-bg-color; $voice-playback-button-bg-color: $message-body-panel-icon-bg-color;

View file

@ -335,8 +335,6 @@ $voice-record-stop-symbol-color: #ff4b55;
$voice-record-live-circle-color: #ff4b55; $voice-record-live-circle-color: #ff4b55;
$voice-record-stop-border-color: #E3E8F0; // "Separator" $voice-record-stop-border-color: #E3E8F0; // "Separator"
$voice-record-waveform-bg-color: $message-body-panel-bg-color;
$voice-record-waveform-fg-color: $message-body-panel-fg-color;
$voice-record-waveform-incomplete-fg-color: $quaternary-fg-color; $voice-record-waveform-incomplete-fg-color: $quaternary-fg-color;
$voice-record-icon-color: $tertiary-fg-color; $voice-record-icon-color: $tertiary-fg-color;
$voice-playback-button-bg-color: $message-body-panel-icon-bg-color; $voice-playback-button-bg-color: $message-body-panel-icon-bg-color;

View file

@ -1,23 +0,0 @@
#!/bin/sh
#
# generates .eslintignore.errorfiles to list the files which have errors in,
# so that they can be ignored in future automated linting.
out=.eslintignore.errorfiles
cd `dirname $0`/..
echo "generating $out"
{
cat <<EOF
# autogenerated file: run scripts/generate-eslint-error-ignore-file to update.
EOF
./node_modules/.bin/eslint -f json src test |
jq -r '.[] | select((.errorCount + .warningCount) > 0) | .filePath' |
sed -e 's/.*matrix-react-sdk\///';
} > "$out"
# also append rules from eslintignore file
cat .eslintignore >> $out

View file

@ -46,6 +46,7 @@ import { VoiceRecordingStore } from "../stores/VoiceRecordingStore";
import PerformanceMonitor from "../performance"; import PerformanceMonitor from "../performance";
import UIStore from "../stores/UIStore"; import UIStore from "../stores/UIStore";
import { SetupEncryptionStore } from "../stores/SetupEncryptionStore"; import { SetupEncryptionStore } from "../stores/SetupEncryptionStore";
import { RoomScrollStateStore } from "../stores/RoomScrollStateStore";
declare global { declare global {
interface Window { interface Window {
@ -87,6 +88,7 @@ declare global {
mxPerformanceEntryNames: any; mxPerformanceEntryNames: any;
mxUIStore: UIStore; mxUIStore: UIStore;
mxSetupEncryptionStore?: SetupEncryptionStore; mxSetupEncryptionStore?: SetupEncryptionStore;
mxRoomScrollStateStore?: RoomScrollStateStore;
} }
interface Document { interface Document {

View file

@ -124,9 +124,9 @@ interface ThirdpartyLookupResponseFields {
} }
interface ThirdpartyLookupResponse { interface ThirdpartyLookupResponse {
userid: string, userid: string;
protocol: string, protocol: string;
fields: ThirdpartyLookupResponseFields, fields: ThirdpartyLookupResponseFields;
} }
// Unlike 'CallType' in js-sdk, this one includes screen sharing // Unlike 'CallType' in js-sdk, this one includes screen sharing

View file

@ -17,9 +17,10 @@ limitations under the License.
*/ */
import React from "react"; import React from "react";
import dis from './dispatcher/dispatcher'; import { encode } from "blurhash";
import { MatrixClientPeg } from './MatrixClientPeg';
import { MatrixClient } from "matrix-js-sdk/src/client"; import { MatrixClient } from "matrix-js-sdk/src/client";
import dis from './dispatcher/dispatcher';
import * as sdk from './index'; import * as sdk from './index';
import { _t } from './languageHandler'; import { _t } from './languageHandler';
import Modal from './Modal'; import Modal from './Modal';
@ -47,6 +48,10 @@ const MAX_HEIGHT = 600;
// 5669 px (x-axis) , 5669 px (y-axis) , per metre // 5669 px (x-axis) , 5669 px (y-axis) , per metre
const PHYS_HIDPI = [0x00, 0x00, 0x16, 0x25, 0x00, 0x00, 0x16, 0x25, 0x01]; const PHYS_HIDPI = [0x00, 0x00, 0x16, 0x25, 0x00, 0x00, 0x16, 0x25, 0x01];
export const BLURHASH_FIELD = "xyz.amorgan.blurhash"; // MSC2448
const BLURHASH_X_COMPONENTS = 6;
const BLURHASH_Y_COMPONENTS = 6;
export class UploadCanceledError extends Error {} export class UploadCanceledError extends Error {}
type ThumbnailableElement = HTMLImageElement | HTMLVideoElement; type ThumbnailableElement = HTMLImageElement | HTMLVideoElement;
@ -77,6 +82,7 @@ interface IThumbnail {
}; };
w: number; w: number;
h: number; h: number;
[BLURHASH_FIELD]: string;
}; };
thumbnail: Blob; thumbnail: Blob;
} }
@ -124,7 +130,16 @@ function createThumbnail(
const canvas = document.createElement("canvas"); const canvas = document.createElement("canvas");
canvas.width = targetWidth; canvas.width = targetWidth;
canvas.height = targetHeight; canvas.height = targetHeight;
canvas.getContext("2d").drawImage(element, 0, 0, targetWidth, targetHeight); const context = canvas.getContext("2d");
context.drawImage(element, 0, 0, targetWidth, targetHeight);
const imageData = context.getImageData(0, 0, targetWidth, targetHeight);
const blurhash = encode(
imageData.data,
imageData.width,
imageData.height,
BLURHASH_X_COMPONENTS,
BLURHASH_Y_COMPONENTS,
);
canvas.toBlob(function(thumbnail) { canvas.toBlob(function(thumbnail) {
resolve({ resolve({
info: { info: {
@ -136,8 +151,9 @@ function createThumbnail(
}, },
w: inputWidth, w: inputWidth,
h: inputHeight, h: inputHeight,
[BLURHASH_FIELD]: blurhash,
}, },
thumbnail: thumbnail, thumbnail,
}); });
}, mimeType); }, mimeType);
}); });
@ -220,7 +236,8 @@ function infoForImageFile(matrixClient, roomId, imageFile) {
} }
/** /**
* Load a file into a newly created video element. * Load a file into a newly created video element and pull some strings
* in an attempt to guarantee the first frame will be showing.
* *
* @param {File} videoFile The file to load in an video element. * @param {File} videoFile The file to load in an video element.
* @return {Promise} A promise that resolves with the video image element. * @return {Promise} A promise that resolves with the video image element.
@ -229,20 +246,25 @@ function loadVideoElement(videoFile): Promise<HTMLVideoElement> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
// Load the file into an html element // Load the file into an html element
const video = document.createElement("video"); const video = document.createElement("video");
video.preload = "metadata";
video.playsInline = true;
video.muted = true;
const reader = new FileReader(); const reader = new FileReader();
reader.onload = function(ev) { reader.onload = function(ev) {
video.src = ev.target.result as string;
// Once ready, returns its size
// Wait until we have enough data to thumbnail the first frame. // Wait until we have enough data to thumbnail the first frame.
video.onloadeddata = function() { video.onloadeddata = async function() {
resolve(video); resolve(video);
video.pause();
}; };
video.onerror = function(e) { video.onerror = function(e) {
reject(e); reject(e);
}; };
video.src = ev.target.result as string;
video.load();
video.play();
}; };
reader.onerror = function(e) { reader.onerror = function(e) {
reject(e); reject(e);
@ -347,7 +369,7 @@ export function uploadFile(
}); });
(prom as IAbortablePromise<any>).abort = () => { (prom as IAbortablePromise<any>).abort = () => {
canceled = true; canceled = true;
if (uploadPromise) MatrixClientPeg.get().cancelUpload(uploadPromise); if (uploadPromise) matrixClient.cancelUpload(uploadPromise);
}; };
return prom; return prom;
} else { } else {
@ -357,11 +379,11 @@ export function uploadFile(
const promise1 = basePromise.then(function(url) { const promise1 = basePromise.then(function(url) {
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": url }; return { url };
}); });
(promise1 as any).abort = () => { (promise1 as any).abort = () => {
canceled = true; canceled = true;
MatrixClientPeg.get().cancelUpload(basePromise); matrixClient.cancelUpload(basePromise);
}; };
return promise1; return promise1;
} }
@ -373,7 +395,7 @@ export default class ContentMessages {
sendStickerContentToRoom(url: string, roomId: string, info: IImageInfo, text: string, matrixClient: MatrixClient) { sendStickerContentToRoom(url: string, roomId: string, info: IImageInfo, text: string, matrixClient: MatrixClient) {
const startTime = CountlyAnalytics.getTimestamp(); const startTime = CountlyAnalytics.getTimestamp();
const prom = MatrixClientPeg.get().sendStickerMessage(roomId, url, info, text).catch((e) => { const prom = matrixClient.sendStickerMessage(roomId, url, info, text).catch((e) => {
console.warn(`Failed to send content with URL ${url} to room ${roomId}`, e); console.warn(`Failed to send content with URL ${url} to room ${roomId}`, e);
throw e; throw e;
}); });
@ -415,7 +437,7 @@ export default class ContentMessages {
if (!this.mediaConfig) { // hot-path optimization to not flash a spinner if we don't need to if (!this.mediaConfig) { // hot-path optimization to not flash a spinner if we don't need to
const modal = Modal.createDialog(Spinner, null, 'mx_Dialog_spinner'); const modal = Modal.createDialog(Spinner, null, 'mx_Dialog_spinner');
await this.ensureMediaConfigFetched(); await this.ensureMediaConfigFetched(matrixClient);
modal.close(); modal.close();
} }
@ -470,7 +492,7 @@ export default class ContentMessages {
return this.inprogress.filter(u => !u.canceled); return this.inprogress.filter(u => !u.canceled);
} }
cancelUpload(promise: Promise<any>) { cancelUpload(promise: Promise<any>, matrixClient: MatrixClient) {
let upload: IUpload; let upload: IUpload;
for (let i = 0; i < this.inprogress.length; ++i) { for (let i = 0; i < this.inprogress.length; ++i) {
if (this.inprogress[i].promise === promise) { if (this.inprogress[i].promise === promise) {
@ -480,7 +502,7 @@ export default class ContentMessages {
} }
if (upload) { if (upload) {
upload.canceled = true; upload.canceled = true;
MatrixClientPeg.get().cancelUpload(upload.promise); matrixClient.cancelUpload(upload.promise);
dis.dispatch<UploadCanceledPayload>({ action: Action.UploadCanceled, upload }); dis.dispatch<UploadCanceledPayload>({ action: Action.UploadCanceled, upload });
} }
} }
@ -621,11 +643,11 @@ export default class ContentMessages {
return true; return true;
} }
private ensureMediaConfigFetched() { private ensureMediaConfigFetched(matrixClient: MatrixClient) {
if (this.mediaConfig !== null) return; if (this.mediaConfig !== null) return;
console.log("[Media Config] Fetching"); console.log("[Media Config] Fetching");
return MatrixClientPeg.get().getMediaConfig().then((config) => { return matrixClient.getMediaConfig().then((config) => {
console.log("[Media Config] Fetched config:", config); console.log("[Media Config] Fetched config:", config);
return config; return config;
}).catch(() => { }).catch(() => {

View file

@ -15,12 +15,13 @@ limitations under the License.
*/ */
import { randomString } from "matrix-js-sdk/src/randomstring"; import { randomString } from "matrix-js-sdk/src/randomstring";
import { IContent } from "matrix-js-sdk/src/models/event";
import { sleep } from "matrix-js-sdk/src/utils";
import { getCurrentLanguage } from './languageHandler'; import { getCurrentLanguage } from './languageHandler';
import PlatformPeg from './PlatformPeg'; import PlatformPeg from './PlatformPeg';
import SdkConfig from './SdkConfig'; import SdkConfig from './SdkConfig';
import { MatrixClientPeg } from "./MatrixClientPeg"; import { MatrixClientPeg } from "./MatrixClientPeg";
import { sleep } from "./utils/promise";
import RoomViewStore from "./stores/RoomViewStore"; import RoomViewStore from "./stores/RoomViewStore";
import { Action } from "./dispatcher/actions"; import { Action } from "./dispatcher/actions";
@ -255,7 +256,7 @@ interface ICreateRoomEvent extends IEvent {
num_users: number; num_users: number;
is_encrypted: boolean; is_encrypted: boolean;
is_public: boolean; is_public: boolean;
} };
} }
interface IJoinRoomEvent extends IEvent { interface IJoinRoomEvent extends IEvent {
@ -868,7 +869,7 @@ export default class CountlyAnalytics {
roomId: string, roomId: string,
isEdit: boolean, isEdit: boolean,
isReply: boolean, isReply: boolean,
content: {format?: string, msgtype: string}, content: IContent,
) { ) {
if (this.disabled) return; if (this.disabled) return;
const cli = MatrixClientPeg.get(); const cli = MatrixClientPeg.get();

View file

@ -358,11 +358,11 @@ interface IOpts {
stripReplyFallback?: boolean; stripReplyFallback?: boolean;
returnString?: boolean; returnString?: boolean;
forComposerQuote?: boolean; forComposerQuote?: boolean;
ref?: React.Ref<any>; ref?: React.Ref<HTMLSpanElement>;
} }
export interface IOptsReturnNode extends IOpts { export interface IOptsReturnNode extends IOpts {
returnString: false; returnString: false | undefined;
} }
export interface IOptsReturnString extends IOpts { export interface IOptsReturnString extends IOpts {
@ -403,9 +403,14 @@ export function bodyToHtml(content: IContent, highlights: string[], opts: IOpts
try { try {
if (highlights && highlights.length > 0) { if (highlights && highlights.length > 0) {
const highlighter = new HtmlHighlighter("mx_EventTile_searchHighlight", opts.highlightLink); const highlighter = new HtmlHighlighter("mx_EventTile_searchHighlight", opts.highlightLink);
const safeHighlights = highlights.map(function(highlight) { const safeHighlights = highlights
return sanitizeHtml(highlight, sanitizeParams); // sanitizeHtml can hang if an unclosed HTML tag is thrown at it
}); // A search for `<foo` will make the browser crash
// an alternative would be to escape HTML special characters
// but that would bring no additional benefit as the highlighter
// does not work with those special chars
.filter((highlight: string): boolean => !highlight.includes("<"))
.map((highlight: string): string => sanitizeHtml(highlight, sanitizeParams));
// XXX: hacky bodge to temporarily apply a textFilter to the sanitizeParams structure. // XXX: hacky bodge to temporarily apply a textFilter to the sanitizeParams structure.
sanitizeParams.textFilter = function(safeText) { sanitizeParams.textFilter = function(safeText) {
return highlighter.applyHighlights(safeText, safeHighlights).join(''); return highlighter.applyHighlights(safeText, safeHighlights).join('');

View file

@ -1,5 +1,6 @@
/* /*
Copyright 2016 OpenMarket Ltd Copyright 2016 OpenMarket 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.
@ -15,16 +16,24 @@ limitations under the License.
*/ */
import * as commonmark from 'commonmark'; import * as commonmark from 'commonmark';
import {escape} from "lodash"; import { escape } from "lodash";
const ALLOWED_HTML_TAGS = ['sub', 'sup', 'del', 'u']; const ALLOWED_HTML_TAGS = ['sub', 'sup', 'del', 'u'];
// These types of node are definitely text // These types of node are definitely text
const TEXT_NODES = ['text', 'softbreak', 'linebreak', 'paragraph', 'document']; const TEXT_NODES = ['text', 'softbreak', 'linebreak', 'paragraph', 'document'];
function is_allowed_html_tag(node) { // As far as @types/commonmark is concerned, these are not public, so add them
interface CommonmarkHtmlRendererInternal extends commonmark.HtmlRenderer {
paragraph: (node: commonmark.Node, entering: boolean) => void;
link: (node: commonmark.Node, entering: boolean) => void;
html_inline: (node: commonmark.Node) => void; // eslint-disable-line camelcase
html_block: (node: commonmark.Node) => void; // eslint-disable-line camelcase
}
function isAllowedHtmlTag(node: commonmark.Node): boolean {
if (node.literal != null && if (node.literal != null &&
node.literal.match('^<((div|span) data-mx-maths="[^"]*"|\/(div|span))>$') != null) { node.literal.match('^<((div|span) data-mx-maths="[^"]*"|/(div|span))>$') != null) {
return true; return true;
} }
@ -39,21 +48,12 @@ function is_allowed_html_tag(node) {
return false; return false;
} }
function html_if_tag_allowed(node) {
if (is_allowed_html_tag(node)) {
this.lit(node.literal);
return;
} else {
this.lit(escape(node.literal));
}
}
/* /*
* Returns true if the parse output containing the node * Returns true if the parse output containing the node
* comprises multiple block level elements (ie. lines), * comprises multiple block level elements (ie. lines),
* or false if it is only a single line. * or false if it is only a single line.
*/ */
function is_multi_line(node) { function isMultiLine(node: commonmark.Node): boolean {
let par = node; let par = node;
while (par.parent) { while (par.parent) {
par = par.parent; par = par.parent;
@ -67,6 +67,9 @@ function is_multi_line(node) {
* it's plain text. * it's plain text.
*/ */
export default class Markdown { export default class Markdown {
private input: string;
private parsed: commonmark.Node;
constructor(input) { constructor(input) {
this.input = input; this.input = input;
@ -74,7 +77,7 @@ export default class Markdown {
this.parsed = parser.parse(this.input); this.parsed = parser.parse(this.input);
} }
isPlainText() { isPlainText(): boolean {
const walker = this.parsed.walker(); const walker = this.parsed.walker();
let ev; let ev;
@ -87,7 +90,7 @@ export default class Markdown {
// if it's an allowed html tag, we need to render it and therefore // if it's an allowed html tag, we need to render it and therefore
// we will need to use HTML. If it's not allowed, it's not HTML since // we will need to use HTML. If it's not allowed, it's not HTML since
// we'll just be treating it as text. // we'll just be treating it as text.
if (is_allowed_html_tag(node)) { if (isAllowedHtmlTag(node)) {
return false; return false;
} }
} else { } else {
@ -97,7 +100,7 @@ export default class Markdown {
return true; return true;
} }
toHTML({ externalLinks = false } = {}) { toHTML({ externalLinks = false } = {}): string {
const renderer = new commonmark.HtmlRenderer({ const renderer = new commonmark.HtmlRenderer({
safe: false, safe: false,
@ -107,7 +110,7 @@ export default class Markdown {
// block quote ends up all on one line // block quote ends up all on one line
// (https://github.com/vector-im/element-web/issues/3154) // (https://github.com/vector-im/element-web/issues/3154)
softbreak: '<br />', softbreak: '<br />',
}); }) as CommonmarkHtmlRendererInternal;
// Trying to strip out the wrapping <p/> causes a lot more complication // Trying to strip out the wrapping <p/> causes a lot more complication
// than it's worth, i think. For instance, this code will go and strip // than it's worth, i think. For instance, this code will go and strip
@ -118,16 +121,16 @@ export default class Markdown {
// //
// Let's try sending with <p/>s anyway for now, though. // Let's try sending with <p/>s anyway for now, though.
const real_paragraph = renderer.paragraph; const realParagraph = renderer.paragraph;
renderer.paragraph = function(node, entering) { renderer.paragraph = function(node: commonmark.Node, entering: boolean) {
// If there is only one top level node, just return the // If there is only one top level node, just return the
// bare text: it's a single line of text and so should be // bare text: it's a single line of text and so should be
// 'inline', rather than unnecessarily wrapped in its own // 'inline', rather than unnecessarily wrapped in its own
// p tag. If, however, we have multiple nodes, each gets // p tag. If, however, we have multiple nodes, each gets
// its own p tag to keep them as separate paragraphs. // its own p tag to keep them as separate paragraphs.
if (is_multi_line(node)) { if (isMultiLine(node)) {
real_paragraph.call(this, node, entering); realParagraph.call(this, node, entering);
} }
}; };
@ -150,19 +153,26 @@ export default class Markdown {
} }
}; };
renderer.html_inline = html_if_tag_allowed; renderer.html_inline = function(node: commonmark.Node) {
if (isAllowedHtmlTag(node)) {
this.lit(node.literal);
return;
} else {
this.lit(escape(node.literal));
}
};
renderer.html_block = function(node) { renderer.html_block = function(node: commonmark.Node) {
/* /*
// as with `paragraph`, we only insert line breaks // as with `paragraph`, we only insert line breaks
// if there are multiple lines in the markdown. // if there are multiple lines in the markdown.
const isMultiLine = is_multi_line(node); const isMultiLine = is_multi_line(node);
if (isMultiLine) this.cr(); if (isMultiLine) this.cr();
*/ */
html_if_tag_allowed.call(this, node); renderer.html_inline(node);
/* /*
if (isMultiLine) this.cr(); if (isMultiLine) this.cr();
*/ */
}; };
return renderer.render(this.parsed); return renderer.render(this.parsed);
@ -177,23 +187,22 @@ export default class Markdown {
* N.B. this does **NOT** render arbitrary MD to plain text - only MD * N.B. this does **NOT** render arbitrary MD to plain text - only MD
* which has no formatting. Otherwise it emits HTML(!). * which has no formatting. Otherwise it emits HTML(!).
*/ */
toPlaintext() { toPlaintext(): string {
const renderer = new commonmark.HtmlRenderer({safe: false}); const renderer = new commonmark.HtmlRenderer({ safe: false }) as CommonmarkHtmlRendererInternal;
const real_paragraph = renderer.paragraph;
renderer.paragraph = function(node, entering) { renderer.paragraph = function(node: commonmark.Node, entering: boolean) {
// as with toHTML, only append lines to paragraphs if there are // as with toHTML, only append lines to paragraphs if there are
// multiple paragraphs // multiple paragraphs
if (is_multi_line(node)) { if (isMultiLine(node)) {
if (!entering && node.next) { if (!entering && node.next) {
this.lit('\n\n'); this.lit('\n\n');
} }
} }
}; };
renderer.html_block = function(node) { renderer.html_block = function(node: commonmark.Node) {
this.lit(node.literal); this.lit(node.literal);
if (is_multi_line(node) && node.next) this.lit('\n\n'); if (isMultiLine(node) && node.next) this.lit('\n\n');
}; };
return renderer.render(this.parsed); return renderer.render(this.parsed);

View file

@ -18,10 +18,10 @@ limitations under the License.
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import classNames from 'classnames'; import classNames from 'classnames';
import { defer } from "matrix-js-sdk/src/utils";
import Analytics from './Analytics'; import Analytics from './Analytics';
import dis from './dispatcher/dispatcher'; import dis from './dispatcher/dispatcher';
import { defer } from './utils/promise';
import AsyncWrapper from './AsyncWrapper'; import AsyncWrapper from './AsyncWrapper';
const DIALOG_CONTAINER_ID = "mx_Dialog_Container"; const DIALOG_CONTAINER_ID = "mx_Dialog_Container";

View file

@ -42,8 +42,8 @@ let secretStorageBeingAccessed = false;
let nonInteractive = false; let nonInteractive = false;
let dehydrationCache: { let dehydrationCache: {
key?: Uint8Array, key?: Uint8Array;
keyInfo?: ISecretStorageKeyInfo, keyInfo?: ISecretStorageKeyInfo;
} = {}; } = {};
function isCachingAllowed(): boolean { function isCachingAllowed(): boolean {

View file

@ -1181,7 +1181,7 @@ export const Commands = [
]; ];
// build a map from names and aliases to the Command objects. // build a map from names and aliases to the Command objects.
export const CommandMap = new Map(); export const CommandMap = new Map<string, Command>();
Commands.forEach(cmd => { Commands.forEach(cmd => {
CommandMap.set(cmd.command, cmd); CommandMap.set(cmd.command, cmd);
cmd.aliases.forEach(alias => { cmd.aliases.forEach(alias => {
@ -1189,15 +1189,15 @@ Commands.forEach(cmd => {
}); });
}); });
export function parseCommandString(input: string) { export function parseCommandString(input: string): { cmd?: string, args?: string } {
// trim any trailing whitespace, as it can confuse the parser for // trim any trailing whitespace, as it can confuse the parser for
// IRC-style commands // IRC-style commands
input = input.replace(/\s+$/, ''); input = input.replace(/\s+$/, '');
if (input[0] !== '/') return {}; // not a command if (input[0] !== '/') return {}; // not a command
const bits = input.match(/^(\S+?)(?:[ \n]+((.|\n)*))?$/); const bits = input.match(/^(\S+?)(?:[ \n]+((.|\n)*))?$/);
let cmd; let cmd: string;
let args; let args: string;
if (bits) { if (bits) {
cmd = bits[1].substring(1).toLowerCase(); cmd = bits[1].substring(1).toLowerCase();
args = bits[2]; args = bits[2];
@ -1208,6 +1208,11 @@ export function parseCommandString(input: string) {
return { cmd, args }; return { cmd, args };
} }
interface ICmd {
cmd?: Command;
args?: string;
}
/** /**
* Process the given text for /commands and return a bound method to perform them. * Process the given text for /commands and return a bound method to perform them.
* @param {string} roomId The room in which the command was performed. * @param {string} roomId The room in which the command was performed.
@ -1216,7 +1221,7 @@ export function parseCommandString(input: string) {
* processing the command, or 'promise' if a request was sent out. * processing the command, or 'promise' if a request was sent out.
* Returns null if the input didn't match a command. * Returns null if the input didn't match a command.
*/ */
export function getCommand(input: string) { export function getCommand(input: string): ICmd {
const { cmd, args } = parseCommandString(input); const { cmd, args } = parseCommandString(input);
if (CommandMap.has(cmd) && CommandMap.get(cmd).isEnabled()) { if (CommandMap.has(cmd) && CommandMap.get(cmd).isEnabled()) {

View file

@ -15,6 +15,7 @@ limitations under the License.
*/ */
import classNames from 'classnames'; import classNames from 'classnames';
import { SERVICE_TYPES } from 'matrix-js-sdk/src/service-types';
import { MatrixClientPeg } from './MatrixClientPeg'; import { MatrixClientPeg } from './MatrixClientPeg';
import * as sdk from '.'; import * as sdk from '.';
@ -32,7 +33,7 @@ export class Service {
* @param {string} baseUrl The Base URL of the service (ie. before '/_matrix') * @param {string} baseUrl The Base URL of the service (ie. before '/_matrix')
* @param {string} accessToken The user's access token for the service * @param {string} accessToken The user's access token for the service
*/ */
constructor(public serviceType: string, public baseUrl: string, public accessToken: string) { constructor(public serviceType: SERVICE_TYPES, public baseUrl: string, public accessToken: string) {
} }
} }
@ -48,13 +49,13 @@ export interface Policy {
} }
export type Policies = { export type Policies = {
[policy: string]: Policy, [policy: string]: Policy;
}; };
export type TermsInteractionCallback = ( export type TermsInteractionCallback = (
policiesAndServicePairs: { policiesAndServicePairs: {
service: Service, service: Service;
policies: Policies, policies: Policies;
}[], }[],
agreedUrls: string[], agreedUrls: string[],
extraClassNames?: string, extraClassNames?: string,
@ -180,8 +181,8 @@ export async function startTermsFlow(
export function dialogTermsInteractionCallback( export function dialogTermsInteractionCallback(
policiesAndServicePairs: { policiesAndServicePairs: {
service: Service, service: Service;
policies: { [policy: string]: Policy }, policies: { [policy: string]: Policy };
}[], }[],
agreedUrls: string[], agreedUrls: string[],
extraClassNames?: string, extraClassNames?: string,

View file

@ -21,8 +21,8 @@ interface IProps extends Omit<HTMLAttributes<HTMLDivElement>, "onScroll"> {
className?: string; className?: string;
onScroll?: (event: Event) => void; onScroll?: (event: Event) => void;
onWheel?: (event: WheelEvent) => void; onWheel?: (event: WheelEvent) => void;
style?: React.CSSProperties style?: React.CSSProperties;
tabIndex?: number, tabIndex?: number;
wrappedRef?: (ref: HTMLDivElement) => void; wrappedRef?: (ref: HTMLDivElement) => void;
} }

View file

@ -19,6 +19,7 @@ import React from 'react';
import { Filter } from 'matrix-js-sdk/src/filter'; import { Filter } from 'matrix-js-sdk/src/filter';
import { EventTimelineSet } from "matrix-js-sdk/src/models/event-timeline-set"; import { EventTimelineSet } from "matrix-js-sdk/src/models/event-timeline-set";
import { Direction } from "matrix-js-sdk/src/models/event-timeline";
import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { Room } from 'matrix-js-sdk/src/models/room'; import { Room } from 'matrix-js-sdk/src/models/room';
import { TimelineWindow } from 'matrix-js-sdk/src/timeline-window'; import { TimelineWindow } from 'matrix-js-sdk/src/timeline-window';
@ -37,7 +38,7 @@ import ResizeNotifier from '../../utils/ResizeNotifier';
interface IProps { interface IProps {
roomId: string; roomId: string;
onClose: () => void; onClose: () => void;
resizeNotifier: ResizeNotifier resizeNotifier: ResizeNotifier;
} }
interface IState { interface IState {
@ -129,7 +130,7 @@ class FilePanel extends React.Component<IProps, IState> {
} }
} }
public async fetchFileEventsServer(room: Room): Promise<void> { public async fetchFileEventsServer(room: Room): Promise<EventTimelineSet> {
const client = MatrixClientPeg.get(); const client = MatrixClientPeg.get();
const filter = new Filter(client.credentials.userId); const filter = new Filter(client.credentials.userId);
@ -153,7 +154,11 @@ class FilePanel extends React.Component<IProps, IState> {
return timelineSet; return timelineSet;
} }
private onPaginationRequest = (timelineWindow: TimelineWindow, direction: string, limit: number): void => { private onPaginationRequest = (
timelineWindow: TimelineWindow,
direction: Direction,
limit: number,
): Promise<boolean> => {
const client = MatrixClientPeg.get(); const client = MatrixClientPeg.get();
const eventIndex = EventIndexPeg.get(); const eventIndex = EventIndexPeg.get();
const roomId = this.props.roomId; const roomId = this.props.roomId;

View file

@ -36,7 +36,7 @@ import FlairStore from '../../stores/FlairStore';
import { showGroupAddRoomDialog } from '../../GroupAddressPicker'; import { showGroupAddRoomDialog } from '../../GroupAddressPicker';
import { makeGroupPermalink, makeUserPermalink } from "../../utils/permalinks/Permalinks"; import { makeGroupPermalink, makeUserPermalink } from "../../utils/permalinks/Permalinks";
import { Group } from "matrix-js-sdk/src/models/group"; import { Group } from "matrix-js-sdk/src/models/group";
import { sleep } from "../../utils/promise"; import { sleep } from "matrix-js-sdk/src/utils";
import RightPanelStore from "../../stores/RightPanelStore"; import RightPanelStore from "../../stores/RightPanelStore";
import AutoHideScrollbar from "./AutoHideScrollbar"; import AutoHideScrollbar from "./AutoHideScrollbar";
import { mediaFromMxc } from "../../customisations/Media"; import { mediaFromMxc } from "../../customisations/Media";

View file

@ -48,7 +48,7 @@ import { ViewRoomDeltaPayload } from "../../dispatcher/payloads/ViewRoomDeltaPay
import RoomListStore from "../../stores/room-list/RoomListStore"; import RoomListStore from "../../stores/room-list/RoomListStore";
import NonUrgentToastContainer from "./NonUrgentToastContainer"; import NonUrgentToastContainer from "./NonUrgentToastContainer";
import { ToggleRightPanelPayload } from "../../dispatcher/payloads/ToggleRightPanelPayload"; import { ToggleRightPanelPayload } from "../../dispatcher/payloads/ToggleRightPanelPayload";
import { IThreepidInvite } from "../../stores/ThreepidInviteStore"; import { IOOBData, IThreepidInvite } from "../../stores/ThreepidInviteStore";
import Modal from "../../Modal"; import Modal from "../../Modal";
import { ICollapseConfig } from "../../resizer/distributors/collapse"; import { ICollapseConfig } from "../../resizer/distributors/collapse";
import HostSignupContainer from '../views/host_signup/HostSignupContainer'; import HostSignupContainer from '../views/host_signup/HostSignupContainer';
@ -81,14 +81,14 @@ interface IProps {
page_type: string; page_type: string;
autoJoin: boolean; autoJoin: boolean;
threepidInvite?: IThreepidInvite; threepidInvite?: IThreepidInvite;
roomOobData?: object; roomOobData?: IOOBData;
currentRoomId: string; currentRoomId: string;
collapseLhs: boolean; collapseLhs: boolean;
config: { config: {
piwik: { piwik: {
policyUrl: string; policyUrl: string;
}, };
[key: string]: any, [key: string]: any;
}; };
currentUserId?: string; currentUserId?: string;
currentGroupId?: string; currentGroupId?: string;

View file

@ -19,6 +19,8 @@ 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";
// 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';
// what-input helps improve keyboard accessibility // what-input helps improve keyboard accessibility
@ -55,7 +57,6 @@ import DMRoomMap from '../../utils/DMRoomMap';
import ThemeWatcher from "../../settings/watchers/ThemeWatcher"; import ThemeWatcher from "../../settings/watchers/ThemeWatcher";
import { FontWatcher } from '../../settings/watchers/FontWatcher'; import { FontWatcher } from '../../settings/watchers/FontWatcher';
import { storeRoomAliasInCache } from '../../RoomAliasCache'; import { storeRoomAliasInCache } from '../../RoomAliasCache';
import { defer, IDeferred, sleep } from "../../utils/promise";
import ToastStore from "../../stores/ToastStore"; import ToastStore from "../../stores/ToastStore";
import * as StorageManager from "../../utils/StorageManager"; import * as StorageManager from "../../utils/StorageManager";
import type LoggedInViewType from "./LoggedInView"; import type LoggedInViewType from "./LoggedInView";
@ -203,7 +204,7 @@ interface IState {
resizeNotifier: ResizeNotifier; resizeNotifier: ResizeNotifier;
serverConfig?: ValidatedServerConfig; serverConfig?: ValidatedServerConfig;
ready: boolean; ready: boolean;
threepidInvite?: IThreepidInvite, threepidInvite?: IThreepidInvite;
roomOobData?: object; roomOobData?: object;
pendingInitialSync?: boolean; pendingInitialSync?: boolean;
justRegistered?: boolean; justRegistered?: boolean;

View file

@ -24,7 +24,7 @@ interface IProps {
} }
interface IState { interface IState {
toasts: ComponentClass[], toasts: ComponentClass[];
} }
@replaceableComponent("structures.NonUrgentToastContainer") @replaceableComponent("structures.NonUrgentToastContainer")

View file

@ -23,7 +23,6 @@ import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { VerificationRequest } from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest"; import { VerificationRequest } from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest";
import dis from '../../dispatcher/dispatcher'; import dis from '../../dispatcher/dispatcher';
import RateLimitedFunc from '../../ratelimitedfunc';
import GroupStore from '../../stores/GroupStore'; import GroupStore from '../../stores/GroupStore';
import { import {
RIGHT_PANEL_PHASES_NO_ARGS, RIGHT_PANEL_PHASES_NO_ARGS,
@ -48,6 +47,7 @@ import FilePanel from "./FilePanel";
import NotificationPanel from "./NotificationPanel"; 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';
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
@ -73,7 +73,6 @@ interface IState {
export default class RightPanel extends React.Component<IProps, IState> { export default class RightPanel extends React.Component<IProps, IState> {
static contextType = MatrixClientContext; static contextType = MatrixClientContext;
private readonly delayedUpdate: RateLimitedFunc;
private dispatcherRef: string; private dispatcherRef: string;
constructor(props, context) { constructor(props, context) {
@ -84,12 +83,12 @@ export default class RightPanel extends React.Component<IProps, IState> {
isUserPrivilegedInGroup: null, isUserPrivilegedInGroup: null,
member: this.getUserForPanel(), member: this.getUserForPanel(),
}; };
this.delayedUpdate = new RateLimitedFunc(() => {
this.forceUpdate();
}, 500);
} }
private readonly delayedUpdate = throttle((): void => {
this.forceUpdate();
}, 500, { leading: true, trailing: true });
// Helper function to split out the logic for getPhaseFromProps() and the constructor // Helper function to split out the logic for getPhaseFromProps() and the constructor
// as both are called at the same time in the constructor. // as both are called at the same time in the constructor.
private getUserForPanel() { private getUserForPanel() {

View file

@ -370,7 +370,7 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
private onFilterChange = (alias: string) => { private onFilterChange = (alias: string) => {
this.setState({ this.setState({
filterString: alias || null, filterString: alias || "",
}); });
// don't send the request for a little bit, // don't send the request for a little bit,
@ -389,7 +389,7 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
private onFilterClear = () => { private onFilterClear = () => {
// update immediately // update immediately
this.setState({ this.setState({
filterString: null, filterString: "",
}, this.refreshRoomList); }, this.refreshRoomList);
if (this.filterTimeout) { if (this.filterTimeout) {

View file

@ -37,13 +37,12 @@ import Modal from '../../Modal';
import * as sdk from '../../index'; import * as sdk from '../../index';
import CallHandler, { PlaceCallType } from '../../CallHandler'; import CallHandler, { PlaceCallType } from '../../CallHandler';
import dis from '../../dispatcher/dispatcher'; import dis from '../../dispatcher/dispatcher';
import rateLimitedFunc from '../../ratelimitedfunc';
import * as Rooms from '../../Rooms'; import * as Rooms from '../../Rooms';
import eventSearch, { searchPagination } from '../../Searching'; import eventSearch, { searchPagination } from '../../Searching';
import MainSplit from './MainSplit'; import MainSplit from './MainSplit';
import RightPanel from './RightPanel'; import RightPanel from './RightPanel';
import RoomViewStore from '../../stores/RoomViewStore'; import RoomViewStore from '../../stores/RoomViewStore';
import RoomScrollStateStore from '../../stores/RoomScrollStateStore'; import RoomScrollStateStore, { ScrollState } from '../../stores/RoomScrollStateStore';
import WidgetEchoStore from '../../stores/WidgetEchoStore'; import WidgetEchoStore from '../../stores/WidgetEchoStore';
import SettingsStore from "../../settings/SettingsStore"; import SettingsStore from "../../settings/SettingsStore";
import { Layout } from "../../settings/Layout"; import { Layout } from "../../settings/Layout";
@ -64,7 +63,7 @@ import RoomUpgradeWarningBar from "../views/rooms/RoomUpgradeWarningBar";
import AuxPanel from "../views/rooms/AuxPanel"; import AuxPanel from "../views/rooms/AuxPanel";
import RoomHeader from "../views/rooms/RoomHeader"; import RoomHeader from "../views/rooms/RoomHeader";
import { XOR } from "../../@types/common"; import { XOR } from "../../@types/common";
import { IThreepidInvite } from "../../stores/ThreepidInviteStore"; import { IOOBData, IThreepidInvite } from "../../stores/ThreepidInviteStore";
import EffectsOverlay from "../views/elements/EffectsOverlay"; import EffectsOverlay from "../views/elements/EffectsOverlay";
import { containsEmoji } from '../../effects/utils'; import { containsEmoji } from '../../effects/utils';
import { CHAT_EFFECTS } from '../../effects'; import { CHAT_EFFECTS } from '../../effects';
@ -82,6 +81,7 @@ import { IOpts } from "../../createRoom";
import { replaceableComponent } from "../../utils/replaceableComponent"; import { replaceableComponent } from "../../utils/replaceableComponent";
import UIStore from "../../stores/UIStore"; import UIStore from "../../stores/UIStore";
import EditorStateTransfer from "../../utils/EditorStateTransfer"; import EditorStateTransfer from "../../utils/EditorStateTransfer";
import { throttle } from "lodash";
const DEBUG = false; const DEBUG = false;
let debuglog = function(msg: string) {}; let debuglog = function(msg: string) {};
@ -94,22 +94,8 @@ if (DEBUG) {
} }
interface IProps { interface IProps {
threepidInvite: IThreepidInvite, threepidInvite: IThreepidInvite;
oobData?: IOOBData;
// Any data about the room that would normally come from the homeserver
// but has been passed out-of-band, eg. the room name and avatar URL
// from an email invite (a workaround for the fact that we can't
// get this information from the HS using an email invite).
// Fields:
// * name (string) The room's name
// * avatarUrl (string) The mxc:// avatar URL for the room
// * inviterName (string) The display name of the person who
// * invited us to the room
oobData?: {
name?: string;
avatarUrl?: string;
inviterName?: string;
};
resizeNotifier: ResizeNotifier; resizeNotifier: ResizeNotifier;
justCreatedOpts?: IOpts; justCreatedOpts?: IOpts;
@ -675,8 +661,8 @@ export default class RoomView extends React.Component<IProps, IState> {
); );
} }
// cancel any pending calls to the rate_limited_funcs // cancel any pending calls to the throttled updated
this.updateRoomMembers.cancelPendingCall(); this.updateRoomMembers.cancel();
for (const watcher of this.settingWatchers) { for (const watcher of this.settingWatchers) {
SettingsStore.unwatchSetting(watcher); SettingsStore.unwatchSetting(watcher);
@ -1054,11 +1040,6 @@ export default class RoomView extends React.Component<IProps, IState> {
}); });
} }
private updateTint() {
const room = this.state.room;
if (!room) return;
}
private onAccountData = (event: MatrixEvent) => { private onAccountData = (event: MatrixEvent) => {
const type = event.getType(); const type = event.getType();
if ((type === "org.matrix.preview_urls" || type === "im.vector.web.settings") && this.state.room) { if ((type === "org.matrix.preview_urls" || type === "im.vector.web.settings") && this.state.room) {
@ -1097,7 +1078,7 @@ export default class RoomView extends React.Component<IProps, IState> {
return; return;
} }
this.updateRoomMembers(member); this.updateRoomMembers();
}; };
private onMyMembership = (room: Room, membership: string, oldMembership: string) => { private onMyMembership = (room: Room, membership: string, oldMembership: string) => {
@ -1119,10 +1100,10 @@ export default class RoomView extends React.Component<IProps, IState> {
} }
// rate limited because a power level change will emit an event for every member in the room. // rate limited because a power level change will emit an event for every member in the room.
private updateRoomMembers = rateLimitedFunc(() => { private updateRoomMembers = throttle(() => {
this.updateDMState(); this.updateDMState();
this.updateE2EStatus(this.state.room); this.updateE2EStatus(this.state.room);
}, 500); }, 500, { leading: true, trailing: true });
private checkDesktopNotifications() { private checkDesktopNotifications() {
const memberCount = this.state.room.getJoinedMemberCount() + this.state.room.getInvitedMemberCount(); const memberCount = this.state.room.getJoinedMemberCount() + this.state.room.getInvitedMemberCount();
@ -1266,7 +1247,7 @@ export default class RoomView extends React.Component<IProps, IState> {
}); });
}; };
private injectSticker(url, info, text) { private injectSticker(url: string, info: object, text: string) {
if (this.context.isGuest()) { if (this.context.isGuest()) {
dis.dispatch({ action: 'require_registration' }); dis.dispatch({ action: 'require_registration' });
return; return;
@ -1465,13 +1446,6 @@ export default class RoomView extends React.Component<IProps, IState> {
}); });
}; };
private onLeaveClick = () => {
dis.dispatch({
action: 'leave_room',
room_id: this.state.room.roomId,
});
};
private onForgetClick = () => { private onForgetClick = () => {
dis.dispatch({ dis.dispatch({
action: 'forget_room', action: 'forget_room',
@ -1603,7 +1577,7 @@ export default class RoomView extends React.Component<IProps, IState> {
// get the current scroll position of the room, so that it can be // get the current scroll position of the room, so that it can be
// restored when we switch back to it. // restored when we switch back to it.
// //
private getScrollState() { private getScrollState(): ScrollState {
const messagePanel = this.messagePanel; const messagePanel = this.messagePanel;
if (!messagePanel) return null; if (!messagePanel) return null;
@ -1705,10 +1679,6 @@ export default class RoomView extends React.Component<IProps, IState> {
// otherwise react calls it with null on each update. // otherwise react calls it with null on each update.
private gatherTimelinePanelRef = r => { private gatherTimelinePanelRef = r => {
this.messagePanel = r; this.messagePanel = r;
if (r) {
console.log("updateTint from RoomView.gatherTimelinePanelRef");
this.updateTint();
}
}; };
private getOldRoom() { private getOldRoom() {
@ -2115,7 +2085,6 @@ export default class RoomView extends React.Component<IProps, IState> {
onSearchClick={this.onSearchClick} onSearchClick={this.onSearchClick}
onSettingsClick={this.onSettingsClick} onSettingsClick={this.onSettingsClick}
onForgetClick={(myMembership === "leave") ? this.onForgetClick : null} onForgetClick={(myMembership === "leave") ? this.onForgetClick : null}
onLeaveClick={(myMembership === "join") ? this.onLeaveClick : null}
e2eStatus={this.state.e2eStatus} e2eStatus={this.state.e2eStatus}
onAppsClick={this.state.hasPinnedWidgets ? this.onAppsClick : null} onAppsClick={this.state.hasPinnedWidgets ? this.onAppsClick : null}
appsShown={this.state.showApps} appsShown={this.state.showApps}

View file

@ -58,7 +58,7 @@ export interface ISpaceSummaryRoom {
avatar_url?: string; avatar_url?: string;
guest_can_join: boolean; guest_can_join: boolean;
name?: string; name?: string;
num_joined_members: number num_joined_members: number;
room_id: string; room_id: string;
topic?: string; topic?: string;
world_readable: boolean; world_readable: boolean;

View file

@ -16,11 +16,13 @@ limitations under the License.
import React, { createRef, ReactNode, SyntheticEvent } from 'react'; import React, { createRef, ReactNode, SyntheticEvent } from 'react';
import ReactDOM from "react-dom"; import ReactDOM from "react-dom";
import { Room } from "matrix-js-sdk/src/models/room"; import { 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 { TimelineSet } from "matrix-js-sdk/src/models/event-timeline-set"; import { EventTimelineSet } from "matrix-js-sdk/src/models/event-timeline-set";
import { EventTimeline } from "matrix-js-sdk/src/models/event-timeline"; import { Direction, EventTimeline } from "matrix-js-sdk/src/models/event-timeline";
import { TimelineWindow } from "matrix-js-sdk/src/timeline-window"; import { TimelineWindow } from "matrix-js-sdk/src/timeline-window";
import { EventType, RelationType } from 'matrix-js-sdk/src/@types/event';
import { SyncState } from 'matrix-js-sdk/src/sync.api';
import SettingsStore from "../../settings/SettingsStore"; import SettingsStore from "../../settings/SettingsStore";
import { Layout } from "../../settings/Layout"; import { Layout } from "../../settings/Layout";
@ -39,10 +41,8 @@ import { UIFeature } from "../../settings/UIFeature";
import { replaceableComponent } from "../../utils/replaceableComponent"; import { replaceableComponent } from "../../utils/replaceableComponent";
import { arrayFastClone } from "../../utils/arrays"; import { arrayFastClone } from "../../utils/arrays";
import MessagePanel from "./MessagePanel"; import MessagePanel from "./MessagePanel";
import { SyncState } from 'matrix-js-sdk/src/sync.api';
import { IScrollState } from "./ScrollPanel"; import { IScrollState } from "./ScrollPanel";
import { ActionPayload } from "../../dispatcher/payloads"; import { ActionPayload } from "../../dispatcher/payloads";
import { EventType } from 'matrix-js-sdk/src/@types/event';
import ResizeNotifier from "../../utils/ResizeNotifier"; import ResizeNotifier from "../../utils/ResizeNotifier";
import { RoomPermalinkCreator } from "../../utils/permalinks/Permalinks"; import { RoomPermalinkCreator } from "../../utils/permalinks/Permalinks";
import Spinner from "../views/elements/Spinner"; import Spinner from "../views/elements/Spinner";
@ -65,7 +65,7 @@ interface IProps {
// representing. This may or may not have a room, depending on what it's // representing. This may or may not have a room, depending on what it's
// a timeline representing. If it has a room, we maintain RRs etc for // a timeline representing. If it has a room, we maintain RRs etc for
// that room. // that room.
timelineSet: TimelineSet; timelineSet: EventTimelineSet;
showReadReceipts?: boolean; showReadReceipts?: boolean;
// Enable managing RRs and RMs. These require the timelineSet to have a room. // Enable managing RRs and RMs. These require the timelineSet to have a room.
manageReadReceipts?: boolean; manageReadReceipts?: boolean;
@ -125,7 +125,7 @@ interface IProps {
onReadMarkerUpdated?(): void; onReadMarkerUpdated?(): void;
// callback which is called when we wish to paginate the timeline window. // callback which is called when we wish to paginate the timeline window.
onPaginationRequest?(timelineWindow: TimelineWindow, direction: string, size: number): Promise<boolean>, onPaginationRequest?(timelineWindow: TimelineWindow, direction: string, size: number): Promise<boolean>;
} }
interface IState { interface IState {
@ -388,7 +388,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
private onPaginationRequest = ( private onPaginationRequest = (
timelineWindow: TimelineWindow, timelineWindow: TimelineWindow,
direction: string, direction: Direction,
size: number, size: number,
): Promise<boolean> => { ): Promise<boolean> => {
if (this.props.onPaginationRequest) { if (this.props.onPaginationRequest) {
@ -579,7 +579,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
}); });
}; };
private onRoomTimelineReset = (room: Room, timelineSet: TimelineSet): void => { private onRoomTimelineReset = (room: Room, timelineSet: EventTimelineSet): void => {
if (timelineSet !== this.props.timelineSet) return; if (timelineSet !== this.props.timelineSet) return;
if (this.messagePanel.current && this.messagePanel.current.isAtBottom()) { if (this.messagePanel.current && this.messagePanel.current.isAtBottom()) {
@ -792,8 +792,8 @@ class TimelinePanel extends React.Component<IProps, IState> {
// that sending an RR for the latest message will set our notif counter // that sending an RR for the latest message will set our notif counter
// to zero: it may not do this if we send an RR for somewhere before the end. // to zero: it may not do this if we send an RR for somewhere before the end.
if (this.isAtEndOfLiveTimeline()) { if (this.isAtEndOfLiveTimeline()) {
this.props.timelineSet.room.setUnreadNotificationCount('total', 0); this.props.timelineSet.room.setUnreadNotificationCount(NotificationCountType.Total, 0);
this.props.timelineSet.room.setUnreadNotificationCount('highlight', 0); this.props.timelineSet.room.setUnreadNotificationCount(NotificationCountType.Highlight, 0);
dis.dispatch({ dis.dispatch({
action: 'on_room_read', action: 'on_room_read',
roomId: this.props.timelineSet.room.roomId, roomId: this.props.timelineSet.room.roomId,
@ -1416,7 +1416,11 @@ class TimelinePanel extends React.Component<IProps, IState> {
}); });
} }
private getRelationsForEvent = (...args) => this.props.timelineSet.getRelationsForEvent(...args); private getRelationsForEvent = (
eventId: string,
relationType: RelationType,
eventType: EventType | string,
) => this.props.timelineSet.getRelationsForEvent(eventId, relationType, eventType);
render() { render() {
// just show a spinner while the timeline loads. // just show a spinner while the timeline loads.

View file

@ -26,6 +26,7 @@ import ProgressBar from "../views/elements/ProgressBar";
import AccessibleButton from "../views/elements/AccessibleButton"; import AccessibleButton from "../views/elements/AccessibleButton";
import { IUpload } from "../../models/IUpload"; import { IUpload } from "../../models/IUpload";
import { replaceableComponent } from "../../utils/replaceableComponent"; import { replaceableComponent } from "../../utils/replaceableComponent";
import MatrixClientContext from "../../contexts/MatrixClientContext";
interface IProps { interface IProps {
room: Room; room: Room;
@ -38,6 +39,8 @@ interface IState {
@replaceableComponent("structures.UploadBar") @replaceableComponent("structures.UploadBar")
export default class UploadBar extends React.Component<IProps, IState> { export default class UploadBar extends React.Component<IProps, IState> {
static contextType = MatrixClientContext;
private dispatcherRef: string; private dispatcherRef: string;
private mounted: boolean; private mounted: boolean;
@ -82,7 +85,7 @@ export default class UploadBar extends React.Component<IProps, IState> {
private onCancelClick = (ev) => { private onCancelClick = (ev) => {
ev.preventDefault(); ev.preventDefault();
ContentMessages.sharedInstance().cancelUpload(this.state.currentUpload.promise); ContentMessages.sharedInstance().cancelUpload(this.state.currentUpload.promise, this.context);
}; };
render() { render() {

View file

@ -15,39 +15,42 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import * as sdk from '../../../index'; import * as sdk from '../../../index';
import { SetupEncryptionStore, Phase } from '../../../stores/SetupEncryptionStore'; import { SetupEncryptionStore, Phase } from '../../../stores/SetupEncryptionStore';
import SetupEncryptionBody from "./SetupEncryptionBody"; import SetupEncryptionBody from "./SetupEncryptionBody";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
@replaceableComponent("structures.auth.CompleteSecurity") interface IProps {
export default class CompleteSecurity extends React.Component { onFinished: () => void;
static propTypes = { }
onFinished: PropTypes.func.isRequired,
};
constructor() { interface IState {
super(); phase: Phase;
}
@replaceableComponent("structures.auth.CompleteSecurity")
export default class CompleteSecurity extends React.Component<IProps, IState> {
constructor(props: IProps) {
super(props);
const store = SetupEncryptionStore.sharedInstance(); const store = SetupEncryptionStore.sharedInstance();
store.on("update", this._onStoreUpdate); store.on("update", this.onStoreUpdate);
store.start(); store.start();
this.state = { phase: store.phase }; this.state = { phase: store.phase };
} }
_onStoreUpdate = () => { private onStoreUpdate = (): void => {
const store = SetupEncryptionStore.sharedInstance(); const store = SetupEncryptionStore.sharedInstance();
this.setState({ phase: store.phase }); this.setState({ phase: store.phase });
}; };
componentWillUnmount() { public componentWillUnmount(): void {
const store = SetupEncryptionStore.sharedInstance(); const store = SetupEncryptionStore.sharedInstance();
store.off("update", this._onStoreUpdate); store.off("update", this.onStoreUpdate);
store.stop(); store.stop();
} }
render() { public render() {
const AuthPage = sdk.getComponent("auth.AuthPage"); const AuthPage = sdk.getComponent("auth.AuthPage");
const CompleteSecurityBody = sdk.getComponent("auth.CompleteSecurityBody"); const CompleteSecurityBody = sdk.getComponent("auth.CompleteSecurityBody");
const { phase } = this.state; const { phase } = this.state;

View file

@ -15,20 +15,19 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import AuthPage from '../../views/auth/AuthPage'; import AuthPage from '../../views/auth/AuthPage';
import CompleteSecurityBody from '../../views/auth/CompleteSecurityBody'; import CompleteSecurityBody from '../../views/auth/CompleteSecurityBody';
import CreateCrossSigningDialog from '../../views/dialogs/security/CreateCrossSigningDialog'; import CreateCrossSigningDialog from '../../views/dialogs/security/CreateCrossSigningDialog';
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
@replaceableComponent("structures.auth.E2eSetup") interface IProps {
export default class E2eSetup extends React.Component { onFinished: () => void;
static propTypes = { accountPassword?: string;
onFinished: PropTypes.func.isRequired, tokenLogin?: boolean;
accountPassword: PropTypes.string, }
tokenLogin: PropTypes.bool,
};
@replaceableComponent("structures.auth.E2eSetup")
export default class E2eSetup extends React.Component<IProps> {
render() { render() {
return ( return (
<AuthPage> <AuthPage>

View file

@ -17,7 +17,6 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import { _t, _td } from '../../../languageHandler'; import { _t, _td } from '../../../languageHandler';
import * as sdk from '../../../index'; import * as sdk from '../../../index';
import Modal from "../../../Modal"; import Modal from "../../../Modal";
@ -31,27 +30,50 @@ import PassphraseField from '../../views/auth/PassphraseField';
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import { PASSWORD_MIN_SCORE } from '../../views/auth/RegistrationForm'; import { PASSWORD_MIN_SCORE } from '../../views/auth/RegistrationForm';
// Phases import { IValidationResult } from "../../views/elements/Validation";
// Show the forgot password inputs
const PHASE_FORGOT = 1; enum Phase {
// Email is in the process of being sent // Show the forgot password inputs
const PHASE_SENDING_EMAIL = 2; Forgot = 1,
// Email has been sent // Email is in the process of being sent
const PHASE_EMAIL_SENT = 3; SendingEmail = 2,
// User has clicked the link in email and completed reset // Email has been sent
const PHASE_DONE = 4; EmailSent = 3,
// User has clicked the link in email and completed reset
Done = 4,
}
interface IProps {
serverConfig: ValidatedServerConfig;
onServerConfigChange: () => void;
onLoginClick?: () => void;
onComplete: () => void;
}
interface IState {
phase: Phase;
email: string;
password: string;
password2: string;
errorText: string;
// We perform liveliness checks later, but for now suppress the errors.
// We also track the server dead errors independently of the regular errors so
// that we can render it differently, and override any other error the user may
// be seeing.
serverIsAlive: boolean;
serverErrorIsFatal: boolean;
serverDeadError: string;
passwordFieldValid: boolean;
}
@replaceableComponent("structures.auth.ForgotPassword") @replaceableComponent("structures.auth.ForgotPassword")
export default class ForgotPassword extends React.Component { export default class ForgotPassword extends React.Component<IProps, IState> {
static propTypes = { private reset: PasswordReset;
serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired,
onServerConfigChange: PropTypes.func.isRequired,
onLoginClick: PropTypes.func,
onComplete: PropTypes.func.isRequired,
};
state = { state = {
phase: PHASE_FORGOT, phase: Phase.Forgot,
email: "", email: "",
password: "", password: "",
password2: "", password2: "",
@ -64,30 +86,31 @@ export default class ForgotPassword extends React.Component {
serverIsAlive: true, serverIsAlive: true,
serverErrorIsFatal: false, serverErrorIsFatal: false,
serverDeadError: "", serverDeadError: "",
passwordFieldValid: false,
}; };
constructor(props) { constructor(props: IProps) {
super(props); super(props);
CountlyAnalytics.instance.track("onboarding_forgot_password_begin"); CountlyAnalytics.instance.track("onboarding_forgot_password_begin");
} }
componentDidMount() { public componentDidMount() {
this.reset = null; this.reset = null;
this._checkServerLiveliness(this.props.serverConfig); this.checkServerLiveliness(this.props.serverConfig);
} }
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event // TODO: [REACT-WARNING] Replace with appropriate lifecycle event
// eslint-disable-next-line camelcase // eslint-disable-next-line camelcase
UNSAFE_componentWillReceiveProps(newProps) { public UNSAFE_componentWillReceiveProps(newProps: IProps): void {
if (newProps.serverConfig.hsUrl === this.props.serverConfig.hsUrl && if (newProps.serverConfig.hsUrl === this.props.serverConfig.hsUrl &&
newProps.serverConfig.isUrl === this.props.serverConfig.isUrl) return; newProps.serverConfig.isUrl === this.props.serverConfig.isUrl) return;
// Do a liveliness check on the new URLs // Do a liveliness check on the new URLs
this._checkServerLiveliness(newProps.serverConfig); this.checkServerLiveliness(newProps.serverConfig);
} }
async _checkServerLiveliness(serverConfig) { private async checkServerLiveliness(serverConfig): Promise<void> {
try { try {
await AutoDiscoveryUtils.validateServerConfigWithStaticUrls( await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(
serverConfig.hsUrl, serverConfig.hsUrl,
@ -98,28 +121,28 @@ export default class ForgotPassword extends React.Component {
serverIsAlive: true, serverIsAlive: true,
}); });
} catch (e) { } catch (e) {
this.setState(AutoDiscoveryUtils.authComponentStateForError(e, "forgot_password")); this.setState(AutoDiscoveryUtils.authComponentStateForError(e, "forgot_password") as IState);
} }
} }
submitPasswordReset(email, password) { public submitPasswordReset(email: string, password: string): void {
this.setState({ this.setState({
phase: PHASE_SENDING_EMAIL, phase: Phase.SendingEmail,
}); });
this.reset = new PasswordReset(this.props.serverConfig.hsUrl, this.props.serverConfig.isUrl); this.reset = new PasswordReset(this.props.serverConfig.hsUrl, this.props.serverConfig.isUrl);
this.reset.resetPassword(email, password).then(() => { this.reset.resetPassword(email, password).then(() => {
this.setState({ this.setState({
phase: PHASE_EMAIL_SENT, phase: Phase.EmailSent,
}); });
}, (err) => { }, (err) => {
this.showErrorDialog(_t('Failed to send email') + ": " + err.message); this.showErrorDialog(_t('Failed to send email') + ": " + err.message);
this.setState({ this.setState({
phase: PHASE_FORGOT, phase: Phase.Forgot,
}); });
}); });
} }
onVerify = async ev => { private onVerify = async (ev: React.MouseEvent): Promise<void> => {
ev.preventDefault(); ev.preventDefault();
if (!this.reset) { if (!this.reset) {
console.error("onVerify called before submitPasswordReset!"); console.error("onVerify called before submitPasswordReset!");
@ -127,17 +150,17 @@ export default class ForgotPassword extends React.Component {
} }
try { try {
await this.reset.checkEmailLinkClicked(); await this.reset.checkEmailLinkClicked();
this.setState({ phase: PHASE_DONE }); this.setState({ phase: Phase.Done });
} catch (err) { } catch (err) {
this.showErrorDialog(err.message); this.showErrorDialog(err.message);
} }
}; };
onSubmitForm = async ev => { private onSubmitForm = async (ev: React.FormEvent): Promise<void> => {
ev.preventDefault(); ev.preventDefault();
// refresh the server errors, just in case the server came back online // refresh the server errors, just in case the server came back online
await this._checkServerLiveliness(this.props.serverConfig); await this.checkServerLiveliness(this.props.serverConfig);
await this['password_field'].validate({ allowEmpty: false }); await this['password_field'].validate({ allowEmpty: false });
@ -172,27 +195,27 @@ export default class ForgotPassword extends React.Component {
} }
}; };
onInputChanged = (stateKey, ev) => { private onInputChanged = (stateKey: string, ev: React.FormEvent<HTMLInputElement>) => {
this.setState({ this.setState({
[stateKey]: ev.target.value, [stateKey]: ev.currentTarget.value,
}); } as any);
}; };
onLoginClick = ev => { private onLoginClick = (ev: React.MouseEvent): void => {
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
this.props.onLoginClick(); this.props.onLoginClick();
}; };
showErrorDialog(body, title) { public showErrorDialog(description: string, title?: string) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Forgot Password Error', '', ErrorDialog, { Modal.createTrackedDialog('Forgot Password Error', '', ErrorDialog, {
title: title, title,
description: body, description,
}); });
} }
onPasswordValidate(result) { private onPasswordValidate(result: IValidationResult) {
this.setState({ this.setState({
passwordFieldValid: result.valid, passwordFieldValid: result.valid,
}); });
@ -316,16 +339,16 @@ export default class ForgotPassword extends React.Component {
let resetPasswordJsx; let resetPasswordJsx;
switch (this.state.phase) { switch (this.state.phase) {
case PHASE_FORGOT: case Phase.Forgot:
resetPasswordJsx = this.renderForgot(); resetPasswordJsx = this.renderForgot();
break; break;
case PHASE_SENDING_EMAIL: case Phase.SendingEmail:
resetPasswordJsx = this.renderSendingEmail(); resetPasswordJsx = this.renderSendingEmail();
break; break;
case PHASE_EMAIL_SENT: case Phase.EmailSent:
resetPasswordJsx = this.renderEmailSent(); resetPasswordJsx = this.renderEmailSent();
break; break;
case PHASE_DONE: case Phase.Done:
resetPasswordJsx = this.renderDone(); resetPasswordJsx = this.renderDone();
break; break;
} }

View file

@ -49,7 +49,7 @@ interface IProps {
// for operations like uploading cross-signing keys). // for operations like uploading cross-signing keys).
onLoggedIn(params: { onLoggedIn(params: {
userId: string; userId: string;
deviceId: string deviceId: string;
homeserverUrl: string; homeserverUrl: string;
identityServerUrl?: string; identityServerUrl?: string;
accessToken: string; accessToken: string;

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2020 The Matrix.org Foundation C.I.C. Copyright 2020-2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); 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,33 +15,43 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import { MatrixClientPeg } from '../../../MatrixClientPeg'; import { MatrixClientPeg } from '../../../MatrixClientPeg';
import Modal from '../../../Modal'; import Modal from '../../../Modal';
import VerificationRequestDialog from '../../views/dialogs/VerificationRequestDialog'; import VerificationRequestDialog from '../../views/dialogs/VerificationRequestDialog';
import * as sdk from '../../../index';
import { SetupEncryptionStore, Phase } from '../../../stores/SetupEncryptionStore'; import { SetupEncryptionStore, Phase } from '../../../stores/SetupEncryptionStore';
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import { ISecretStorageKeyInfo } from 'matrix-js-sdk';
import EncryptionPanel from "../../views/right_panel/EncryptionPanel";
import AccessibleButton from '../../views/elements/AccessibleButton';
import Spinner from '../../views/elements/Spinner';
import { IKeyBackupInfo } from "matrix-js-sdk/src/crypto/keybackup";
import { VerificationRequest } from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest";
function keyHasPassphrase(keyInfo) { function keyHasPassphrase(keyInfo: ISecretStorageKeyInfo): boolean {
return ( return Boolean(
keyInfo.passphrase && keyInfo.passphrase &&
keyInfo.passphrase.salt && keyInfo.passphrase.salt &&
keyInfo.passphrase.iterations keyInfo.passphrase.iterations,
); );
} }
@replaceableComponent("structures.auth.SetupEncryptionBody") interface IProps {
export default class SetupEncryptionBody extends React.Component { onFinished: (boolean) => void;
static propTypes = { }
onFinished: PropTypes.func.isRequired,
};
constructor() { interface IState {
super(); phase: Phase;
verificationRequest: VerificationRequest;
backupInfo: IKeyBackupInfo;
}
@replaceableComponent("structures.auth.SetupEncryptionBody")
export default class SetupEncryptionBody extends React.Component<IProps, IState> {
constructor(props) {
super(props);
const store = SetupEncryptionStore.sharedInstance(); const store = SetupEncryptionStore.sharedInstance();
store.on("update", this._onStoreUpdate); store.on("update", this.onStoreUpdate);
store.start(); store.start();
this.state = { this.state = {
phase: store.phase, phase: store.phase,
@ -53,10 +63,10 @@ export default class SetupEncryptionBody extends React.Component {
}; };
} }
_onStoreUpdate = () => { private onStoreUpdate = () => {
const store = SetupEncryptionStore.sharedInstance(); const store = SetupEncryptionStore.sharedInstance();
if (store.phase === Phase.Finished) { if (store.phase === Phase.Finished) {
this.props.onFinished(); this.props.onFinished(true);
return; return;
} }
this.setState({ this.setState({
@ -66,18 +76,18 @@ export default class SetupEncryptionBody extends React.Component {
}); });
}; };
componentWillUnmount() { public componentWillUnmount() {
const store = SetupEncryptionStore.sharedInstance(); const store = SetupEncryptionStore.sharedInstance();
store.off("update", this._onStoreUpdate); store.off("update", this.onStoreUpdate);
store.stop(); store.stop();
} }
_onUsePassphraseClick = async () => { private onUsePassphraseClick = async () => {
const store = SetupEncryptionStore.sharedInstance(); const store = SetupEncryptionStore.sharedInstance();
store.usePassPhrase(); store.usePassPhrase();
} };
_onVerifyClick = () => { private onVerifyClick = () => {
const cli = MatrixClientPeg.get(); const cli = MatrixClientPeg.get();
const userId = cli.getUserId(); const userId = cli.getUserId();
const requestPromise = cli.requestVerification(userId); const requestPromise = cli.requestVerification(userId);
@ -91,42 +101,44 @@ export default class SetupEncryptionBody extends React.Component {
request.cancel(); request.cancel();
}, },
}); });
} };
onSkipClick = () => { private onSkipClick = () => {
const store = SetupEncryptionStore.sharedInstance(); const store = SetupEncryptionStore.sharedInstance();
store.skip(); store.skip();
} };
onSkipConfirmClick = () => { private onSkipConfirmClick = () => {
const store = SetupEncryptionStore.sharedInstance(); const store = SetupEncryptionStore.sharedInstance();
store.skipConfirm(); store.skipConfirm();
} };
onSkipBackClick = () => { private onSkipBackClick = () => {
const store = SetupEncryptionStore.sharedInstance(); const store = SetupEncryptionStore.sharedInstance();
store.returnAfterSkip(); store.returnAfterSkip();
} };
onDoneClick = () => { private onDoneClick = () => {
const store = SetupEncryptionStore.sharedInstance(); const store = SetupEncryptionStore.sharedInstance();
store.done(); store.done();
} };
render() { private onEncryptionPanelClose = () => {
const AccessibleButton = sdk.getComponent("elements.AccessibleButton"); this.props.onFinished(false);
};
public render() {
const { const {
phase, phase,
} = this.state; } = this.state;
if (this.state.verificationRequest) { if (this.state.verificationRequest) {
const EncryptionPanel = sdk.getComponent("views.right_panel.EncryptionPanel");
return <EncryptionPanel return <EncryptionPanel
layout="dialog" layout="dialog"
verificationRequest={this.state.verificationRequest} verificationRequest={this.state.verificationRequest}
onClose={this.props.onFinished} onClose={this.onEncryptionPanelClose}
member={MatrixClientPeg.get().getUser(this.state.verificationRequest.otherUserId)} member={MatrixClientPeg.get().getUser(this.state.verificationRequest.otherUserId)}
isRoomEncrypted={false}
/>; />;
} else if (phase === Phase.Intro) { } else if (phase === Phase.Intro) {
const store = SetupEncryptionStore.sharedInstance(); const store = SetupEncryptionStore.sharedInstance();
@ -139,14 +151,14 @@ export default class SetupEncryptionBody extends React.Component {
let useRecoveryKeyButton; let useRecoveryKeyButton;
if (recoveryKeyPrompt) { if (recoveryKeyPrompt) {
useRecoveryKeyButton = <AccessibleButton kind="link" onClick={this._onUsePassphraseClick}> useRecoveryKeyButton = <AccessibleButton kind="link" onClick={this.onUsePassphraseClick}>
{recoveryKeyPrompt} {recoveryKeyPrompt}
</AccessibleButton>; </AccessibleButton>;
} }
let verifyButton; let verifyButton;
if (store.hasDevicesToVerifyAgainst) { if (store.hasDevicesToVerifyAgainst) {
verifyButton = <AccessibleButton kind="primary" onClick={this._onVerifyClick}> verifyButton = <AccessibleButton kind="primary" onClick={this.onVerifyClick}>
{ _t("Use another login") } { _t("Use another login") }
</AccessibleButton>; </AccessibleButton>;
} }
@ -217,7 +229,6 @@ export default class SetupEncryptionBody extends React.Component {
</div> </div>
); );
} else if (phase === Phase.Busy || phase === Phase.Loading) { } else if (phase === Phase.Busy || phase === Phase.Loading) {
const Spinner = sdk.getComponent('views.elements.Spinner');
return <Spinner />; return <Spinner />;
} else { } else {
console.log(`SetupEncryptionBody: Unknown phase ${phase}`); console.log(`SetupEncryptionBody: Unknown phase ${phase}`);

View file

@ -49,7 +49,7 @@ interface IProps {
fragmentAfterLogin?: string; fragmentAfterLogin?: string;
// Called when the SSO login completes // Called when the SSO login completes
onTokenLoginCompleted: () => void, onTokenLoginCompleted: () => void;
} }
interface IState { interface IState {

View file

@ -0,0 +1,124 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { Playback, PlaybackState } from "../../../voice/Playback";
import React, { createRef, ReactNode, RefObject } from "react";
import { UPDATE_EVENT } from "../../../stores/AsyncStore";
import PlayPauseButton from "./PlayPauseButton";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { formatBytes } from "../../../utils/FormattingUtils";
import DurationClock from "./DurationClock";
import { Key } from "../../../Keyboard";
import { _t } from "../../../languageHandler";
import SeekBar from "./SeekBar";
import PlaybackClock from "./PlaybackClock";
interface IProps {
// Playback instance to render. Cannot change during component lifecycle: create
// an all-new component instead.
playback: Playback;
mediaName: string;
}
interface IState {
playbackPhase: PlaybackState;
}
@replaceableComponent("views.audio_messages.AudioPlayer")
export default class AudioPlayer extends React.PureComponent<IProps, IState> {
private playPauseRef: RefObject<PlayPauseButton> = createRef();
private seekRef: RefObject<SeekBar> = createRef();
constructor(props: IProps) {
super(props);
this.state = {
playbackPhase: PlaybackState.Decoding, // default assumption
};
// We don't need to de-register: the class handles this for us internally
this.props.playback.on(UPDATE_EVENT, this.onPlaybackUpdate);
// Don't wait for the promise to complete - it will emit a progress update when it
// is done, and it's not meant to take long anyhow.
// noinspection JSIgnoredPromiseFromCall
this.props.playback.prepare();
}
private onPlaybackUpdate = (ev: PlaybackState) => {
this.setState({ playbackPhase: ev });
};
private onKeyDown = (ev: React.KeyboardEvent) => {
// stopPropagation() prevents the FocusComposer catch-all from triggering,
// but we need to do it on key down instead of press (even though the user
// interaction is typically on press).
if (ev.key === Key.SPACE) {
ev.stopPropagation();
this.playPauseRef.current?.toggleState();
} else if (ev.key === Key.ARROW_LEFT) {
ev.stopPropagation();
this.seekRef.current?.left();
} else if (ev.key === Key.ARROW_RIGHT) {
ev.stopPropagation();
this.seekRef.current?.right();
}
};
protected renderFileSize(): string {
const bytes = this.props.playback.sizeBytes;
if (!bytes) return null;
// Not translated here - we're just presenting the data which should already
// be translated if needed.
return `(${formatBytes(bytes)})`;
}
public render(): ReactNode {
// tabIndex=0 to ensure that the whole component becomes a tab stop, where we handle keyboard
// events for accessibility
return <div className='mx_MediaBody mx_AudioPlayer_container' tabIndex={0} onKeyDown={this.onKeyDown}>
<div className='mx_AudioPlayer_primaryContainer'>
<PlayPauseButton
playback={this.props.playback}
playbackPhase={this.state.playbackPhase}
tabIndex={-1} // prevent tabbing into the button
ref={this.playPauseRef}
/>
<div className='mx_AudioPlayer_mediaInfo'>
<span className='mx_AudioPlayer_mediaName'>
{this.props.mediaName || _t("Unnamed audio")}
</span>
<div className='mx_AudioPlayer_byline'>
<DurationClock playback={this.props.playback} />
&nbsp; {/* easiest way to introduce a gap between the components */}
{ this.renderFileSize() }
</div>
</div>
</div>
<div className='mx_AudioPlayer_seek'>
<SeekBar
playback={this.props.playback}
tabIndex={-1} // prevent tabbing into the bar
playbackPhase={this.state.playbackPhase}
ref={this.seekRef}
/>
<PlaybackClock playback={this.props.playback} defaultDisplaySeconds={0} />
</div>
</div>;
}
}

View file

@ -28,7 +28,7 @@ interface IState {
* Simply converts seconds into minutes and seconds. Note that hours will not be * Simply converts seconds into minutes and seconds. Note that hours will not be
* displayed, making it possible to see "82:29". * displayed, making it possible to see "82:29".
*/ */
@replaceableComponent("views.voice_messages.Clock") @replaceableComponent("views.audio_messages.Clock")
export default class Clock extends React.Component<IProps, IState> { export default class Clock extends React.Component<IProps, IState> {
public constructor(props) { public constructor(props) {
super(props); super(props);

View file

@ -0,0 +1,55 @@
/*
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 from "react";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import Clock from "./Clock";
import { Playback } from "../../../voice/Playback";
interface IProps {
playback: Playback;
}
interface IState {
durationSeconds: number;
}
/**
* A clock which shows a clip's maximum duration.
*/
@replaceableComponent("views.audio_messages.DurationClock")
export default class DurationClock extends React.PureComponent<IProps, IState> {
public constructor(props) {
super(props);
this.state = {
// we track the duration on state because we won't really know what the clip duration
// is until the first time update, and as a PureComponent we are trying to dedupe state
// updates as much as possible. This is just the easiest way to avoid a forceUpdate() or
// member property to track "did we get a duration".
durationSeconds: this.props.playback.clockInfo.durationSeconds,
};
this.props.playback.clockInfo.liveData.onUpdate(this.onTimeUpdate);
}
private onTimeUpdate = (time: number[]) => {
this.setState({ durationSeconds: time[1] });
};
public render() {
return <Clock seconds={this.state.durationSeconds} />;
}
}

View file

@ -1,9 +1,12 @@
/* /*
Copyright 2021 The Matrix.org Foundation C.I.C. 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.
You may obtain a copy of the License at You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0 http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS, distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@ -12,16 +15,13 @@ limitations under the License.
*/ */
import React from "react"; import React from "react";
import Clock from "./Clock"; import { IRecordingUpdate, VoiceRecording } from "../../../voice/VoiceRecording";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import Clock from "./Clock";
import { MarkedExecution } from "../../../utils/MarkedExecution"; import { MarkedExecution } from "../../../utils/MarkedExecution";
import {
IRecordingUpdate,
VoiceRecording,
} from "../../../voice/VoiceRecording";
interface IProps { interface IProps {
recorder?: VoiceRecording; recorder: VoiceRecording;
} }
interface IState { interface IState {
@ -31,7 +31,7 @@ interface IState {
/** /**
* A clock for a live recording. * A clock for a live recording.
*/ */
@replaceableComponent("views.voice_messages.LiveRecordingClock") @replaceableComponent("views.audio_messages.LiveRecordingClock")
export default class LiveRecordingClock extends React.PureComponent<IProps, IState> { export default class LiveRecordingClock extends React.PureComponent<IProps, IState> {
private seconds = 0; private seconds = 0;
private scheduledUpdate = new MarkedExecution( private scheduledUpdate = new MarkedExecution(

View file

@ -1,9 +1,12 @@
/* /*
Copyright 2021 The Matrix.org Foundation C.I.C. 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.
You may obtain a copy of the License at You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0 http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS, distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@ -12,16 +15,15 @@ limitations under the License.
*/ */
import React from "react"; import React from "react";
import Waveform from "./Waveform"; import { IRecordingUpdate, RECORDING_PLAYBACK_SAMPLES, VoiceRecording } from "../../../voice/VoiceRecording";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import { arrayFastResample } from "../../../utils/arrays";
import { percentageOf } from "../../../utils/numbers";
import Waveform from "./Waveform";
import { MarkedExecution } from "../../../utils/MarkedExecution"; import { MarkedExecution } from "../../../utils/MarkedExecution";
import {
IRecordingUpdate,
VoiceRecording,
} from "../../../voice/VoiceRecording";
interface IProps { interface IProps {
recorder?: VoiceRecording; recorder: VoiceRecording;
} }
interface IState { interface IState {
@ -31,7 +33,7 @@ interface IState {
/** /**
* A waveform which shows the waveform of a live recording * A waveform which shows the waveform of a live recording
*/ */
@replaceableComponent("views.voice_messages.LiveRecordingWaveform") @replaceableComponent("views.audio_messages.LiveRecordingWaveform")
export default class LiveRecordingWaveform extends React.PureComponent<IProps, IState> { export default class LiveRecordingWaveform extends React.PureComponent<IProps, IState> {
public static defaultProps = { public static defaultProps = {
progress: 1, progress: 1,
@ -52,15 +54,18 @@ export default class LiveRecordingWaveform extends React.PureComponent<IProps, I
componentDidMount() { componentDidMount() {
this.props.recorder.liveData.onUpdate((update: IRecordingUpdate) => { this.props.recorder.liveData.onUpdate((update: IRecordingUpdate) => {
this.waveform = update.waveform; const bars = arrayFastResample(Array.from(update.waveform), RECORDING_PLAYBACK_SAMPLES);
// The incoming data is between zero and one, but typically even screaming into a
// microphone won't send you over 0.6, so we artificially adjust the gain for the
// waveform. This results in a slightly more cinematic/animated waveform for the
// user.
this.waveform = bars.map(b => percentageOf(b, 0, 0.50));
this.scheduledUpdate.mark(); this.scheduledUpdate.mark();
}); });
} }
private updateWaveform() { private updateWaveform() {
this.setState({ this.setState({ waveform: this.waveform });
waveform: this.waveform,
});
} }
public render() { public render() {

View file

@ -21,7 +21,8 @@ import { _t } from "../../../languageHandler";
import { Playback, PlaybackState } from "../../../voice/Playback"; import { Playback, PlaybackState } from "../../../voice/Playback";
import classNames from "classnames"; import classNames from "classnames";
interface IProps { // omitted props are handled by render function
interface IProps extends Omit<React.ComponentProps<typeof AccessibleTooltipButton>, "title" | "onClick" | "disabled"> {
// Playback instance to manipulate. Cannot change during the component lifecycle. // Playback instance to manipulate. Cannot change during the component lifecycle.
playback: Playback; playback: Playback;
@ -33,19 +34,25 @@ interface IProps {
* Displays a play/pause button (activating the play/pause function of the recorder) * Displays a play/pause button (activating the play/pause function of the recorder)
* to be displayed in reference to a recording. * to be displayed in reference to a recording.
*/ */
@replaceableComponent("views.voice_messages.PlayPauseButton") @replaceableComponent("views.audio_messages.PlayPauseButton")
export default class PlayPauseButton extends React.PureComponent<IProps> { export default class PlayPauseButton extends React.PureComponent<IProps> {
public constructor(props) { public constructor(props) {
super(props); super(props);
} }
private onClick = async () => { private onClick = () => {
await this.props.playback.toggle(); // noinspection JSIgnoredPromiseFromCall
this.toggleState();
}; };
public async toggleState() {
await this.props.playback.toggle();
}
public render(): ReactNode { public render(): ReactNode {
const isPlaying = this.props.playback.isPlaying; const { playback, playbackPhase, ...restProps } = this.props;
const isDisabled = this.props.playbackPhase === PlaybackState.Decoding; const isPlaying = playback.isPlaying;
const isDisabled = playbackPhase === PlaybackState.Decoding;
const classes = classNames('mx_PlayPauseButton', { const classes = classNames('mx_PlayPauseButton', {
'mx_PlayPauseButton_play': !isPlaying, 'mx_PlayPauseButton_play': !isPlaying,
'mx_PlayPauseButton_pause': isPlaying, 'mx_PlayPauseButton_pause': isPlaying,
@ -56,6 +63,7 @@ export default class PlayPauseButton extends React.PureComponent<IProps> {
title={isPlaying ? _t("Pause") : _t("Play")} title={isPlaying ? _t("Pause") : _t("Play")}
onClick={this.onClick} onClick={this.onClick}
disabled={isDisabled} disabled={isDisabled}
{...restProps}
/>; />;
} }
} }

View file

@ -22,6 +22,11 @@ import { UPDATE_EVENT } from "../../../stores/AsyncStore";
interface IProps { interface IProps {
playback: Playback; playback: Playback;
// The default number of seconds to show when the playback has completed or
// has not started. Not used during playback, even when paused. Defaults to
// clip duration length.
defaultDisplaySeconds?: number;
} }
interface IState { interface IState {
@ -33,7 +38,7 @@ interface IState {
/** /**
* A clock for a playback of a recording. * A clock for a playback of a recording.
*/ */
@replaceableComponent("views.voice_messages.PlaybackClock") @replaceableComponent("views.audio_messages.PlaybackClock")
export default class PlaybackClock extends React.PureComponent<IProps, IState> { export default class PlaybackClock extends React.PureComponent<IProps, IState> {
public constructor(props) { public constructor(props) {
super(props); super(props);
@ -64,8 +69,12 @@ export default class PlaybackClock extends React.PureComponent<IProps, IState> {
public render() { public render() {
let seconds = this.state.seconds; let seconds = this.state.seconds;
if (this.state.playbackPhase === PlaybackState.Stopped) { if (this.state.playbackPhase === PlaybackState.Stopped) {
if (Number.isFinite(this.props.defaultDisplaySeconds)) {
seconds = this.props.defaultDisplaySeconds;
} else {
seconds = this.state.durationSeconds; seconds = this.state.durationSeconds;
} }
}
return <Clock seconds={seconds} />; return <Clock seconds={seconds} />;
} }
} }

View file

@ -33,7 +33,7 @@ interface IState {
/** /**
* A waveform which shows the waveform of a previously recorded recording * A waveform which shows the waveform of a previously recorded recording
*/ */
@replaceableComponent("views.voice_messages.PlaybackWaveform") @replaceableComponent("views.audio_messages.PlaybackWaveform")
export default class PlaybackWaveform extends React.PureComponent<IProps, IState> { export default class PlaybackWaveform extends React.PureComponent<IProps, IState> {
public constructor(props) { public constructor(props) {
super(props); super(props);

View file

@ -20,6 +20,7 @@ import { UPDATE_EVENT } from "../../../stores/AsyncStore";
import PlaybackWaveform from "./PlaybackWaveform"; 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";
interface IProps { interface IProps {
// Playback instance to render. Cannot change during component lifecycle: create // Playback instance to render. Cannot change during component lifecycle: create
@ -31,6 +32,7 @@ interface IState {
playbackPhase: PlaybackState; playbackPhase: PlaybackState;
} }
@replaceableComponent("views.audio_messages.RecordingPlayback")
export default class RecordingPlayback extends React.PureComponent<IProps, IState> { export default class RecordingPlayback extends React.PureComponent<IProps, IState> {
constructor(props: IProps) { constructor(props: IProps) {
super(props); super(props);
@ -53,7 +55,7 @@ export default class RecordingPlayback extends React.PureComponent<IProps, IStat
}; };
public render(): ReactNode { public render(): ReactNode {
return <div className='mx_VoiceMessagePrimaryContainer'> return <div className='mx_MediaBody mx_VoiceMessagePrimaryContainer'>
<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} /> <PlaybackWaveform playback={this.props.playback} />

View file

@ -0,0 +1,112 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { Playback, PlaybackState } from "../../../voice/Playback";
import React, { ChangeEvent, CSSProperties, ReactNode } from "react";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { MarkedExecution } from "../../../utils/MarkedExecution";
import { percentageOf } from "../../../utils/numbers";
interface IProps {
// Playback instance to render. Cannot change during component lifecycle: create
// an all-new component instead.
playback: Playback;
// Tab index for the underlying component. Useful if the seek bar is in a managed state.
// Defaults to zero.
tabIndex?: number;
playbackPhase: PlaybackState;
}
interface IState {
percentage: number;
}
interface ISeekCSS extends CSSProperties {
'--fillTo': number;
}
const ARROW_SKIP_SECONDS = 5; // arbitrary
@replaceableComponent("views.audio_messages.SeekBar")
export default class SeekBar extends React.PureComponent<IProps, IState> {
// We use an animation frame request to avoid overly spamming prop updates, even if we aren't
// really using anything demanding on the CSS front.
private animationFrameFn = new MarkedExecution(
() => this.doUpdate(),
() => requestAnimationFrame(() => this.animationFrameFn.trigger()));
public static defaultProps = {
tabIndex: 0,
};
constructor(props: IProps) {
super(props);
this.state = {
percentage: 0,
};
// We don't need to de-register: the class handles this for us internally
this.props.playback.clockInfo.liveData.onUpdate(() => this.animationFrameFn.mark());
}
private doUpdate() {
this.setState({
percentage: percentageOf(
this.props.playback.clockInfo.timeSeconds,
0,
this.props.playback.clockInfo.durationSeconds),
});
}
public left() {
// noinspection JSIgnoredPromiseFromCall
this.props.playback.skipTo(this.props.playback.clockInfo.timeSeconds - ARROW_SKIP_SECONDS);
}
public right() {
// noinspection JSIgnoredPromiseFromCall
this.props.playback.skipTo(this.props.playback.clockInfo.timeSeconds + ARROW_SKIP_SECONDS);
}
private onChange = (ev: ChangeEvent<HTMLInputElement>) => {
// Thankfully, onChange is only called when the user changes the value, not when we
// change the value on the component. We can use this as a reliable "skip to X" function.
//
// noinspection JSIgnoredPromiseFromCall
this.props.playback.skipTo(Number(ev.target.value) * this.props.playback.clockInfo.durationSeconds);
};
public render(): ReactNode {
// We use a range input to avoid having to re-invent accessibility handling on
// a custom set of divs.
return <input
type="range"
className='mx_SeekBar'
tabIndex={this.props.tabIndex}
onChange={this.onChange}
min={0}
max={1}
value={this.state.percentage}
step={0.001}
style={{ '--fillTo': this.state.percentage } as ISeekCSS}
disabled={this.props.playbackPhase === PlaybackState.Decoding}
/>;
}
}

View file

@ -17,8 +17,13 @@ limitations under the License.
import React from "react"; import React from "react";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import classNames from "classnames"; import classNames from "classnames";
import { CSSProperties } from "react";
export interface IProps { interface WaveformCSSProperties extends CSSProperties {
'--barHeight': number;
}
interface IProps {
relHeights: number[]; // relative heights (0-1) relHeights: number[]; // relative heights (0-1)
progress: number; // percent complete, 0-1, default 100% progress: number; // percent complete, 0-1, default 100%
} }
@ -34,14 +39,7 @@ interface IState {
* For CSS purposes, a mx_Waveform_bar_100pct class is added when the bar should be * For CSS purposes, a mx_Waveform_bar_100pct class is added when the bar should be
* "filled", as a demonstration of the progress property. * "filled", as a demonstration of the progress property.
*/ */
@replaceableComponent("views.audio_messages.Waveform")
import { CSSProperties } from "react";
export interface WaveformCSSProperties extends CSSProperties {
'--barHeight': number;
}
@replaceableComponent("views.voice_messages.Waveform")
export default class Waveform extends React.PureComponent<IProps, IState> { export default class Waveform extends React.PureComponent<IProps, IState> {
public static defaultProps = { public static defaultProps = {
progress: 1, progress: 1,

View file

@ -52,8 +52,8 @@ interface IProps {
interface IState { interface IState {
fieldValid: Partial<Record<LoginField, boolean>>; fieldValid: Partial<Record<LoginField, boolean>>;
loginType: LoginField.Email | LoginField.MatrixId | LoginField.Phone, loginType: LoginField.Email | LoginField.MatrixId | LoginField.Phone;
password: "", password: "";
} }
enum LoginField { enum LoginField {

View file

@ -30,13 +30,14 @@ import { _t } from "../../../languageHandler";
import TextWithTooltip from "../elements/TextWithTooltip"; import TextWithTooltip from "../elements/TextWithTooltip";
import DMRoomMap from "../../../utils/DMRoomMap"; import DMRoomMap from "../../../utils/DMRoomMap";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import { IOOBData } from "../../../stores/ThreepidInviteStore";
interface IProps { interface IProps {
room: Room; room: Room;
avatarSize: number; avatarSize: number;
displayBadge?: boolean; displayBadge?: boolean;
forceCount?: boolean; forceCount?: boolean;
oobData?: object; oobData?: IOOBData;
viewAvatarOnClick?: boolean; viewAvatarOnClick?: boolean;
} }

View file

@ -24,14 +24,14 @@ import Modal from '../../../Modal';
import * as Avatar from '../../../Avatar'; import * as Avatar from '../../../Avatar';
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';
interface IProps extends Omit<ComponentProps<typeof BaseAvatar>, "name" | "idName" | "url" | "onClick"> { interface IProps extends Omit<ComponentProps<typeof BaseAvatar>, "name" | "idName" | "url" | "onClick"> {
// Room may be left unset here, but if it is, // Room may be left unset here, but if it is,
// oobData.avatarUrl should be set (else there // oobData.avatarUrl should be set (else there
// would be nowhere to get the avatar from) // would be nowhere to get the avatar from)
room?: Room; room?: Room;
// TODO: type when js-sdk has types oobData?: IOOBData;
oobData?: any;
width?: number; width?: number;
height?: number; height?: number;
resizeMethod?: ResizeMethod; resizeMethod?: ResizeMethod;

View file

@ -18,6 +18,7 @@ import React, { ReactNode, useContext, useMemo, useState } from "react";
import classNames from "classnames"; import classNames from "classnames";
import { Room } from "matrix-js-sdk/src/models/room"; import { Room } from "matrix-js-sdk/src/models/room";
import { MatrixClient } from "matrix-js-sdk/src/client"; import { MatrixClient } from "matrix-js-sdk/src/client";
import { sleep } from "matrix-js-sdk/src/utils";
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import { IDialogProps } from "./IDialogProps"; import { IDialogProps } from "./IDialogProps";
@ -29,7 +30,6 @@ import RoomAvatar from "../avatars/RoomAvatar";
import { getDisplayAliasForRoom } from "../../../Rooms"; import { getDisplayAliasForRoom } from "../../../Rooms";
import AccessibleButton from "../elements/AccessibleButton"; import AccessibleButton from "../elements/AccessibleButton";
import AutoHideScrollbar from "../../structures/AutoHideScrollbar"; import AutoHideScrollbar from "../../structures/AutoHideScrollbar";
import { sleep } from "../../../utils/promise";
import DMRoomMap from "../../../utils/DMRoomMap"; import DMRoomMap from "../../../utils/DMRoomMap";
import { calculateRoomVia } from "../../../utils/permalinks/Permalinks"; import { calculateRoomVia } from "../../../utils/permalinks/Permalinks";
import StyledCheckbox from "../elements/StyledCheckbox"; import StyledCheckbox from "../elements/StyledCheckbox";

View file

@ -19,6 +19,7 @@ limitations under the License.
import React, { createRef } from 'react'; import React, { createRef } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { sleep } from "matrix-js-sdk/src/utils";
import { _t, _td } from '../../../languageHandler'; import { _t, _td } from '../../../languageHandler';
import * as sdk from '../../../index'; import * as sdk from '../../../index';
@ -30,7 +31,6 @@ import * as Email from '../../../email';
import IdentityAuthClient from '../../../IdentityAuthClient'; import IdentityAuthClient from '../../../IdentityAuthClient';
import { getDefaultIdentityServerUrl, useDefaultIdentityServer } from '../../../utils/IdentityServerUtils'; import { getDefaultIdentityServerUrl, useDefaultIdentityServer } from '../../../utils/IdentityServerUtils';
import { abbreviateUrl } from '../../../utils/UrlUtils'; import { abbreviateUrl } from '../../../utils/UrlUtils';
import { sleep } from "../../../utils/promise";
import { Key } from "../../../Keyboard"; import { Key } from "../../../Keyboard";
import { Action } from "../../../dispatcher/actions"; import { Action } from "../../../dispatcher/actions";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";

View file

@ -29,7 +29,7 @@ interface IProps {
// group member object. Supply either this or 'member' // group member object. Supply either this or 'member'
groupMember: GroupMemberType; groupMember: GroupMemberType;
// needed if a group member is specified // needed if a group member is specified
matrixClient?: MatrixClient, matrixClient?: MatrixClient;
action: string; // eg. 'Ban' action: string; // eg. 'Ban'
title: string; // eg. 'Ban this user?' title: string; // eg. 'Ban this user?'

View file

@ -70,9 +70,9 @@ import GenericTextContextMenu from "../context_menus/GenericTextContextMenu";
/* eslint-disable camelcase */ /* eslint-disable camelcase */
interface IRecentUser { interface IRecentUser {
userId: string, userId: string;
user: RoomMember, user: RoomMember;
lastActive: number, lastActive: number;
} }
export const KIND_DM = "dm"; export const KIND_DM = "dm";
@ -330,16 +330,16 @@ interface IInviteDialogProps {
// The kind of invite being performed. Assumed to be KIND_DM if // The kind of invite being performed. Assumed to be KIND_DM if
// not provided. // not provided.
kind: string, kind: string;
// The room ID this dialog is for. Only required for KIND_INVITE. // The room ID this dialog is for. Only required for KIND_INVITE.
roomId: string, roomId: string;
// The call to transfer. Only required for KIND_CALL_TRANSFER. // The call to transfer. Only required for KIND_CALL_TRANSFER.
call: MatrixCall, call: MatrixCall;
// Initial value to populate the filter with // Initial value to populate the filter with
initialText: string, initialText: string;
} }
interface IInviteDialogState { interface IInviteDialogState {
@ -356,8 +356,8 @@ interface IInviteDialogState {
consultFirst: boolean; consultFirst: boolean;
// 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;
errorText: string, errorText: string;
} }
@replaceableComponent("views.dialogs.InviteDialog") @replaceableComponent("views.dialogs.InviteDialog")

View file

@ -1,121 +0,0 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler';
import Modal from '../../../Modal';
import { replaceableComponent } from '../../../utils/replaceableComponent';
import VerificationRequestDialog from './VerificationRequestDialog';
import BaseDialog from './BaseDialog';
import DialogButtons from '../elements/DialogButtons';
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import * as sdk from '../../../index';
@replaceableComponent("views.dialogs.NewSessionReviewDialog")
export default class NewSessionReviewDialog extends React.PureComponent {
static propTypes = {
userId: PropTypes.string.isRequired,
device: PropTypes.object.isRequired,
onFinished: PropTypes.func.isRequired,
}
onCancelClick = () => {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog("Verification failed", "insecure", ErrorDialog, {
headerImage: require("../../../../res/img/e2e/warning.svg"),
title: _t("Your account is not secure"),
description: <div>
{_t("One of the following may be compromised:")}
<ul>
<li>{_t("Your password")}</li>
<li>{_t("Your homeserver")}</li>
<li>{_t("This session, or the other session")}</li>
<li>{_t("The internet connection either session is using")}</li>
</ul>
<div>
{_t("We recommend you change your password and Security Key in Settings immediately")}
</div>
</div>,
onFinished: () => this.props.onFinished(false),
});
}
onContinueClick = () => {
const { userId, device } = this.props;
const cli = MatrixClientPeg.get();
const requestPromise = cli.requestVerification(
userId,
[device.deviceId],
);
this.props.onFinished(true);
Modal.createTrackedDialog('New Session Verification', 'Starting dialog', VerificationRequestDialog, {
verificationRequestPromise: requestPromise,
member: cli.getUser(userId),
onFinished: async () => {
const request = await requestPromise;
request.cancel();
},
});
}
render() {
const { device } = this.props;
const icon = <span className="mx_NewSessionReviewDialog_headerIcon mx_E2EIcon_warning"></span>;
const titleText = _t("New session");
const title = <h2 className="mx_NewSessionReviewDialog_header">
{icon}
{titleText}
</h2>;
return (
<BaseDialog
title={title}
onFinished={this.props.onFinished}
>
<div className="mx_NewSessionReviewDialog_body">
<p>{_t(
"Use this session to verify your new one, " +
"granting it access to encrypted messages:",
)}</p>
<div className="mx_NewSessionReviewDialog_deviceInfo">
<div>
<span className="mx_NewSessionReviewDialog_deviceName">
{device.getDisplayName()}
</span> <span className="mx_NewSessionReviewDialog_deviceID">
({device.deviceId})
</span>
</div>
</div>
<p>{_t(
"If you didnt sign in to this session, " +
"your account may be compromised.",
)}</p>
<DialogButtons
cancelButton={_t("This wasn't me")}
cancelButtonClass="danger"
primaryButton={_t("Continue")}
onCancel={this.onCancelClick}
onPrimaryButtonClick={this.onContinueClick}
/>
</div>
</BaseDialog>
);
}
}

View file

@ -46,19 +46,19 @@ interface ITermsDialogProps {
* Array of [Service, policies] pairs, where policies is the response from the * Array of [Service, policies] pairs, where policies is the response from the
* /terms endpoint for that service * /terms endpoint for that service
*/ */
policiesAndServicePairs: any[], policiesAndServicePairs: any[];
/** /**
* urls that the user has already agreed to * urls that the user has already agreed to
*/ */
agreedUrls?: string[], agreedUrls?: string[];
/** /**
* Called with: * Called with:
* * success {bool} True if the user accepted any douments, false if cancelled * * success {bool} True if the user accepted any douments, false if cancelled
* * agreedUrls {string[]} List of agreed URLs * * agreedUrls {string[]} List of agreed URLs
*/ */
onFinished: (success: boolean, agreedUrls?: string[]) => void, onFinished: (success: boolean, agreedUrls?: string[]) => void;
} }
interface IState { interface IState {

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2020 The Matrix.org Foundation C.I.C. Copyright 2020-2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); 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,27 +15,33 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import { MatrixClientPeg } from '../../../MatrixClientPeg'; import { MatrixClientPeg } from '../../../MatrixClientPeg';
import * as sdk from '../../../index';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import { VerificationRequest } from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest";
import BaseDialog from "./BaseDialog";
import EncryptionPanel from "../right_panel/EncryptionPanel";
import { User } from 'matrix-js-sdk';
interface IProps {
verificationRequest: VerificationRequest;
verificationRequestPromise: Promise<VerificationRequest>;
onFinished: () => void;
member: User;
}
interface IState {
verificationRequest: VerificationRequest;
}
@replaceableComponent("views.dialogs.VerificationRequestDialog") @replaceableComponent("views.dialogs.VerificationRequestDialog")
export default class VerificationRequestDialog extends React.Component { export default class VerificationRequestDialog extends React.Component<IProps, IState> {
static propTypes = { constructor(props) {
verificationRequest: PropTypes.object, super(props);
verificationRequestPromise: PropTypes.object, this.state = {
onFinished: PropTypes.func.isRequired, verificationRequest: this.props.verificationRequest,
member: PropTypes.string,
}; };
if (this.props.verificationRequestPromise) {
constructor(...args) {
super(...args);
this.state = {};
if (this.props.verificationRequest) {
this.state.verificationRequest = this.props.verificationRequest;
} else if (this.props.verificationRequestPromise) {
this.props.verificationRequestPromise.then(r => { this.props.verificationRequestPromise.then(r => {
this.setState({ verificationRequest: r }); this.setState({ verificationRequest: r });
}); });
@ -43,8 +49,6 @@ export default class VerificationRequestDialog extends React.Component {
} }
render() { render() {
const BaseDialog = sdk.getComponent("views.dialogs.BaseDialog");
const EncryptionPanel = sdk.getComponent("views.right_panel.EncryptionPanel");
const request = this.state.verificationRequest; const request = this.state.verificationRequest;
const otherUserId = request && request.otherUserId; const otherUserId = request && request.otherUserId;
const member = this.props.member || const member = this.props.member ||
@ -65,6 +69,7 @@ export default class VerificationRequestDialog extends React.Component {
verificationRequestPromise={this.props.verificationRequestPromise} verificationRequestPromise={this.props.verificationRequestPromise}
onClose={this.props.onFinished} onClose={this.props.onFinished}
member={member} member={member}
isRoomEncrypted={false}
/> />
</BaseDialog>; </BaseDialog>;
} }

View file

@ -0,0 +1,56 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import { decode } from "blurhash";
interface IProps {
blurhash: string;
width: number;
height: number;
}
export default class BlurhashPlaceholder extends React.PureComponent<IProps> {
private canvas: React.RefObject<HTMLCanvasElement> = React.createRef();
public componentDidMount() {
this.draw();
}
public componentDidUpdate() {
this.draw();
}
private draw() {
if (!this.canvas.current) return;
try {
const { width, height } = this.props;
const pixels = decode(this.props.blurhash, Math.ceil(width), Math.ceil(height));
const ctx = this.canvas.current.getContext("2d");
const imgData = ctx.createImageData(width, height);
imgData.data.set(pixels);
ctx.putImageData(imgData, 0, 0);
} catch (e) {
console.error("Error rendering blurhash: ", e);
}
}
public render() {
return <canvas height={this.props.height} width={this.props.width} ref={this.canvas} />;
}
}

View file

@ -29,7 +29,7 @@ interface IProps {
// The minimum number of events needed to trigger summarisation // The minimum number of events needed to trigger summarisation
threshold?: number; threshold?: number;
// Whether or not to begin with state.expanded=true // Whether or not to begin with state.expanded=true
startExpanded?: boolean, startExpanded?: boolean;
// The list of room members for which to show avatars next to the summary // The list of room members for which to show avatars next to the summary
summaryMembers?: RoomMember[]; summaryMembers?: RoomMember[];
// The text to show as the summary of this event list // The text to show as the summary of this event list

View file

@ -24,7 +24,7 @@ import FocusLock from "react-focus-lock";
import MemberAvatar from "../avatars/MemberAvatar"; import MemberAvatar from "../avatars/MemberAvatar";
import { ContextMenuTooltipButton } from "../../../accessibility/context_menu/ContextMenuTooltipButton"; import { ContextMenuTooltipButton } from "../../../accessibility/context_menu/ContextMenuTooltipButton";
import MessageContextMenu from "../context_menus/MessageContextMenu"; import MessageContextMenu from "../context_menus/MessageContextMenu";
import { aboveLeftOf, ContextMenu } from '../../structures/ContextMenu'; import { aboveLeftOf } from '../../structures/ContextMenu';
import MessageTimestamp from "../messages/MessageTimestamp"; import MessageTimestamp from "../messages/MessageTimestamp";
import SettingsStore from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
import { formatFullDate } from "../../../DateUtils"; import { formatFullDate } from "../../../DateUtils";
@ -44,31 +44,31 @@ const ZOOM_COEFFICIENT = 0.0025;
const ZOOM_DISTANCE = 10; const ZOOM_DISTANCE = 10;
interface IProps { interface IProps {
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 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
// properties above, which let us use lightboxes to display images which aren't associated // properties above, which let us use lightboxes to display images which aren't associated
// with events. // with events.
mxEvent: MatrixEvent, mxEvent: MatrixEvent;
permalinkCreator: RoomPermalinkCreator, permalinkCreator: RoomPermalinkCreator;
} }
interface IState { interface IState {
zoom: number, zoom: number;
minZoom: number, minZoom: number;
maxZoom: number, maxZoom: number;
rotation: number, rotation: number;
translationX: number, translationX: number;
translationY: number, translationY: number;
moving: boolean, moving: boolean;
contextMenuDisplayed: boolean, contextMenuDisplayed: boolean;
} }
@replaceableComponent("views.elements.ImageView") @replaceableComponent("views.elements.ImageView")
@ -122,7 +122,7 @@ export default class ImageView extends React.Component<IProps, IState> {
const image = this.image.current; const image = this.image.current;
const imageWrapper = this.imageWrapper.current; const imageWrapper = this.imageWrapper.current;
const rotation = inputRotation || this.state.rotation; const rotation = inputRotation ?? this.state.rotation;
const imageIsNotFlipped = rotation % 180 === 0; const imageIsNotFlipped = rotation % 180 === 0;
@ -304,17 +304,13 @@ export default class ImageView extends React.Component<IProps, IState> {
let contextMenu = null; let contextMenu = null;
if (this.state.contextMenuDisplayed) { if (this.state.contextMenuDisplayed) {
contextMenu = ( contextMenu = (
<ContextMenu
{...aboveLeftOf(this.contextMenuButton.current.getBoundingClientRect())}
onFinished={this.onCloseContextMenu}
>
<MessageContextMenu <MessageContextMenu
{...aboveLeftOf(this.contextMenuButton.current.getBoundingClientRect())}
mxEvent={this.props.mxEvent} mxEvent={this.props.mxEvent}
permalinkCreator={this.props.permalinkCreator} permalinkCreator={this.props.permalinkCreator}
onFinished={this.onCloseContextMenu} onFinished={this.onCloseContextMenu}
onCloseDialog={this.props.onFinished} onCloseDialog={this.props.onFinished}
/> />
</ContextMenu>
); );
} }

View file

@ -30,14 +30,14 @@ function languageMatchesSearchQuery(query, language) {
} }
interface SpellCheckLanguagesDropdownIProps { interface SpellCheckLanguagesDropdownIProps {
className: string, className: string;
value: string, value: string;
onOptionChange(language: string), onOptionChange(language: string);
} }
interface SpellCheckLanguagesDropdownIState { interface SpellCheckLanguagesDropdownIState {
searchQuery: string, searchQuery: string;
languages: any, languages: any;
} }
@replaceableComponent("views.elements.SpellCheckLanguagesDropdown") @replaceableComponent("views.elements.SpellCheckLanguagesDropdown")

View file

@ -25,7 +25,7 @@ import { replaceableComponent } from "../../../utils/replaceableComponent";
interface IProps { interface IProps {
categories: ICategory[]; categories: ICategory[];
onAnchorClick(id: CategoryKey): void onAnchorClick(id: CategoryKey): void;
} }
@replaceableComponent("views.emojipicker.Header") @replaceableComponent("views.emojipicker.Header")

View file

@ -1,112 +0,0 @@
/*
Copyright 2016 OpenMarket Ltd
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 MFileBody from './MFileBody';
import { decryptFile } from '../../../utils/DecryptFile';
import { _t } from '../../../languageHandler';
import InlineSpinner from '../elements/InlineSpinner';
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { mediaFromContent } from "../../../customisations/Media";
@replaceableComponent("views.messages.MAudioBody")
export default class MAudioBody extends React.Component {
constructor(props) {
super(props);
this.state = {
playing: false,
decryptedUrl: null,
decryptedBlob: null,
error: null,
};
}
onPlayToggle() {
this.setState({
playing: !this.state.playing,
});
}
_getContentUrl() {
const media = mediaFromContent(this.props.mxEvent.getContent());
if (media.isEncrypted) {
return this.state.decryptedUrl;
} else {
return media.srcHttp;
}
}
componentDidMount() {
const content = this.props.mxEvent.getContent();
if (content.file !== undefined && this.state.decryptedUrl === null) {
let decryptedBlob;
decryptFile(content.file).then(function(blob) {
decryptedBlob = blob;
return URL.createObjectURL(decryptedBlob);
}).then((url) => {
this.setState({
decryptedUrl: url,
decryptedBlob: decryptedBlob,
});
}, (err) => {
console.warn("Unable to decrypt attachment: ", err);
this.setState({
error: err,
});
});
}
}
componentWillUnmount() {
if (this.state.decryptedUrl) {
URL.revokeObjectURL(this.state.decryptedUrl);
}
}
render() {
const content = this.props.mxEvent.getContent();
if (this.state.error !== null) {
return (
<span className="mx_MAudioBody">
<img src={require("../../../../res/img/warning.svg")} width="16" height="16" />
{ _t("Error decrypting audio") }
</span>
);
}
if (content.file !== undefined && this.state.decryptedUrl === null) {
// Need to decrypt the attachment
// The attachment is decrypted in componentDidMount.
// For now add an img tag with a 16x16 spinner.
// Not sure how tall the audio player is so not sure how tall it should actually be.
return (
<span className="mx_MAudioBody">
<InlineSpinner />
</span>
);
}
const contentUrl = this._getContentUrl();
return (
<span className="mx_MAudioBody">
<audio src={contentUrl} controls />
<MFileBody {...this.props} decryptedBlob={this.state.decryptedBlob} showGenericPlaceholder={false} />
</span>
);
}
}

View file

@ -0,0 +1,110 @@
/*
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 from "react";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { Playback } from "../../../voice/Playback";
import MFileBody from "./MFileBody";
import InlineSpinner from '../elements/InlineSpinner';
import { _t } from "../../../languageHandler";
import { mediaFromContent } from "../../../customisations/Media";
import { decryptFile } from "../../../utils/DecryptFile";
import { IMediaEventContent } from "../../../customisations/models/IMediaEventContent";
import AudioPlayer from "../audio_messages/AudioPlayer";
interface IProps {
mxEvent: MatrixEvent;
}
interface IState {
error?: Error;
playback?: Playback;
decryptedBlob?: Blob;
}
@replaceableComponent("views.messages.MAudioBody")
export default class MAudioBody extends React.PureComponent<IProps, IState> {
constructor(props: IProps) {
super(props);
this.state = {};
}
public async componentDidMount() {
let buffer: ArrayBuffer;
const content: IMediaEventContent = this.props.mxEvent.getContent();
const media = mediaFromContent(content);
if (media.isEncrypted) {
try {
const blob = await decryptFile(content.file);
buffer = await blob.arrayBuffer();
this.setState({ decryptedBlob: blob });
} catch (e) {
this.setState({ error: e });
console.warn("Unable to decrypt audio message", e);
return; // stop processing the audio file
}
} else {
try {
buffer = await media.downloadSource().then(r => r.blob()).then(r => r.arrayBuffer());
} catch (e) {
this.setState({ error: e });
console.warn("Unable to download audio message", e);
return; // stop processing the audio file
}
}
// We should have a buffer to work with now: let's set it up
const playback = new Playback(buffer);
playback.clockInfo.populatePlaceholdersFrom(this.props.mxEvent);
this.setState({ playback });
// Note: the RecordingPlayback component will handle preparing the Playback class for us.
}
public componentWillUnmount() {
this.state.playback?.destroy();
}
public render() {
if (this.state.error) {
// TODO: @@TR: Verify error state
return (
<span className="mx_MAudioBody">
<img src={require("../../../../res/img/warning.svg")} width="16" height="16" />
{ _t("Error processing audio message") }
</span>
);
}
if (!this.state.playback) {
// TODO: @@TR: Verify loading/decrypting state
return (
<span className="mx_MAudioBody">
<InlineSpinner />
</span>
);
}
// At this point we should have a playable state
return (
<span className="mx_MAudioBody">
<AudioPlayer playback={this.state.playback} mediaName={this.props.mxEvent.getContent().body} />
<MFileBody {...this.props} decryptedBlob={this.state.decryptedBlob} showGenericPlaceholder={false} />
</span>
);
}
}

View file

@ -29,6 +29,8 @@ import MatrixClientContext from "../../../contexts/MatrixClientContext";
import InlineSpinner from '../elements/InlineSpinner'; 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 BlurhashPlaceholder from "../elements/BlurhashPlaceholder";
import { BLURHASH_FIELD } from "../../../ContentMessages";
@replaceableComponent("views.messages.MImageBody") @replaceableComponent("views.messages.MImageBody")
export default class MImageBody extends React.Component { export default class MImageBody extends React.Component {
@ -333,7 +335,8 @@ export default class MImageBody extends React.Component {
infoWidth = content.info.w; infoWidth = content.info.w;
infoHeight = content.info.h; infoHeight = content.info.h;
} else { } else {
// Whilst the image loads, display nothing. // Whilst the image loads, display nothing. We also don't display a blurhash image
// because we don't really know what size of image we'll end up with.
// //
// Once loaded, use the loaded image dimensions stored in `loadedImageDimensions`. // Once loaded, use the loaded image dimensions stored in `loadedImageDimensions`.
// //
@ -368,12 +371,8 @@ export default class MImageBody extends React.Component {
let placeholder = null; let placeholder = null;
let gifLabel = null; let gifLabel = null;
// e2e image hasn't been decrypted yet if (!this.state.imgLoaded) {
if (content.file !== undefined && this.state.decryptedUrl === null) { placeholder = this.getPlaceholder(maxWidth, maxHeight);
placeholder = <InlineSpinner w={32} h={32} />;
} else if (!this.state.imgLoaded) {
// Deliberately, getSpinner is left unimplemented here, MStickerBody overides
placeholder = this.getPlaceholder();
} }
let showPlaceholder = Boolean(placeholder); let showPlaceholder = Boolean(placeholder);
@ -395,7 +394,7 @@ export default class MImageBody extends React.Component {
if (!this.state.showImage) { if (!this.state.showImage) {
img = <HiddenImagePlaceholder style={{ maxWidth: maxWidth + "px" }} />; img = <HiddenImagePlaceholder style={{ maxWidth: maxWidth + "px" }} />;
showPlaceholder = false; // because we're hiding the image, so don't show the sticker icon. 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) {
@ -411,10 +410,8 @@ export default class MImageBody extends React.Component {
// 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",
}}> }}>
<div className="mx_MImageBody_thumbnail_spinner">
{ placeholder } { placeholder }
</div> </div>
</div>
} }
<div style={{ display: !showPlaceholder ? undefined : 'none' }}> <div style={{ display: !showPlaceholder ? undefined : 'none' }}>
@ -437,9 +434,12 @@ export default class MImageBody extends React.Component {
} }
// Overidden by MStickerBody // Overidden by MStickerBody
getPlaceholder() { getPlaceholder(width, height) {
// MImageBody doesn't show a placeholder whilst the image loads, (but it could do) const blurhash = this.props.mxEvent.getContent().info[BLURHASH_FIELD];
return null; if (blurhash) return <BlurhashPlaceholder blurhash={blurhash} width={width} height={height} />;
return <div className="mx_MImageBody_thumbnail_spinner">
<InlineSpinner w={32} h={32} />
</div>;
} }
// Overidden by MStickerBody // Overidden by MStickerBody

View file

@ -28,7 +28,7 @@ import EventTileBubble from "./EventTileBubble";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
interface IProps { interface IProps {
mxEvent: MatrixEvent mxEvent: MatrixEvent;
} }
@replaceableComponent("views.messages.MKeyVerificationRequest") @replaceableComponent("views.messages.MKeyVerificationRequest")
@ -154,7 +154,7 @@ export default class MKeyVerificationRequest extends React.Component<IProps> {
<AccessibleButton kind="danger" onClick={this.onRejectClicked}> <AccessibleButton kind="danger" onClick={this.onRejectClicked}>
{_t("Decline")} {_t("Decline")}
</AccessibleButton> </AccessibleButton>
<AccessibleButton onClick={this.onAcceptClicked}> <AccessibleButton kind="primary" onClick={this.onAcceptClicked}>
{_t("Accept")} {_t("Accept")}
</AccessibleButton> </AccessibleButton>
</div>); </div>);

View file

@ -18,6 +18,7 @@ import React from 'react';
import MImageBody from './MImageBody'; import MImageBody from './MImageBody';
import * as sdk from '../../../index'; import * as sdk from '../../../index';
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import { BLURHASH_FIELD } from "../../../ContentMessages";
@replaceableComponent("views.messages.MStickerBody") @replaceableComponent("views.messages.MStickerBody")
export default class MStickerBody extends MImageBody { export default class MStickerBody extends MImageBody {
@ -41,9 +42,9 @@ export default class MStickerBody extends MImageBody {
// Placeholder to show in place of the sticker image if // Placeholder to show in place of the sticker image if
// img onLoad hasn't fired yet. // img onLoad hasn't fired yet.
getPlaceholder() { getPlaceholder(width, height) {
const TintableSVG = sdk.getComponent('elements.TintableSvg'); if (this.props.mxEvent.getContent().info[BLURHASH_FIELD]) return super.getPlaceholder(width, height);
return <TintableSVG src={require("../../../../res/img/icons-show-stickers.svg")} width="75" height="75" />; return <img src={require("../../../../res/img/icons-show-stickers.svg")} width="75" height="75" />;
} }
// Tooltip to show on mouse over // Tooltip to show on mouse over

View file

@ -16,6 +16,8 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import { decode } from "blurhash";
import MFileBody from './MFileBody'; import MFileBody from './MFileBody';
import { decryptFile } from '../../../utils/DecryptFile'; import { decryptFile } from '../../../utils/DecryptFile';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
@ -23,6 +25,7 @@ import SettingsStore from "../../../settings/SettingsStore";
import InlineSpinner from '../elements/InlineSpinner'; 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";
interface IProps { interface IProps {
/* the MatrixEvent to show */ /* the MatrixEvent to show */
@ -32,11 +35,13 @@ interface IProps {
} }
interface IState { interface IState {
decryptedUrl: string|null, decryptedUrl?: string;
decryptedThumbnailUrl: string|null, decryptedThumbnailUrl?: string;
decryptedBlob: Blob|null, decryptedBlob?: Blob;
error: any|null, error?: any;
fetchingData: boolean, fetchingData: boolean;
posterLoading: boolean;
blurhashUrl: string;
} }
@replaceableComponent("views.messages.MVideoBody") @replaceableComponent("views.messages.MVideoBody")
@ -51,10 +56,12 @@ export default class MVideoBody extends React.PureComponent<IProps, IState> {
decryptedThumbnailUrl: null, decryptedThumbnailUrl: null,
decryptedBlob: null, decryptedBlob: null,
error: null, error: null,
posterLoading: false,
blurhashUrl: null,
}; };
} }
thumbScale(fullWidth: number, fullHeight: number, thumbWidth: number, thumbHeight: number) { thumbScale(fullWidth: number, fullHeight: number, thumbWidth = 480, thumbHeight = 360) {
if (!fullWidth || !fullHeight) { if (!fullWidth || !fullHeight) {
// Cannot calculate thumbnail height for image: missing w/h in metadata. We can't even // Cannot calculate thumbnail height for image: missing w/h in metadata. We can't even
// log this because it's spammy // log this because it's spammy
@ -92,8 +99,11 @@ export default class MVideoBody extends React.PureComponent<IProps, IState> {
private getThumbUrl(): string|null { private getThumbUrl(): string|null {
const content = this.props.mxEvent.getContent(); const content = this.props.mxEvent.getContent();
const media = mediaFromContent(content); const media = mediaFromContent(content);
if (media.isEncrypted) {
if (media.isEncrypted && this.state.decryptedThumbnailUrl) {
return this.state.decryptedThumbnailUrl; return this.state.decryptedThumbnailUrl;
} else if (this.state.posterLoading) {
return this.state.blurhashUrl;
} else if (media.hasThumbnail) { } else if (media.hasThumbnail) {
return media.thumbnailHttp; return media.thumbnailHttp;
} else { } else {
@ -101,18 +111,57 @@ export default class MVideoBody extends React.PureComponent<IProps, IState> {
} }
} }
private loadBlurhash() {
const info = this.props.mxEvent.getContent()?.info;
if (!info[BLURHASH_FIELD]) return;
const canvas = document.createElement("canvas");
let width = info.w;
let height = info.h;
const scale = this.thumbScale(info.w, info.h);
if (scale) {
width = Math.floor(info.w * scale);
height = Math.floor(info.h * scale);
}
canvas.width = width;
canvas.height = height;
const pixels = decode(info[BLURHASH_FIELD], width, height);
const ctx = canvas.getContext("2d");
const imgData = ctx.createImageData(width, height);
imgData.data.set(pixels);
ctx.putImageData(imgData, 0, 0);
this.setState({
blurhashUrl: canvas.toDataURL(),
posterLoading: true,
});
const content = this.props.mxEvent.getContent();
const media = mediaFromContent(content);
if (media.hasThumbnail) {
const image = new Image();
image.onload = () => {
this.setState({ posterLoading: false });
};
image.src = media.thumbnailHttp;
}
}
async componentDidMount() { async componentDidMount() {
const autoplay = SettingsStore.getValue("autoplayGifsAndVideos") as boolean; const autoplay = SettingsStore.getValue("autoplayGifsAndVideos") as boolean;
const content = this.props.mxEvent.getContent(); const content = this.props.mxEvent.getContent();
this.loadBlurhash();
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);
if (content.info && content.info.thumbnail_file) { if (content?.info?.thumbnail_file) {
thumbnailPromise = decryptFile( thumbnailPromise = decryptFile(content.info.thumbnail_file)
content.info.thumbnail_file, .then(blob => URL.createObjectURL(blob));
).then(function(blob) {
return URL.createObjectURL(blob);
});
} }
try { try {
const thumbnailUrl = await thumbnailPromise; const thumbnailUrl = await thumbnailPromise;
if (autoplay) { if (autoplay) {
@ -218,7 +267,7 @@ export default class MVideoBody extends React.PureComponent<IProps, IState> {
let poster = null; let poster = null;
let preload = "metadata"; let preload = "metadata";
if (content.info) { if (content.info) {
const scale = this.thumbScale(content.info.w, content.info.h, 480, 360); const scale = this.thumbScale(content.info.w, content.info.h);
if (scale) { if (scale) {
width = Math.floor(content.info.w * scale); width = Math.floor(content.info.w * scale);
height = Math.floor(content.info.h * scale); height = Math.floor(content.info.h * scale);

View file

@ -23,7 +23,7 @@ import InlineSpinner from '../elements/InlineSpinner';
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import { mediaFromContent } from "../../../customisations/Media"; import { mediaFromContent } from "../../../customisations/Media";
import { decryptFile } from "../../../utils/DecryptFile"; import { decryptFile } from "../../../utils/DecryptFile";
import RecordingPlayback from "../voice_messages/RecordingPlayback"; import RecordingPlayback from "../audio_messages/RecordingPlayback";
import { IMediaEventContent } from "../../../customisations/models/IMediaEventContent"; import { IMediaEventContent } from "../../../customisations/models/IMediaEventContent";
interface IProps { interface IProps {

View file

@ -1,7 +1,5 @@
/* /*
Copyright 2015, 2016 OpenMarket Ltd Copyright 2015 - 2021 The Matrix.org Foundation C.I.C.
Copyright 2017 New Vector Ltd
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.
@ -16,134 +14,151 @@ 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, { createRef, SyntheticEvent } from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import PropTypes from 'prop-types';
import highlight from 'highlight.js'; import highlight from 'highlight.js';
import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
import { MsgType } from "matrix-js-sdk/src/@types/event";
import * as HtmlUtils from '../../../HtmlUtils'; import * as HtmlUtils from '../../../HtmlUtils';
import { formatDate } from '../../../DateUtils'; import { formatDate } from '../../../DateUtils';
import * as sdk from '../../../index';
import Modal from '../../../Modal'; import Modal from '../../../Modal';
import dis from '../../../dispatcher/dispatcher'; import dis from '../../../dispatcher/dispatcher';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import * as ContextMenu from '../../structures/ContextMenu'; import * as ContextMenu from '../../structures/ContextMenu';
import { toRightOf } from '../../structures/ContextMenu';
import SettingsStore from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
import ReplyThread from "../elements/ReplyThread"; import ReplyThread from "../elements/ReplyThread";
import { pillifyLinks, unmountPills } from '../../../utils/pillify'; import { pillifyLinks, unmountPills } from '../../../utils/pillify';
import { IntegrationManagers } from "../../../integrations/IntegrationManagers"; import { IntegrationManagers } from "../../../integrations/IntegrationManagers";
import { isPermalinkHost } from "../../../utils/permalinks/Permalinks"; import { isPermalinkHost } from "../../../utils/permalinks/Permalinks";
import { toRightOf } from "../../structures/ContextMenu";
import { copyPlaintext } from "../../../utils/strings"; import { copyPlaintext } from "../../../utils/strings";
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import UIStore from "../../../stores/UIStore"; import UIStore from "../../../stores/UIStore";
import { ComposerInsertPayload } from "../../../dispatcher/payloads/ComposerInsertPayload"; import { ComposerInsertPayload } from "../../../dispatcher/payloads/ComposerInsertPayload";
import { Action } from "../../../dispatcher/actions"; import { Action } from "../../../dispatcher/actions";
import { TileShape } from '../rooms/EventTile';
import EditorStateTransfer from "../../../utils/EditorStateTransfer";
import GenericTextContextMenu from "../context_menus/GenericTextContextMenu";
import Spoiler from "../elements/Spoiler";
import QuestionDialog from "../dialogs/QuestionDialog";
import MessageEditHistoryDialog from "../dialogs/MessageEditHistoryDialog";
import EditMessageComposer from '../rooms/EditMessageComposer';
import LinkPreviewWidget from '../rooms/LinkPreviewWidget';
@replaceableComponent("views.messages.TextualBody") interface IProps {
export default class TextualBody extends React.Component {
static propTypes = {
/* the MatrixEvent to show */ /* the MatrixEvent to show */
mxEvent: PropTypes.object.isRequired, mxEvent: MatrixEvent;
/* a list of words to highlight */ /* a list of words to highlight */
highlights: PropTypes.array, highlights?: string[];
/* link URL for the highlights */ /* link URL for the highlights */
highlightLink: PropTypes.string, highlightLink?: string;
/* should show URL previews for this event */ /* should show URL previews for this event */
showUrlPreview: PropTypes.bool, showUrlPreview?: boolean;
/* callback for when our widget has loaded */
onHeightChanged: PropTypes.func,
/* the shape of the tile, used */ /* the shape of the tile, used */
tileShape: PropTypes.string, tileShape?: TileShape;
};
editState?: EditorStateTransfer;
replacingEventId?: string;
/* callback for when our widget has loaded */
onHeightChanged(): void;
}
interface IState {
// the URLs (if any) to be previewed with a LinkPreviewWidget inside this TextualBody.
links: string[];
// track whether the preview widget is hidden
widgetHidden: boolean;
}
@replaceableComponent("views.messages.TextualBody")
export default class TextualBody extends React.Component<IProps, IState> {
private readonly contentRef = createRef<HTMLSpanElement>();
private unmounted = false;
private pills: Element[] = [];
constructor(props) { constructor(props) {
super(props); super(props);
this._content = createRef();
this.state = { this.state = {
// the URLs (if any) to be previewed with a LinkPreviewWidget
// inside this TextualBody.
links: [], links: [],
// track whether the preview widget is hidden
widgetHidden: false, widgetHidden: false,
}; };
} }
componentDidMount() { componentDidMount() {
this._unmounted = false;
this._pills = [];
if (!this.props.editState) { if (!this.props.editState) {
this._applyFormatting(); this.applyFormatting();
} }
} }
_applyFormatting() { private applyFormatting(): void {
const showLineNumbers = SettingsStore.getValue("showCodeLineNumbers"); const showLineNumbers = SettingsStore.getValue("showCodeLineNumbers");
this.activateSpoilers([this._content.current]); this.activateSpoilers([this.contentRef.current]);
// pillifyLinks BEFORE linkifyElement because plain room/user URLs in the composer // pillifyLinks BEFORE linkifyElement because plain room/user URLs in the composer
// are still sent as plaintext URLs. If these are ever pillified in the composer, // are still sent as plaintext URLs. If these are ever pillified in the composer,
// we should be pillify them here by doing the linkifying BEFORE the pillifying. // we should be pillify them here by doing the linkifying BEFORE the pillifying.
pillifyLinks([this._content.current], this.props.mxEvent, this._pills); pillifyLinks([this.contentRef.current], this.props.mxEvent, this.pills);
HtmlUtils.linkifyElement(this._content.current); HtmlUtils.linkifyElement(this.contentRef.current);
this.calculateUrlPreview(); this.calculateUrlPreview();
if (this.props.mxEvent.getContent().format === "org.matrix.custom.html") { if (this.props.mxEvent.getContent().format === "org.matrix.custom.html") {
// Handle expansion and add buttons // Handle expansion and add buttons
const pres = ReactDOM.findDOMNode(this).getElementsByTagName("pre"); const pres = (ReactDOM.findDOMNode(this) as Element).getElementsByTagName("pre");
if (pres.length > 0) { if (pres.length > 0) {
for (let i = 0; i < pres.length; i++) { for (let i = 0; i < pres.length; i++) {
// If there already is a div wrapping the codeblock we want to skip this. // If there already is a div wrapping the codeblock we want to skip this.
// This happens after the codeblock was edited. // This happens after the codeblock was edited.
if (pres[i].parentNode.className == "mx_EventTile_pre_container") continue; if (pres[i].parentElement.className == "mx_EventTile_pre_container") continue;
// Add code element if it's missing since we depend on it // Add code element if it's missing since we depend on it
if (pres[i].getElementsByTagName("code").length == 0) { if (pres[i].getElementsByTagName("code").length == 0) {
this._addCodeElement(pres[i]); this.addCodeElement(pres[i]);
} }
// Wrap a div around <pre> so that the copy button can be correctly positioned // Wrap a div around <pre> so that the copy button can be correctly positioned
// when the <pre> overflows and is scrolled horizontally. // when the <pre> overflows and is scrolled horizontally.
const div = this._wrapInDiv(pres[i]); const div = this.wrapInDiv(pres[i]);
this._handleCodeBlockExpansion(pres[i]); this.handleCodeBlockExpansion(pres[i]);
this._addCodeExpansionButton(div, pres[i]); this.addCodeExpansionButton(div, pres[i]);
this._addCodeCopyButton(div); this.addCodeCopyButton(div);
if (showLineNumbers) { if (showLineNumbers) {
this._addLineNumbers(pres[i]); this.addLineNumbers(pres[i]);
} }
} }
} }
// Highlight code // Highlight code
const codes = ReactDOM.findDOMNode(this).getElementsByTagName("code"); const codes = (ReactDOM.findDOMNode(this) as Element).getElementsByTagName("code");
if (codes.length > 0) { if (codes.length > 0) {
// Do this asynchronously: parsing code takes time and we don't // Do this asynchronously: parsing code takes time and we don't
// need to block the DOM update on it. // need to block the DOM update on it.
setTimeout(() => { setTimeout(() => {
if (this._unmounted) return; if (this.unmounted) return;
for (let i = 0; i < codes.length; i++) { for (let i = 0; i < codes.length; i++) {
// If the code already has the hljs class we want to skip this. // If the code already has the hljs class we want to skip this.
// This happens after the codeblock was edited. // This happens after the codeblock was edited.
if (codes[i].className.includes("hljs")) continue; if (codes[i].className.includes("hljs")) continue;
this._highlightCode(codes[i]); this.highlightCode(codes[i]);
} }
}, 10); }, 10);
} }
} }
} }
_addCodeElement(pre) { private addCodeElement(pre: HTMLPreElement): void {
const code = document.createElement("code"); const code = document.createElement("code");
code.append(...pre.childNodes); code.append(...pre.childNodes);
pre.appendChild(code); pre.appendChild(code);
} }
_addCodeExpansionButton(div, pre) { private addCodeExpansionButton(div: HTMLDivElement, pre: HTMLPreElement): void {
// Calculate how many percent does the pre element take up. // Calculate how many percent does the pre element take up.
// If it's less than 30% we don't add the expansion button. // If it's less than 30% we don't add the expansion button.
const percentageOfViewport = pre.offsetHeight / UIStore.instance.windowHeight * 100; const percentageOfViewport = pre.offsetHeight / UIStore.instance.windowHeight * 100;
@ -175,7 +190,7 @@ export default class TextualBody extends React.Component {
div.appendChild(button); div.appendChild(button);
} }
_addCodeCopyButton(div) { private addCodeCopyButton(div: HTMLDivElement): void {
const button = document.createElement("span"); const button = document.createElement("span");
button.className = "mx_EventTile_button mx_EventTile_copyButton "; button.className = "mx_EventTile_button mx_EventTile_copyButton ";
@ -185,11 +200,10 @@ export default class TextualBody extends React.Component {
if (expansionButtonExists.length > 0) button.className += "mx_EventTile_buttonBottom"; if (expansionButtonExists.length > 0) button.className += "mx_EventTile_buttonBottom";
button.onclick = async () => { button.onclick = async () => {
const copyCode = button.parentNode.getElementsByTagName("code")[0]; const copyCode = button.parentElement.getElementsByTagName("code")[0];
const successful = await copyPlaintext(copyCode.textContent); const successful = await copyPlaintext(copyCode.textContent);
const buttonRect = button.getBoundingClientRect(); const buttonRect = button.getBoundingClientRect();
const GenericTextContextMenu = sdk.getComponent('context_menus.GenericTextContextMenu');
const { close } = ContextMenu.createMenu(GenericTextContextMenu, { const { close } = ContextMenu.createMenu(GenericTextContextMenu, {
...toRightOf(buttonRect, 2), ...toRightOf(buttonRect, 2),
message: successful ? _t('Copied!') : _t('Failed to copy'), message: successful ? _t('Copied!') : _t('Failed to copy'),
@ -200,7 +214,7 @@ export default class TextualBody extends React.Component {
div.appendChild(button); div.appendChild(button);
} }
_wrapInDiv(pre) { private wrapInDiv(pre: HTMLPreElement): HTMLDivElement {
const div = document.createElement("div"); const div = document.createElement("div");
div.className = "mx_EventTile_pre_container"; div.className = "mx_EventTile_pre_container";
@ -212,13 +226,13 @@ export default class TextualBody extends React.Component {
return div; return div;
} }
_handleCodeBlockExpansion(pre) { private handleCodeBlockExpansion(pre: HTMLPreElement): void {
if (!SettingsStore.getValue("expandCodeByDefault")) { if (!SettingsStore.getValue("expandCodeByDefault")) {
pre.className = "mx_EventTile_collapsedCodeBlock"; pre.className = "mx_EventTile_collapsedCodeBlock";
} }
} }
_addLineNumbers(pre) { private addLineNumbers(pre: HTMLPreElement): void {
// Calculate number of lines in pre // Calculate number of lines in pre
const number = pre.innerHTML.replace(/\n(<\/code>)?$/, "").split(/\n/).length; const number = pre.innerHTML.replace(/\n(<\/code>)?$/, "").split(/\n/).length;
pre.innerHTML = '<span class="mx_EventTile_lineNumbers"></span>' + pre.innerHTML + '<span></span>'; pre.innerHTML = '<span class="mx_EventTile_lineNumbers"></span>' + pre.innerHTML + '<span></span>';
@ -229,7 +243,7 @@ export default class TextualBody extends React.Component {
} }
} }
_highlightCode(code) { private highlightCode(code: HTMLElement): void {
if (SettingsStore.getValue("enableSyntaxHighlightLanguageDetection")) { if (SettingsStore.getValue("enableSyntaxHighlightLanguageDetection")) {
highlight.highlightBlock(code); highlight.highlightBlock(code);
} else { } else {
@ -249,14 +263,14 @@ export default class TextualBody extends React.Component {
const stoppedEditing = prevProps.editState && !this.props.editState; const stoppedEditing = prevProps.editState && !this.props.editState;
const messageWasEdited = prevProps.replacingEventId !== this.props.replacingEventId; const messageWasEdited = prevProps.replacingEventId !== this.props.replacingEventId;
if (messageWasEdited || stoppedEditing) { if (messageWasEdited || stoppedEditing) {
this._applyFormatting(); this.applyFormatting();
} }
} }
} }
componentWillUnmount() { componentWillUnmount() {
this._unmounted = true; this.unmounted = true;
unmountPills(this._pills); unmountPills(this.pills);
} }
shouldComponentUpdate(nextProps, nextState) { shouldComponentUpdate(nextProps, nextState) {
@ -273,12 +287,12 @@ export default class TextualBody extends React.Component {
nextState.widgetHidden !== this.state.widgetHidden); nextState.widgetHidden !== this.state.widgetHidden);
} }
calculateUrlPreview() { private calculateUrlPreview(): void {
//console.info("calculateUrlPreview: ShowUrlPreview for %s is %s", this.props.mxEvent.getId(), this.props.showUrlPreview); //console.info("calculateUrlPreview: ShowUrlPreview for %s is %s", this.props.mxEvent.getId(), this.props.showUrlPreview);
if (this.props.showUrlPreview) { if (this.props.showUrlPreview) {
// pass only the first child which is the event tile otherwise this recurses on edited events // pass only the first child which is the event tile otherwise this recurses on edited events
let links = this.findLinks([this._content.current]); let links = this.findLinks([this.contentRef.current]);
if (links.length) { if (links.length) {
// de-duplicate the links after stripping hashes as they don't affect the preview // de-duplicate the links after stripping hashes as they don't affect the preview
// using a set here maintains the order // using a set here maintains the order
@ -291,8 +305,8 @@ export default class TextualBody extends React.Component {
this.setState({ links }); this.setState({ links });
// lazy-load the hidden state of the preview widget from localstorage // lazy-load the hidden state of the preview widget from localstorage
if (global.localStorage) { if (window.localStorage) {
const hidden = global.localStorage.getItem("hide_preview_" + this.props.mxEvent.getId()); const hidden = !!window.localStorage.getItem("hide_preview_" + this.props.mxEvent.getId());
this.setState({ widgetHidden: hidden }); this.setState({ widgetHidden: hidden });
} }
} else if (this.state.links.length) { } else if (this.state.links.length) {
@ -301,19 +315,15 @@ export default class TextualBody extends React.Component {
} }
} }
activateSpoilers(nodes) { private activateSpoilers(nodes: ArrayLike<Element>): void {
let node = nodes[0]; let node = nodes[0];
while (node) { while (node) {
if (node.tagName === "SPAN" && typeof node.getAttribute("data-mx-spoiler") === "string") { if (node.tagName === "SPAN" && typeof node.getAttribute("data-mx-spoiler") === "string") {
const spoilerContainer = document.createElement('span'); const spoilerContainer = document.createElement('span');
const reason = node.getAttribute("data-mx-spoiler"); const reason = node.getAttribute("data-mx-spoiler");
const Spoiler = sdk.getComponent('elements.Spoiler');
node.removeAttribute("data-mx-spoiler"); // we don't want to recurse node.removeAttribute("data-mx-spoiler"); // we don't want to recurse
const spoiler = <Spoiler const spoiler = <Spoiler reason={reason} contentHtml={node.outerHTML} />;
reason={reason}
contentHtml={node.outerHTML}
/>;
ReactDOM.render(spoiler, spoilerContainer); ReactDOM.render(spoiler, spoilerContainer);
node.parentNode.replaceChild(spoilerContainer, node); node.parentNode.replaceChild(spoilerContainer, node);
@ -322,15 +332,15 @@ export default class TextualBody extends React.Component {
} }
if (node.childNodes && node.childNodes.length) { if (node.childNodes && node.childNodes.length) {
this.activateSpoilers(node.childNodes); this.activateSpoilers(node.childNodes as NodeListOf<Element>);
} }
node = node.nextSibling; node = node.nextSibling as Element;
} }
} }
findLinks(nodes) { private findLinks(nodes: ArrayLike<Element>): string[] {
let links = []; let links: string[] = [];
for (let i = 0; i < nodes.length; i++) { for (let i = 0; i < nodes.length; i++) {
const node = nodes[i]; const node = nodes[i];
@ -348,7 +358,7 @@ export default class TextualBody extends React.Component {
return links; return links;
} }
isLinkPreviewable(node) { private isLinkPreviewable(node: Element): boolean {
// don't try to preview relative links // don't try to preview relative links
if (!node.getAttribute("href").startsWith("http://") && if (!node.getAttribute("href").startsWith("http://") &&
!node.getAttribute("href").startsWith("https://")) { !node.getAttribute("href").startsWith("https://")) {
@ -381,7 +391,7 @@ export default class TextualBody extends React.Component {
} }
} }
onCancelClick = event => { private onCancelClick = (): void => {
this.setState({ widgetHidden: true }); this.setState({ widgetHidden: true });
// FIXME: persist this somewhere smarter than local storage // FIXME: persist this somewhere smarter than local storage
if (global.localStorage) { if (global.localStorage) {
@ -390,7 +400,7 @@ export default class TextualBody extends React.Component {
this.forceUpdate(); this.forceUpdate();
}; };
onEmoteSenderClick = event => { private onEmoteSenderClick = (): void => {
const mxEvent = this.props.mxEvent; const mxEvent = this.props.mxEvent;
dis.dispatch<ComposerInsertPayload>({ dis.dispatch<ComposerInsertPayload>({
action: Action.ComposerInsert, action: Action.ComposerInsert,
@ -398,7 +408,7 @@ export default class TextualBody extends React.Component {
}); });
}; };
getEventTileOps = () => ({ public getEventTileOps = () => ({
isWidgetHidden: () => { isWidgetHidden: () => {
return this.state.widgetHidden; return this.state.widgetHidden;
}, },
@ -411,7 +421,7 @@ export default class TextualBody extends React.Component {
}, },
}); });
onStarterLinkClick = (starterLink, ev) => { private onStarterLinkClick = (starterLink: string, ev: SyntheticEvent): void => {
ev.preventDefault(); ev.preventDefault();
// We need to add on our scalar token to the starter link, but we may not have one! // We need to add on our scalar token to the starter link, but we may not have one!
// In addition, we can't fetch one on click and then go to it immediately as that // In addition, we can't fetch one on click and then go to it immediately as that
@ -431,7 +441,6 @@ export default class TextualBody extends React.Component {
const scalarClient = integrationManager.getScalarClient(); const scalarClient = integrationManager.getScalarClient();
scalarClient.connect().then(() => { scalarClient.connect().then(() => {
const completeUrl = scalarClient.getStarterLink(starterLink); const completeUrl = scalarClient.getStarterLink(starterLink);
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
const integrationsUrl = integrationManager.uiUrl; const integrationsUrl = integrationManager.uiUrl;
Modal.createTrackedDialog('Add an integration', '', QuestionDialog, { Modal.createTrackedDialog('Add an integration', '', QuestionDialog, {
title: _t("Add an Integration"), title: _t("Add an Integration"),
@ -458,12 +467,11 @@ export default class TextualBody extends React.Component {
}); });
}; };
_openHistoryDialog = async () => { private openHistoryDialog = async (): Promise<void> => {
const MessageEditHistoryDialog = sdk.getComponent("views.dialogs.MessageEditHistoryDialog");
Modal.createDialog(MessageEditHistoryDialog, { mxEvent: this.props.mxEvent }); Modal.createDialog(MessageEditHistoryDialog, { mxEvent: this.props.mxEvent });
}; };
_renderEditedMarker() { private renderEditedMarker() {
const date = this.props.mxEvent.replacingEventDate(); const date = this.props.mxEvent.replacingEventDate();
const dateString = date && formatDate(date); const dateString = date && formatDate(date);
@ -479,7 +487,7 @@ export default class TextualBody extends React.Component {
return ( return (
<AccessibleTooltipButton <AccessibleTooltipButton
className="mx_EventTile_edited" className="mx_EventTile_edited"
onClick={this._openHistoryDialog} onClick={this.openHistoryDialog}
title={_t("Edited at %(date)s. Click to view edits.", { date: dateString })} title={_t("Edited at %(date)s. Click to view edits.", { date: dateString })}
tooltip={tooltip} tooltip={tooltip}
> >
@ -490,24 +498,25 @@ export default class TextualBody extends React.Component {
render() { render() {
if (this.props.editState) { if (this.props.editState) {
const EditMessageComposer = sdk.getComponent('rooms.EditMessageComposer');
return <EditMessageComposer editState={this.props.editState} className="mx_EventTile_content" />; return <EditMessageComposer editState={this.props.editState} className="mx_EventTile_content" />;
} }
const mxEvent = this.props.mxEvent; const mxEvent = this.props.mxEvent;
const content = mxEvent.getContent(); const content = mxEvent.getContent();
// only strip reply if this is the original replying event, edits thereafter do not have the fallback // only strip reply if this is the original replying event, edits thereafter do not have the fallback
const stripReply = !mxEvent.replacingEvent() && ReplyThread.getParentEventId(mxEvent); const stripReply = !mxEvent.replacingEvent() && !!ReplyThread.getParentEventId(mxEvent);
let body = HtmlUtils.bodyToHtml(content, this.props.highlights, { let body = HtmlUtils.bodyToHtml(content, this.props.highlights, {
disableBigEmoji: content.msgtype === "m.emote" || !SettingsStore.getValue('TextualBody.enableBigEmoji'), disableBigEmoji: content.msgtype === MsgType.Emote
|| !SettingsStore.getValue<boolean>('TextualBody.enableBigEmoji'),
// Part of Replies fallback support // Part of Replies fallback support
stripReplyFallback: stripReply, stripReplyFallback: stripReply,
ref: this._content, ref: this.contentRef,
returnString: false,
}); });
if (this.props.replacingEventId) { if (this.props.replacingEventId) {
body = <> body = <>
{body} {body}
{this._renderEditedMarker()} {this.renderEditedMarker()}
</>; </>;
} }
@ -521,7 +530,6 @@ export default class TextualBody extends React.Component {
let widgets; let widgets;
if (this.state.links.length && !this.state.widgetHidden && this.props.showUrlPreview) { if (this.state.links.length && !this.state.widgetHidden && this.props.showUrlPreview) {
const LinkPreviewWidget = sdk.getComponent('rooms.LinkPreviewWidget');
widgets = this.state.links.map((link)=>{ widgets = this.state.links.map((link)=>{
return <LinkPreviewWidget return <LinkPreviewWidget
key={link} key={link}
@ -534,7 +542,7 @@ export default class TextualBody extends React.Component {
} }
switch (content.msgtype) { switch (content.msgtype) {
case "m.emote": case MsgType.Emote:
return ( return (
<span className="mx_MEmoteBody mx_EventTile_content"> <span className="mx_MEmoteBody mx_EventTile_content">
*&nbsp; *&nbsp;
@ -549,7 +557,7 @@ export default class TextualBody extends React.Component {
{ widgets } { widgets }
</span> </span>
); );
case "m.notice": case MsgType.Notice:
return ( return (
<span className="mx_MNoticeBody mx_EventTile_content"> <span className="mx_MNoticeBody mx_EventTile_content">
{ body } { body }

View file

@ -1,6 +1,5 @@
/* /*
Copyright 2015, 2016 OpenMarket Ltd Copyright 2015 - 2021 The Matrix.org Foundation C.I.C.
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.
@ -16,20 +15,20 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
import * as TextForEvent from "../../../TextForEvent"; import * as TextForEvent from "../../../TextForEvent";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
@replaceableComponent("views.messages.TextualEvent") interface IProps {
export default class TextualEvent extends React.Component { mxEvent: MatrixEvent;
static propTypes = { }
/* the MatrixEvent to show */
mxEvent: PropTypes.object.isRequired,
};
@replaceableComponent("views.messages.TextualEvent")
export default class TextualEvent extends React.Component<IProps> {
render() { render() {
const text = TextForEvent.textForEvent(this.props.mxEvent, true); const text = TextForEvent.textForEvent(this.props.mxEvent, true);
if (text == null || text.length === 0) return null; if (!text || (text as string).length === 0) return null;
return ( return (
<div className="mx_TextualEvent">{ text }</div> <div className="mx_TextualEvent">{ text }</div>
); );

View file

@ -39,9 +39,8 @@ interface IProps {
member: RoomMember | User; member: RoomMember | User;
onClose: () => void; onClose: () => void;
verificationRequest: VerificationRequest; verificationRequest: VerificationRequest;
verificationRequestPromise: Promise<VerificationRequest>; verificationRequestPromise?: Promise<VerificationRequest>;
layout: string; layout: string;
inDialog: boolean;
isRoomEncrypted: boolean; isRoomEncrypted: boolean;
} }

View file

@ -21,7 +21,6 @@ import { Room } from 'matrix-js-sdk/src/models/room';
import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { MatrixClientPeg } from "../../../MatrixClientPeg";
import AppsDrawer from './AppsDrawer'; import AppsDrawer from './AppsDrawer';
import RateLimitedFunc from '../../../ratelimitedfunc';
import SettingsStore from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
import AutoHideScrollbar from "../../structures/AutoHideScrollbar"; import AutoHideScrollbar from "../../structures/AutoHideScrollbar";
import { UIFeature } from "../../../settings/UIFeature"; import { UIFeature } from "../../../settings/UIFeature";
@ -29,35 +28,36 @@ import ResizeNotifier from "../../../utils/ResizeNotifier";
import CallViewForRoom from '../voip/CallViewForRoom'; import CallViewForRoom from '../voip/CallViewForRoom';
import { objectHasDiff } from "../../../utils/objects"; import { objectHasDiff } from "../../../utils/objects";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import { throttle } from 'lodash';
interface IProps { interface IProps {
// js-sdk room object // js-sdk room object
room: Room, room: Room;
userId: string, userId: string;
showApps: boolean, // Render apps showApps: boolean; // Render apps
// maxHeight attribute for the aux panel and the video // maxHeight attribute for the aux panel and the video
// therein // therein
maxHeight: number, maxHeight: number;
// a callback which is called when the content of the aux panel changes // a callback which is called when the content of the aux panel changes
// content in a way that is likely to make it change size. // content in a way that is likely to make it change size.
onResize: () => void, onResize: () => void;
fullHeight: boolean, fullHeight: boolean;
resizeNotifier: ResizeNotifier, resizeNotifier: ResizeNotifier;
} }
interface Counter { interface Counter {
title: string, title: string;
value: number, value: number;
link: string, link: string;
severity: string, severity: string;
stateKey: string, stateKey: string;
} }
interface IState { interface IState {
counters: Counter[], counters: Counter[];
} }
@replaceableComponent("views.rooms.AuxPanel") @replaceableComponent("views.rooms.AuxPanel")
@ -99,9 +99,9 @@ export default class AuxPanel extends React.Component<IProps, IState> {
} }
} }
private rateLimitedUpdate = new RateLimitedFunc(() => { private rateLimitedUpdate = throttle(() => {
this.setState({ counters: this.computeCounters() }); this.setState({ counters: this.computeCounters() });
}, 500); }, 500, { leading: true, trailing: true });
private computeCounters() { private computeCounters() {
const counters = []; const counters = [];

View file

@ -41,7 +41,7 @@ import { Key } from "../../../Keyboard";
import { EMOTICON_TO_EMOJI } from "../../../emoji"; import { EMOTICON_TO_EMOJI } from "../../../emoji";
import { CommandCategories, CommandMap, parseCommandString } from "../../../SlashCommands"; import { CommandCategories, CommandMap, parseCommandString } from "../../../SlashCommands";
import Range from "../../../editor/range"; import Range from "../../../editor/range";
import MessageComposerFormatBar from "./MessageComposerFormatBar"; import MessageComposerFormatBar, { Formatting } from "./MessageComposerFormatBar";
import DocumentOffset from "../../../editor/offset"; import DocumentOffset from "../../../editor/offset";
import { IDiff } from "../../../editor/diff"; import { IDiff } from "../../../editor/diff";
import AutocompleteWrapperModel from "../../../editor/autocomplete"; import AutocompleteWrapperModel from "../../../editor/autocomplete";
@ -55,7 +55,7 @@ const REGEX_EMOTICON_WHITESPACE = new RegExp('(?:^|\\s)(' + EMOTICON_REGEX.sourc
const IS_MAC = navigator.platform.indexOf("Mac") !== -1; const IS_MAC = navigator.platform.indexOf("Mac") !== -1;
function ctrlShortcutLabel(key) { function ctrlShortcutLabel(key: string): string {
return (IS_MAC ? "⌘" : "Ctrl") + "+" + key; return (IS_MAC ? "⌘" : "Ctrl") + "+" + key;
} }
@ -81,14 +81,6 @@ function selectionEquals(a: Partial<Selection>, b: Selection): boolean {
a.type === b.type; a.type === b.type;
} }
enum Formatting {
Bold = "bold",
Italics = "italics",
Strikethrough = "strikethrough",
Code = "code",
Quote = "quote",
}
interface IProps { interface IProps {
model: EditorModel; model: EditorModel;
room: Room; room: Room;
@ -111,9 +103,9 @@ interface IState {
@replaceableComponent("views.rooms.BasicMessageEditor") @replaceableComponent("views.rooms.BasicMessageEditor")
export default class BasicMessageEditor extends React.Component<IProps, IState> { export default class BasicMessageEditor extends React.Component<IProps, IState> {
private editorRef = createRef<HTMLDivElement>(); public readonly editorRef = createRef<HTMLDivElement>();
private autocompleteRef = createRef<Autocomplete>(); private autocompleteRef = createRef<Autocomplete>();
private formatBarRef = createRef<typeof MessageComposerFormatBar>(); private formatBarRef = createRef<MessageComposerFormatBar>();
private modifiedFlag = false; private modifiedFlag = false;
private isIMEComposing = false; private isIMEComposing = false;
@ -156,7 +148,7 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
} }
} }
private replaceEmoticon = (caretPosition: DocumentPosition) => { private replaceEmoticon = (caretPosition: DocumentPosition): number => {
const { model } = this.props; const { model } = this.props;
const range = model.startRange(caretPosition); const range = model.startRange(caretPosition);
// expand range max 8 characters backwards from caretPosition, // expand range max 8 characters backwards from caretPosition,
@ -188,7 +180,7 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
} }
}; };
private updateEditorState = (selection: Caret, inputType?: string, diff?: IDiff) => { private updateEditorState = (selection: Caret, inputType?: string, diff?: IDiff): void => {
renderModel(this.editorRef.current, this.props.model); renderModel(this.editorRef.current, this.props.model);
if (selection) { // set the caret/selection if (selection) { // set the caret/selection
try { try {
@ -230,25 +222,25 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
} }
}; };
private showPlaceholder() { private showPlaceholder(): void {
// escape single quotes // escape single quotes
const placeholder = this.props.placeholder.replace(/'/g, '\\\''); const placeholder = this.props.placeholder.replace(/'/g, '\\\'');
this.editorRef.current.style.setProperty("--placeholder", `'${placeholder}'`); this.editorRef.current.style.setProperty("--placeholder", `'${placeholder}'`);
this.editorRef.current.classList.add("mx_BasicMessageComposer_inputEmpty"); this.editorRef.current.classList.add("mx_BasicMessageComposer_inputEmpty");
} }
private hidePlaceholder() { private hidePlaceholder(): void {
this.editorRef.current.classList.remove("mx_BasicMessageComposer_inputEmpty"); this.editorRef.current.classList.remove("mx_BasicMessageComposer_inputEmpty");
this.editorRef.current.style.removeProperty("--placeholder"); this.editorRef.current.style.removeProperty("--placeholder");
} }
private onCompositionStart = () => { private onCompositionStart = (): void => {
this.isIMEComposing = true; this.isIMEComposing = true;
// even if the model is empty, the composition text shouldn't be mixed with the placeholder // even if the model is empty, the composition text shouldn't be mixed with the placeholder
this.hidePlaceholder(); this.hidePlaceholder();
}; };
private onCompositionEnd = () => { private onCompositionEnd = (): void => {
this.isIMEComposing = false; this.isIMEComposing = false;
// some browsers (Chrome) don't fire an input event after ending a composition, // some browsers (Chrome) don't fire an input event after ending a composition,
// so trigger a model update after the composition is done by calling the input handler. // so trigger a model update after the composition is done by calling the input handler.
@ -271,14 +263,14 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
} }
}; };
isComposing(event: React.KeyboardEvent) { public isComposing(event: React.KeyboardEvent): boolean {
// checking the event.isComposing flag just in case any browser out there // checking the event.isComposing flag just in case any browser out there
// emits events related to the composition after compositionend // emits events related to the composition after compositionend
// has been fired // has been fired
return !!(this.isIMEComposing || (event.nativeEvent && event.nativeEvent.isComposing)); return !!(this.isIMEComposing || (event.nativeEvent && event.nativeEvent.isComposing));
} }
private onCutCopy = (event: ClipboardEvent, type: string) => { private onCutCopy = (event: ClipboardEvent, type: string): void => {
const selection = document.getSelection(); const selection = document.getSelection();
const text = selection.toString(); const text = selection.toString();
if (text) { if (text) {
@ -296,15 +288,15 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
} }
}; };
private onCopy = (event: ClipboardEvent) => { private onCopy = (event: ClipboardEvent): void => {
this.onCutCopy(event, "copy"); this.onCutCopy(event, "copy");
}; };
private onCut = (event: ClipboardEvent) => { private onCut = (event: ClipboardEvent): void => {
this.onCutCopy(event, "cut"); this.onCutCopy(event, "cut");
}; };
private onPaste = (event: ClipboardEvent<HTMLDivElement>) => { private onPaste = (event: ClipboardEvent<HTMLDivElement>): boolean => {
event.preventDefault(); // we always handle the paste ourselves event.preventDefault(); // we always handle the paste ourselves
if (this.props.onPaste && this.props.onPaste(event, this.props.model)) { if (this.props.onPaste && this.props.onPaste(event, this.props.model)) {
// to prevent double handling, allow props.onPaste to skip internal onPaste // to prevent double handling, allow props.onPaste to skip internal onPaste
@ -328,7 +320,7 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
replaceRangeAndMoveCaret(range, parts); replaceRangeAndMoveCaret(range, parts);
}; };
private onInput = (event: Partial<InputEvent>) => { private onInput = (event: Partial<InputEvent>): void => {
// ignore any input while doing IME compositions // ignore any input while doing IME compositions
if (this.isIMEComposing) { if (this.isIMEComposing) {
return; return;
@ -339,7 +331,7 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
this.props.model.update(text, event.inputType, caret); this.props.model.update(text, event.inputType, caret);
}; };
private insertText(textToInsert: string, inputType = "insertText") { private insertText(textToInsert: string, inputType = "insertText"): void {
const sel = document.getSelection(); const sel = document.getSelection();
const { caret, text } = getCaretOffsetAndText(this.editorRef.current, sel); const { caret, text } = getCaretOffsetAndText(this.editorRef.current, sel);
const newText = text.substr(0, caret.offset) + textToInsert + text.substr(caret.offset); const newText = text.substr(0, caret.offset) + textToInsert + text.substr(caret.offset);
@ -353,14 +345,14 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
// we don't need to. But if the user is navigating the caret without input // we don't need to. But if the user is navigating the caret without input
// we need to recalculate it, to be able to know where to insert content after // we need to recalculate it, to be able to know where to insert content after
// losing focus // losing focus
private setLastCaretFromPosition(position: DocumentPosition) { private setLastCaretFromPosition(position: DocumentPosition): void {
const { model } = this.props; const { model } = this.props;
this._isCaretAtEnd = position.isAtEnd(model); this._isCaretAtEnd = position.isAtEnd(model);
this.lastCaret = position.asOffset(model); this.lastCaret = position.asOffset(model);
this.lastSelection = cloneSelection(document.getSelection()); this.lastSelection = cloneSelection(document.getSelection());
} }
private refreshLastCaretIfNeeded() { private refreshLastCaretIfNeeded(): DocumentOffset {
// XXX: needed when going up and down in editing messages ... not sure why yet // XXX: needed when going up and down in editing messages ... not sure why yet
// because the editors should stop doing this when when blurred ... // because the editors should stop doing this when when blurred ...
// maybe it's on focus and the _editorRef isn't available yet or something. // maybe it's on focus and the _editorRef isn't available yet or something.
@ -377,38 +369,38 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
return this.lastCaret; return this.lastCaret;
} }
clearUndoHistory() { public clearUndoHistory(): void {
this.historyManager.clear(); this.historyManager.clear();
} }
getCaret() { public getCaret(): DocumentOffset {
return this.lastCaret; return this.lastCaret;
} }
isSelectionCollapsed() { public isSelectionCollapsed(): boolean {
return !this.lastSelection || this.lastSelection.isCollapsed; return !this.lastSelection || this.lastSelection.isCollapsed;
} }
isCaretAtStart() { public isCaretAtStart(): boolean {
return this.getCaret().offset === 0; return this.getCaret().offset === 0;
} }
isCaretAtEnd() { public isCaretAtEnd(): boolean {
return this._isCaretAtEnd; return this._isCaretAtEnd;
} }
private onBlur = () => { private onBlur = (): void => {
document.removeEventListener("selectionchange", this.onSelectionChange); document.removeEventListener("selectionchange", this.onSelectionChange);
}; };
private onFocus = () => { private onFocus = (): void => {
document.addEventListener("selectionchange", this.onSelectionChange); document.addEventListener("selectionchange", this.onSelectionChange);
// force to recalculate // force to recalculate
this.lastSelection = null; this.lastSelection = null;
this.refreshLastCaretIfNeeded(); this.refreshLastCaretIfNeeded();
}; };
private onSelectionChange = () => { private onSelectionChange = (): void => {
const { isEmpty } = this.props.model; const { isEmpty } = this.props.model;
this.refreshLastCaretIfNeeded(); this.refreshLastCaretIfNeeded();
@ -427,7 +419,7 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
} }
}; };
private onKeyDown = (event: React.KeyboardEvent) => { private onKeyDown = (event: React.KeyboardEvent): void => {
const model = this.props.model; const model = this.props.model;
let handled = false; let handled = false;
const action = getKeyBindingsManager().getMessageComposerAction(event); const action = getKeyBindingsManager().getMessageComposerAction(event);
@ -523,7 +515,7 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
} }
}; };
private async tabCompleteName() { private async tabCompleteName(): Promise<void> {
try { try {
await new Promise<void>(resolve => this.setState({ showVisualBell: false }, resolve)); await new Promise<void>(resolve => this.setState({ showVisualBell: false }, resolve));
const { model } = this.props; const { model } = this.props;
@ -557,27 +549,27 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
} }
} }
isModified() { public isModified(): boolean {
return this.modifiedFlag; return this.modifiedFlag;
} }
private onAutoCompleteConfirm = (completion: ICompletion) => { private onAutoCompleteConfirm = (completion: ICompletion): void => {
this.modifiedFlag = true; this.modifiedFlag = true;
this.props.model.autoComplete.onComponentConfirm(completion); this.props.model.autoComplete.onComponentConfirm(completion);
}; };
private onAutoCompleteSelectionChange = (completion: ICompletion, completionIndex: number) => { private onAutoCompleteSelectionChange = (completion: ICompletion, completionIndex: number): void => {
this.modifiedFlag = true; this.modifiedFlag = true;
this.props.model.autoComplete.onComponentSelectionChange(completion); this.props.model.autoComplete.onComponentSelectionChange(completion);
this.setState({ completionIndex }); this.setState({ completionIndex });
}; };
private configureEmoticonAutoReplace = () => { private configureEmoticonAutoReplace = (): void => {
const shouldReplace = SettingsStore.getValue('MessageComposerInput.autoReplaceEmoji'); const shouldReplace = SettingsStore.getValue('MessageComposerInput.autoReplaceEmoji');
this.props.model.setTransformCallback(shouldReplace ? this.replaceEmoticon : null); this.props.model.setTransformCallback(shouldReplace ? this.replaceEmoticon : null);
}; };
private configureShouldShowPillAvatar = () => { private configureShouldShowPillAvatar = (): void => {
const showPillAvatar = SettingsStore.getValue("Pill.shouldShowPillAvatar"); const showPillAvatar = SettingsStore.getValue("Pill.shouldShowPillAvatar");
this.setState({ showPillAvatar }); this.setState({ showPillAvatar });
}; };
@ -611,8 +603,8 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
this.editorRef.current.focus(); this.editorRef.current.focus();
} }
private getInitialCaretPosition() { private getInitialCaretPosition(): DocumentPosition {
let caretPosition; let caretPosition: DocumentPosition;
if (this.props.initialCaret) { if (this.props.initialCaret) {
// if restoring state from a previous editor, // if restoring state from a previous editor,
// restore caret position from the state // restore caret position from the state
@ -625,7 +617,7 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
return caretPosition; return caretPosition;
} }
private onFormatAction = (action: Formatting) => { private onFormatAction = (action: Formatting): void => {
const range = getRangeForSelection(this.editorRef.current, this.props.model, document.getSelection()); const range = getRangeForSelection(this.editorRef.current, this.props.model, document.getSelection());
// trim the range as we want it to exclude leading/trailing spaces // trim the range as we want it to exclude leading/trailing spaces
range.trim(); range.trim();
@ -680,9 +672,9 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
}); });
const shortcuts = { const shortcuts = {
bold: ctrlShortcutLabel("B"), [Formatting.Bold]: ctrlShortcutLabel("B"),
italics: ctrlShortcutLabel("I"), [Formatting.Italics]: ctrlShortcutLabel("I"),
quote: ctrlShortcutLabel(">"), [Formatting.Quote]: ctrlShortcutLabel(">"),
}; };
const { completionIndex } = this.state; const { completionIndex } = this.state;
@ -714,11 +706,11 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
</div>); </div>);
} }
focus() { public focus(): void {
this.editorRef.current.focus(); this.editorRef.current.focus();
} }
public insertMention(userId: string) { public insertMention(userId: string): void {
const { model } = this.props; const { model } = this.props;
const { partCreator } = model; const { partCreator } = model;
const member = this.props.room.getMember(userId); const member = this.props.room.getMember(userId);
@ -736,7 +728,7 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
this.focus(); this.focus();
} }
public insertQuotedMessage(event: MatrixEvent) { public insertQuotedMessage(event: MatrixEvent): void {
const { model } = this.props; const { model } = this.props;
const { partCreator } = model; const { partCreator } = model;
const quoteParts = parseEvent(event, partCreator, { isQuotedMessage: true }); const quoteParts = parseEvent(event, partCreator, { isQuotedMessage: true });
@ -751,7 +743,7 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
this.focus(); this.focus();
} }
public insertPlaintext(text: string) { public insertPlaintext(text: string): void {
const { model } = this.props; const { model } = this.props;
const { partCreator } = model; const { partCreator } = model;
const caret = this.getCaret(); const caret = this.getCaret();

View file

@ -1,6 +1,5 @@
/* /*
Copyright 2019 New Vector Ltd Copyright 2019 - 2021 The Matrix.org Foundation C.I.C.
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.
@ -14,37 +13,42 @@ 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 * as sdk from '../../../index'; import React, { createRef, KeyboardEvent } from 'react';
import classNames from 'classnames';
import { EventStatus, IContent, MatrixEvent } from 'matrix-js-sdk/src/models/event';
import { _t, _td } from '../../../languageHandler'; import { _t, _td } from '../../../languageHandler';
import PropTypes from 'prop-types';
import dis from '../../../dispatcher/dispatcher'; import dis from '../../../dispatcher/dispatcher';
import EditorModel from '../../../editor/model'; import EditorModel from '../../../editor/model';
import { getCaretOffsetAndText } from '../../../editor/dom'; import { getCaretOffsetAndText } from '../../../editor/dom';
import { htmlSerializeIfNeeded, textSerialize, containsEmote, stripEmoteCommand } from '../../../editor/serialize'; import { htmlSerializeIfNeeded, textSerialize, containsEmote, stripEmoteCommand } from '../../../editor/serialize';
import { findEditableEvent } from '../../../utils/EventUtils'; import { findEditableEvent } from '../../../utils/EventUtils';
import { parseEvent } from '../../../editor/deserialize'; import { parseEvent } from '../../../editor/deserialize';
import { CommandPartCreator } from '../../../editor/parts'; import { CommandPartCreator, Part, PartCreator } from '../../../editor/parts';
import EditorStateTransfer from '../../../utils/EditorStateTransfer'; import EditorStateTransfer from '../../../utils/EditorStateTransfer';
import classNames from 'classnames';
import { EventStatus } from 'matrix-js-sdk/src/models/event';
import BasicMessageComposer from "./BasicMessageComposer"; import BasicMessageComposer from "./BasicMessageComposer";
import MatrixClientContext from "../../../contexts/MatrixClientContext"; import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { CommandCategories, getCommand } from '../../../SlashCommands'; import { Command, CommandCategories, getCommand } from '../../../SlashCommands';
import { Action } from "../../../dispatcher/actions"; import { Action } from "../../../dispatcher/actions";
import CountlyAnalytics from "../../../CountlyAnalytics"; import CountlyAnalytics from "../../../CountlyAnalytics";
import { getKeyBindingsManager, MessageComposerAction } from '../../../KeyBindingsManager'; import { getKeyBindingsManager, MessageComposerAction } from '../../../KeyBindingsManager';
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import SendHistoryManager from '../../../SendHistoryManager'; import SendHistoryManager from '../../../SendHistoryManager';
import Modal from '../../../Modal'; import Modal from '../../../Modal';
import { MsgType } from 'matrix-js-sdk/src/@types/event';
import { Room } from 'matrix-js-sdk/src/models/room';
import ErrorDialog from "../dialogs/ErrorDialog";
import QuestionDialog from "../dialogs/QuestionDialog";
import { ActionPayload } from "../../../dispatcher/payloads";
import AccessibleButton from '../elements/AccessibleButton';
function _isReply(mxEvent) { function eventIsReply(mxEvent: MatrixEvent): boolean {
const relatesTo = mxEvent.getContent()["m.relates_to"]; const relatesTo = mxEvent.getContent()["m.relates_to"];
const isReply = !!(relatesTo && relatesTo["m.in_reply_to"]); return !!(relatesTo && relatesTo["m.in_reply_to"]);
return isReply;
} }
function getHtmlReplyFallback(mxEvent) { function getHtmlReplyFallback(mxEvent: MatrixEvent): string {
const html = mxEvent.getContent().formatted_body; const html = mxEvent.getContent().formatted_body;
if (!html) { if (!html) {
return ""; return "";
@ -54,7 +58,7 @@ function getHtmlReplyFallback(mxEvent) {
return (mxReply && mxReply.outerHTML) || ""; return (mxReply && mxReply.outerHTML) || "";
} }
function getTextReplyFallback(mxEvent) { function getTextReplyFallback(mxEvent: MatrixEvent): string {
const body = mxEvent.getContent().body; const body = mxEvent.getContent().body;
const lines = body.split("\n").map(l => l.trim()); const lines = body.split("\n").map(l => l.trim());
if (lines.length > 2 && lines[0].startsWith("> ") && lines[1].length === 0) { if (lines.length > 2 && lines[0].startsWith("> ") && lines[1].length === 0) {
@ -63,12 +67,12 @@ function getTextReplyFallback(mxEvent) {
return ""; return "";
} }
function createEditContent(model, editedEvent) { function createEditContent(model: EditorModel, editedEvent: MatrixEvent): IContent {
const isEmote = containsEmote(model); const isEmote = containsEmote(model);
if (isEmote) { if (isEmote) {
model = stripEmoteCommand(model); model = stripEmoteCommand(model);
} }
const isReply = _isReply(editedEvent); const isReply = eventIsReply(editedEvent);
let plainPrefix = ""; let plainPrefix = "";
let htmlPrefix = ""; let htmlPrefix = "";
@ -79,11 +83,11 @@ function createEditContent(model, editedEvent) {
const body = textSerialize(model); const body = textSerialize(model);
const newContent = { const newContent: IContent = {
"msgtype": isEmote ? "m.emote" : "m.text", "msgtype": isEmote ? MsgType.Emote : MsgType.Text,
"body": body, "body": body,
}; };
const contentBody = { const contentBody: IContent = {
msgtype: newContent.msgtype, msgtype: newContent.msgtype,
body: `${plainPrefix} * ${body}`, body: `${plainPrefix} * ${body}`,
}; };
@ -105,55 +109,60 @@ function createEditContent(model, editedEvent) {
}, contentBody); }, contentBody);
} }
interface IProps {
editState: EditorStateTransfer;
className?: string;
}
interface IState {
saveDisabled: boolean;
}
@replaceableComponent("views.rooms.EditMessageComposer") @replaceableComponent("views.rooms.EditMessageComposer")
export default class EditMessageComposer extends React.Component { export default class EditMessageComposer extends React.Component<IProps, IState> {
static propTypes = {
// the message event being edited
editState: PropTypes.instanceOf(EditorStateTransfer).isRequired,
};
static contextType = MatrixClientContext; static contextType = MatrixClientContext;
context!: React.ContextType<typeof MatrixClientContext>;
constructor(props, context) { private readonly editorRef = createRef<BasicMessageComposer>();
super(props, context); private readonly dispatcherRef: string;
this.model = null; private model: EditorModel = null;
this._editorRef = null;
constructor(props: IProps, context: React.ContextType<typeof MatrixClientContext>) {
super(props);
this.context = context; // otherwise React will only set it prior to render due to type def above
this.state = { this.state = {
saveDisabled: true, saveDisabled: true,
}; };
this._createEditorModel();
window.addEventListener("beforeunload", this._saveStoredEditorState); this.createEditorModel();
window.addEventListener("beforeunload", this.saveStoredEditorState);
this.dispatcherRef = dis.register(this.onAction); this.dispatcherRef = dis.register(this.onAction);
} }
_setEditorRef = ref => { private getRoom(): Room {
this._editorRef = ref;
};
_getRoom() {
return this.context.getRoom(this.props.editState.getEvent().getRoomId()); return this.context.getRoom(this.props.editState.getEvent().getRoomId());
} }
_onKeyDown = (event) => { private onKeyDown = (event: KeyboardEvent): void => {
// ignore any keypress while doing IME compositions // ignore any keypress while doing IME compositions
if (this._editorRef.isComposing(event)) { if (this.editorRef.current?.isComposing(event)) {
return; return;
} }
const action = getKeyBindingsManager().getMessageComposerAction(event); const action = getKeyBindingsManager().getMessageComposerAction(event);
switch (action) { switch (action) {
case MessageComposerAction.Send: case MessageComposerAction.Send:
this._sendEdit(); this.sendEdit();
event.preventDefault(); event.preventDefault();
break; break;
case MessageComposerAction.CancelEditing: case MessageComposerAction.CancelEditing:
this._cancelEdit(); this.cancelEdit();
break; break;
case MessageComposerAction.EditPrevMessage: { case MessageComposerAction.EditPrevMessage: {
if (this._editorRef.isModified() || !this._editorRef.isCaretAtStart()) { if (this.editorRef.current?.isModified() || !this.editorRef.current?.isCaretAtStart()) {
return; return;
} }
const previousEvent = findEditableEvent(this._getRoom(), false, const previousEvent = findEditableEvent(this.getRoom(), false,
this.props.editState.getEvent().getId()); this.props.editState.getEvent().getId());
if (previousEvent) { if (previousEvent) {
dis.dispatch({ action: 'edit_event', event: previousEvent }); dis.dispatch({ action: 'edit_event', event: previousEvent });
@ -162,14 +171,14 @@ export default class EditMessageComposer extends React.Component {
break; break;
} }
case MessageComposerAction.EditNextMessage: { case MessageComposerAction.EditNextMessage: {
if (this._editorRef.isModified() || !this._editorRef.isCaretAtEnd()) { if (this.editorRef.current?.isModified() || !this.editorRef.current?.isCaretAtEnd()) {
return; return;
} }
const nextEvent = findEditableEvent(this._getRoom(), true, this.props.editState.getEvent().getId()); const nextEvent = findEditableEvent(this.getRoom(), true, this.props.editState.getEvent().getId());
if (nextEvent) { if (nextEvent) {
dis.dispatch({ action: 'edit_event', event: nextEvent }); dis.dispatch({ action: 'edit_event', event: nextEvent });
} else { } else {
this._clearStoredEditorState(); this.clearStoredEditorState();
dis.dispatch({ action: 'edit_event', event: null }); dis.dispatch({ action: 'edit_event', event: null });
dis.fire(Action.FocusComposer); dis.fire(Action.FocusComposer);
} }
@ -177,32 +186,32 @@ export default class EditMessageComposer extends React.Component {
break; break;
} }
} }
};
private get editorRoomKey(): string {
return `mx_edit_room_${this.getRoom().roomId}`;
} }
get _editorRoomKey() { private get editorStateKey(): string {
return `mx_edit_room_${this._getRoom().roomId}`;
}
get _editorStateKey() {
return `mx_edit_state_${this.props.editState.getEvent().getId()}`; return `mx_edit_state_${this.props.editState.getEvent().getId()}`;
} }
_cancelEdit = () => { private cancelEdit = (): void => {
this._clearStoredEditorState(); this.clearStoredEditorState();
dis.dispatch({ action: "edit_event", event: null }); dis.dispatch({ action: "edit_event", event: null });
dis.fire(Action.FocusComposer); dis.fire(Action.FocusComposer);
};
private get shouldSaveStoredEditorState(): boolean {
return localStorage.getItem(this.editorRoomKey) !== null;
} }
get _shouldSaveStoredEditorState() { private restoreStoredEditorState(partCreator: PartCreator): Part[] {
return localStorage.getItem(this._editorRoomKey) !== null; const json = localStorage.getItem(this.editorStateKey);
}
_restoreStoredEditorState(partCreator) {
const json = localStorage.getItem(this._editorStateKey);
if (json) { if (json) {
try { try {
const { parts: serializedParts } = JSON.parse(json); const { parts: serializedParts } = JSON.parse(json);
const parts = serializedParts.map(p => partCreator.deserializePart(p)); const parts: Part[] = serializedParts.map(p => partCreator.deserializePart(p));
return parts; return parts;
} catch (e) { } catch (e) {
console.error("Error parsing editing state: ", e); console.error("Error parsing editing state: ", e);
@ -210,25 +219,25 @@ export default class EditMessageComposer extends React.Component {
} }
} }
_clearStoredEditorState() { private clearStoredEditorState(): void {
localStorage.removeItem(this._editorRoomKey); localStorage.removeItem(this.editorRoomKey);
localStorage.removeItem(this._editorStateKey); localStorage.removeItem(this.editorStateKey);
} }
_clearPreviousEdit() { private clearPreviousEdit(): void {
if (localStorage.getItem(this._editorRoomKey)) { if (localStorage.getItem(this.editorRoomKey)) {
localStorage.removeItem(`mx_edit_state_${localStorage.getItem(this._editorRoomKey)}`); localStorage.removeItem(`mx_edit_state_${localStorage.getItem(this.editorRoomKey)}`);
} }
} }
_saveStoredEditorState() { private saveStoredEditorState(): void {
const item = SendHistoryManager.createItem(this.model); const item = SendHistoryManager.createItem(this.model);
this._clearPreviousEdit(); this.clearPreviousEdit();
localStorage.setItem(this._editorRoomKey, this.props.editState.getEvent().getId()); localStorage.setItem(this.editorRoomKey, this.props.editState.getEvent().getId());
localStorage.setItem(this._editorStateKey, JSON.stringify(item)); localStorage.setItem(this.editorStateKey, JSON.stringify(item));
} }
_isSlashCommand() { private isSlashCommand(): boolean {
const parts = this.model.parts; const parts = this.model.parts;
const firstPart = parts[0]; const firstPart = parts[0];
if (firstPart) { if (firstPart) {
@ -244,10 +253,10 @@ export default class EditMessageComposer extends React.Component {
return false; return false;
} }
_isContentModified(newContent) { private isContentModified(newContent: IContent): boolean {
// if nothing has changed then bail // if nothing has changed then bail
const oldContent = this.props.editState.getEvent().getContent(); const oldContent = this.props.editState.getEvent().getContent();
if (!this._editorRef.isModified() || if (!this.editorRef.current?.isModified() ||
(oldContent["msgtype"] === newContent["msgtype"] && oldContent["body"] === newContent["body"] && (oldContent["msgtype"] === newContent["msgtype"] && oldContent["body"] === newContent["body"] &&
oldContent["format"] === newContent["format"] && oldContent["format"] === newContent["format"] &&
oldContent["formatted_body"] === newContent["formatted_body"])) { oldContent["formatted_body"] === newContent["formatted_body"])) {
@ -256,7 +265,7 @@ export default class EditMessageComposer extends React.Component {
return true; return true;
} }
_getSlashCommand() { private getSlashCommand(): [Command, string, string] {
const commandText = this.model.parts.reduce((text, part) => { const commandText = this.model.parts.reduce((text, part) => {
// use mxid to textify user pills in a command // use mxid to textify user pills in a command
if (part.type === "user-pill") { if (part.type === "user-pill") {
@ -268,7 +277,7 @@ export default class EditMessageComposer extends React.Component {
return [cmd, args, commandText]; return [cmd, args, commandText];
} }
async _runSlashCommand(cmd, args, roomId) { private async runSlashCommand(cmd: Command, args: string, roomId: string): Promise<void> {
const result = cmd.run(roomId, args); const result = cmd.run(roomId, args);
let messageContent; let messageContent;
let error = result.error; let error = result.error;
@ -285,7 +294,6 @@ export default class EditMessageComposer extends React.Component {
} }
if (error) { if (error) {
console.error("Command failure: %s", error); console.error("Command failure: %s", error);
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
// assume the error is a server error when the command is async // assume the error is a server error when the command is async
const isServerError = !!result.promise; const isServerError = !!result.promise;
const title = isServerError ? _td("Server error") : _td("Command error"); const title = isServerError ? _td("Server error") : _td("Command error");
@ -309,7 +317,7 @@ export default class EditMessageComposer extends React.Component {
} }
} }
_sendEdit = async () => { private sendEdit = async (): Promise<void> => {
const startTime = CountlyAnalytics.getTimestamp(); const startTime = CountlyAnalytics.getTimestamp();
const editedEvent = this.props.editState.getEvent(); const editedEvent = this.props.editState.getEvent();
const editContent = createEditContent(this.model, editedEvent); const editContent = createEditContent(this.model, editedEvent);
@ -318,20 +326,19 @@ export default class EditMessageComposer extends React.Component {
let shouldSend = true; let shouldSend = true;
// If content is modified then send an updated event into the room // If content is modified then send an updated event into the room
if (this._isContentModified(newContent)) { if (this.isContentModified(newContent)) {
const roomId = editedEvent.getRoomId(); const roomId = editedEvent.getRoomId();
if (!containsEmote(this.model) && this._isSlashCommand()) { if (!containsEmote(this.model) && this.isSlashCommand()) {
const [cmd, args, commandText] = this._getSlashCommand(); const [cmd, args, commandText] = this.getSlashCommand();
if (cmd) { if (cmd) {
if (cmd.category === CommandCategories.messages) { if (cmd.category === CommandCategories.messages) {
editContent["m.new_content"] = await this._runSlashCommand(cmd, args, roomId); editContent["m.new_content"] = await this.runSlashCommand(cmd, args, roomId);
} else { } else {
this._runSlashCommand(cmd, args, roomId); this.runSlashCommand(cmd, args, roomId);
shouldSend = false; shouldSend = false;
} }
} else { } else {
// ask the user if their unknown command should be sent as a message // ask the user if their unknown command should be sent as a message
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
const { finished } = Modal.createTrackedDialog("Unknown command", "", QuestionDialog, { const { finished } = Modal.createTrackedDialog("Unknown command", "", QuestionDialog, {
title: _t("Unknown Command"), title: _t("Unknown Command"),
description: <div> description: <div>
@ -358,9 +365,9 @@ export default class EditMessageComposer extends React.Component {
} }
} }
if (shouldSend) { if (shouldSend) {
this._cancelPreviousPendingEdit(); this.cancelPreviousPendingEdit();
const prom = this.context.sendMessage(roomId, editContent); const prom = this.context.sendMessage(roomId, editContent);
this._clearStoredEditorState(); this.clearStoredEditorState();
dis.dispatch({ action: "message_sent" }); dis.dispatch({ action: "message_sent" });
CountlyAnalytics.instance.trackSendMessage(startTime, prom, roomId, true, false, editContent); CountlyAnalytics.instance.trackSendMessage(startTime, prom, roomId, true, false, editContent);
} }
@ -371,7 +378,7 @@ export default class EditMessageComposer extends React.Component {
dis.fire(Action.FocusComposer); dis.fire(Action.FocusComposer);
}; };
_cancelPreviousPendingEdit() { private cancelPreviousPendingEdit(): void {
const originalEvent = this.props.editState.getEvent(); const originalEvent = this.props.editState.getEvent();
const previousEdit = originalEvent.replacingEvent(); const previousEdit = originalEvent.replacingEvent();
if (previousEdit && ( if (previousEdit && (
@ -389,23 +396,23 @@ export default class EditMessageComposer extends React.Component {
const sel = document.getSelection(); const sel = document.getSelection();
let caret; let caret;
if (sel.focusNode) { if (sel.focusNode) {
caret = getCaretOffsetAndText(this._editorRef, sel).caret; caret = getCaretOffsetAndText(this.editorRef.current?.editorRef.current, sel).caret;
} }
const parts = this.model.serializeParts(); const parts = this.model.serializeParts();
// if caret is undefined because for some reason there isn't a valid selection, // if caret is undefined because for some reason there isn't a valid selection,
// then when mounting the editor again with the same editor state, // then when mounting the editor again with the same editor state,
// it will set the cursor at the end. // it will set the cursor at the end.
this.props.editState.setEditorState(caret, parts); this.props.editState.setEditorState(caret, parts);
window.removeEventListener("beforeunload", this._saveStoredEditorState); window.removeEventListener("beforeunload", this.saveStoredEditorState);
if (this._shouldSaveStoredEditorState) { if (this.shouldSaveStoredEditorState) {
this._saveStoredEditorState(); this.saveStoredEditorState();
} }
dis.unregister(this.dispatcherRef); dis.unregister(this.dispatcherRef);
} }
_createEditorModel() { private createEditorModel(): void {
const { editState } = this.props; const { editState } = this.props;
const room = this._getRoom(); const room = this.getRoom();
const partCreator = new CommandPartCreator(room, this.context); const partCreator = new CommandPartCreator(room, this.context);
let parts; let parts;
if (editState.hasEditorState()) { if (editState.hasEditorState()) {
@ -414,13 +421,13 @@ export default class EditMessageComposer extends React.Component {
parts = editState.getSerializedParts().map(p => partCreator.deserializePart(p)); parts = editState.getSerializedParts().map(p => partCreator.deserializePart(p));
} else { } else {
//otherwise, either restore serialized parts from localStorage or parse the body of the event //otherwise, either restore serialized parts from localStorage or parse the body of the event
parts = this._restoreStoredEditorState(partCreator) || parseEvent(editState.getEvent(), partCreator); parts = this.restoreStoredEditorState(partCreator) || parseEvent(editState.getEvent(), partCreator);
} }
this.model = new EditorModel(parts, partCreator); this.model = new EditorModel(parts, partCreator);
this._saveStoredEditorState(); this.saveStoredEditorState();
} }
_getInitialCaretPosition() { private getInitialCaretPosition(): CaretPosition {
const { editState } = this.props; const { editState } = this.props;
let caretPosition; let caretPosition;
if (editState.hasEditorState() && editState.getCaret()) { if (editState.hasEditorState() && editState.getCaret()) {
@ -435,8 +442,8 @@ export default class EditMessageComposer extends React.Component {
return caretPosition; return caretPosition;
} }
_onChange = () => { private onChange = (): void => {
if (!this.state.saveDisabled || !this._editorRef || !this._editorRef.isModified()) { if (!this.state.saveDisabled || !this.editorRef.current?.isModified()) {
return; return;
} }
@ -445,33 +452,34 @@ export default class EditMessageComposer extends React.Component {
}); });
}; };
onAction = payload => { private onAction = (payload: ActionPayload) => {
if (payload.action === "edit_composer_insert" && this._editorRef) { if (payload.action === "edit_composer_insert" && this.editorRef.current) {
if (payload.userId) { if (payload.userId) {
this._editorRef.insertMention(payload.userId); this.editorRef.current?.insertMention(payload.userId);
} else if (payload.event) { } else if (payload.event) {
this._editorRef.insertQuotedMessage(payload.event); this.editorRef.current?.insertQuotedMessage(payload.event);
} else if (payload.text) { } else if (payload.text) {
this._editorRef.insertPlaintext(payload.text); this.editorRef.current?.insertPlaintext(payload.text);
} }
} }
}; };
render() { render() {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); return (<div className={classNames("mx_EditMessageComposer", this.props.className)} onKeyDown={this.onKeyDown}>
return (<div className={classNames("mx_EditMessageComposer", this.props.className)} onKeyDown={this._onKeyDown}>
<BasicMessageComposer <BasicMessageComposer
ref={this._setEditorRef} ref={this.editorRef}
model={this.model} model={this.model}
room={this._getRoom()} room={this.getRoom()}
initialCaret={this.props.editState.getCaret()} initialCaret={this.props.editState.getCaret()}
label={_t("Edit message")} label={_t("Edit message")}
onChange={this._onChange} onChange={this.onChange}
/> />
<div className="mx_EditMessageComposer_buttons"> <div className="mx_EditMessageComposer_buttons">
<AccessibleButton kind="secondary" onClick={this._cancelEdit}>{_t("Cancel")}</AccessibleButton> <AccessibleButton kind="secondary" onClick={this.cancelEdit}>
<AccessibleButton kind="primary" onClick={this._sendEdit} disabled={this.state.saveDisabled}> { _t("Cancel") }
{_t("Save")} </AccessibleButton>
<AccessibleButton kind="primary" onClick={this.sendEdit} disabled={this.state.saveDisabled}>
{ _t("Save") }
</AccessibleButton> </AccessibleButton>
</div> </div>
</div>); </div>);

View file

@ -265,7 +265,7 @@ interface IProps {
showReactions?: boolean; showReactions?: boolean;
// which layout to use // which layout to use
layout: Layout; layout?: Layout;
// whether or not to show flair at all // whether or not to show flair at all
enableFlair?: boolean; enableFlair?: boolean;
@ -285,10 +285,10 @@ interface IProps {
permalinkCreator?: RoomPermalinkCreator; permalinkCreator?: RoomPermalinkCreator;
// Symbol of the root node // Symbol of the root node
as?: string as?: string;
// whether or not to always show timestamps // whether or not to always show timestamps
alwaysShowTimestamps?: boolean alwaysShowTimestamps?: boolean;
} }
interface IState { interface IState {
@ -319,6 +319,7 @@ export default class EventTile extends React.Component<IProps, IState> {
static defaultProps = { static defaultProps = {
// no-op function because onHeightChanged is optional yet some sub-components assume its existence // no-op function because onHeightChanged is optional yet some sub-components assume its existence
onHeightChanged: function() {}, onHeightChanged: function() {},
layout: Layout.Group,
}; };
static contextType = MatrixClientContext; static contextType = MatrixClientContext;

View file

@ -22,7 +22,6 @@ import { _t } from '../../../languageHandler';
import SdkConfig from '../../../SdkConfig'; import SdkConfig from '../../../SdkConfig';
import dis from '../../../dispatcher/dispatcher'; import dis from '../../../dispatcher/dispatcher';
import { isValid3pidInvite } from "../../../RoomInvite"; import { isValid3pidInvite } from "../../../RoomInvite";
import rateLimitedFunction from "../../../ratelimitedfunc";
import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { CommunityPrototypeStore } from "../../../stores/CommunityPrototypeStore"; import { CommunityPrototypeStore } from "../../../stores/CommunityPrototypeStore";
import BaseCard from "../right_panel/BaseCard"; import BaseCard from "../right_panel/BaseCard";
@ -43,6 +42,7 @@ import AccessibleButton from '../elements/AccessibleButton';
import EntityTile from "./EntityTile"; 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';
const INITIAL_LOAD_NUM_MEMBERS = 30; const INITIAL_LOAD_NUM_MEMBERS = 30;
const INITIAL_LOAD_NUM_INVITED = 5; const INITIAL_LOAD_NUM_INVITED = 5;
@ -133,7 +133,7 @@ export default class MemberList extends React.Component<IProps, IState> {
} }
// cancel any pending calls to the rate_limited_funcs // cancel any pending calls to the rate_limited_funcs
this.updateList.cancelPendingCall(); this.updateList.cancel();
} }
/** /**
@ -237,9 +237,9 @@ export default class MemberList extends React.Component<IProps, IState> {
if (this.canInvite !== this.state.canInvite) this.setState({ canInvite: this.canInvite }); if (this.canInvite !== this.state.canInvite) this.setState({ canInvite: this.canInvite });
}; };
private updateList = rateLimitedFunction(() => { private updateList = throttle(() => {
this.updateListNow(); this.updateListNow();
}, 500); }, 500, { leading: true, trailing: true });
private updateListNow(): void { private updateListNow(): void {
const members = this.roomMembers(); const members = this.roomMembers();

View file

@ -43,6 +43,7 @@ import { E2EStatus } from '../../../utils/ShieldUtils';
import SendMessageComposer from "./SendMessageComposer"; import SendMessageComposer from "./SendMessageComposer";
import { ComposerInsertPayload } from "../../../dispatcher/payloads/ComposerInsertPayload"; import { ComposerInsertPayload } from "../../../dispatcher/payloads/ComposerInsertPayload";
import { Action } from "../../../dispatcher/actions"; import { Action } from "../../../dispatcher/actions";
import EditorModel from "../../../editor/model";
interface IComposerAvatarProps { interface IComposerAvatarProps {
me: object; me: object;
@ -318,14 +319,14 @@ export default class MessageComposer extends React.Component<IProps, IState> {
} }
}; };
addEmoji(emoji: string) { private addEmoji(emoji: string) {
dis.dispatch<ComposerInsertPayload>({ dis.dispatch<ComposerInsertPayload>({
action: Action.ComposerInsert, action: Action.ComposerInsert,
text: emoji, text: emoji,
}); });
} }
sendMessage = async () => { private sendMessage = async () => {
if (this.state.haveRecording && this.voiceRecordingButton) { if (this.state.haveRecording && this.voiceRecordingButton) {
// There shouldn't be any text message to send when a voice recording is active, so // There shouldn't be any text message to send when a voice recording is active, so
// just send out the voice recording. // just send out the voice recording.
@ -333,11 +334,10 @@ export default class MessageComposer extends React.Component<IProps, IState> {
return; return;
} }
// XXX: Private function access this.messageComposerInput.sendMessage();
this.messageComposerInput._sendMessage();
}; };
onChange = (model) => { private onChange = (model: EditorModel) => {
this.setState({ this.setState({
isComposerEmpty: model.isEmpty, isComposerEmpty: model.isEmpty,
}); });

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,21 +14,35 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React from 'react'; import React, { createRef } from 'react';
import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler';
import classNames from 'classnames'; import classNames from 'classnames';
import { _t } from '../../../languageHandler';
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
@replaceableComponent("views.rooms.MessageComposerFormatBar") export enum Formatting {
export default class MessageComposerFormatBar extends React.PureComponent { Bold = "bold",
static propTypes = { Italics = "italics",
onAction: PropTypes.func.isRequired, Strikethrough = "strikethrough",
shortcuts: PropTypes.object.isRequired, Code = "code",
}; Quote = "quote",
}
constructor(props) { interface IProps {
shortcuts: Partial<Record<Formatting, string>>;
onAction(action: Formatting): void;
}
interface IState {
visible: boolean;
}
@replaceableComponent("views.rooms.MessageComposerFormatBar")
export default class MessageComposerFormatBar extends React.PureComponent<IProps, IState> {
private readonly formatBarRef = createRef<HTMLDivElement>();
constructor(props: IProps) {
super(props); super(props);
this.state = { visible: false }; this.state = { visible: false };
} }
@ -37,49 +51,53 @@ export default class MessageComposerFormatBar extends React.PureComponent {
const classes = classNames("mx_MessageComposerFormatBar", { const classes = classNames("mx_MessageComposerFormatBar", {
"mx_MessageComposerFormatBar_shown": this.state.visible, "mx_MessageComposerFormatBar_shown": this.state.visible,
}); });
return (<div className={classes} ref={ref => this._formatBarRef = ref}> return (<div className={classes} ref={this.formatBarRef}>
<FormatButton label={_t("Bold")} onClick={() => this.props.onAction("bold")} icon="Bold" shortcut={this.props.shortcuts.bold} visible={this.state.visible} /> <FormatButton label={_t("Bold")} onClick={() => this.props.onAction(Formatting.Bold)} icon="Bold" shortcut={this.props.shortcuts.bold} visible={this.state.visible} />
<FormatButton label={_t("Italics")} onClick={() => this.props.onAction("italics")} icon="Italic" shortcut={this.props.shortcuts.italics} visible={this.state.visible} /> <FormatButton label={_t("Italics")} onClick={() => this.props.onAction(Formatting.Italics)} icon="Italic" shortcut={this.props.shortcuts.italics} visible={this.state.visible} />
<FormatButton label={_t("Strikethrough")} onClick={() => this.props.onAction("strikethrough")} icon="Strikethrough" visible={this.state.visible} /> <FormatButton label={_t("Strikethrough")} onClick={() => this.props.onAction(Formatting.Strikethrough)} icon="Strikethrough" visible={this.state.visible} />
<FormatButton label={_t("Code block")} onClick={() => this.props.onAction("code")} icon="Code" visible={this.state.visible} /> <FormatButton label={_t("Code block")} onClick={() => this.props.onAction(Formatting.Code)} icon="Code" visible={this.state.visible} />
<FormatButton label={_t("Quote")} onClick={() => this.props.onAction("quote")} icon="Quote" shortcut={this.props.shortcuts.quote} visible={this.state.visible} /> <FormatButton label={_t("Quote")} onClick={() => this.props.onAction(Formatting.Quote)} icon="Quote" shortcut={this.props.shortcuts.quote} visible={this.state.visible} />
</div>); </div>);
} }
showAt(selectionRect) { public showAt(selectionRect: DOMRect): void {
if (!this.formatBarRef.current) return;
this.setState({ visible: true }); this.setState({ visible: true });
const parentRect = this._formatBarRef.parentElement.getBoundingClientRect(); const parentRect = this.formatBarRef.current.parentElement.getBoundingClientRect();
this._formatBarRef.style.left = `${selectionRect.left - parentRect.left}px`; this.formatBarRef.current.style.left = `${selectionRect.left - parentRect.left}px`;
// 12 is half the height of the bar (e.g. to center it) and 16 is an offset that felt ok. // 12 is half the height of the bar (e.g. to center it) and 16 is an offset that felt ok.
this._formatBarRef.style.top = `${selectionRect.top - parentRect.top - 16 - 12}px`; this.formatBarRef.current.style.top = `${selectionRect.top - parentRect.top - 16 - 12}px`;
} }
hide() { public hide(): void {
this.setState({ visible: false }); this.setState({ visible: false });
} }
} }
class FormatButton extends React.PureComponent { interface IFormatButtonProps {
static propTypes = { label: string;
label: PropTypes.string.isRequired, icon: string;
onClick: PropTypes.func.isRequired, shortcut?: string;
icon: PropTypes.string.isRequired, visible?: boolean;
shortcut: PropTypes.string, onClick(): void;
visible: PropTypes.bool, }
};
class FormatButton extends React.PureComponent<IFormatButtonProps> {
render() { render() {
const className = `mx_MessageComposerFormatBar_button mx_MessageComposerFormatBar_buttonIcon${this.props.icon}`; const className = `mx_MessageComposerFormatBar_button mx_MessageComposerFormatBar_buttonIcon${this.props.icon}`;
let shortcut; let shortcut;
if (this.props.shortcut) { if (this.props.shortcut) {
shortcut = <div className="mx_MessageComposerFormatBar_tooltipShortcut">{this.props.shortcut}</div>; shortcut = <div className="mx_MessageComposerFormatBar_tooltipShortcut">
{ this.props.shortcut }
</div>;
} }
const tooltip = <div> const tooltip = <div>
<div className="mx_Tooltip_title"> <div className="mx_Tooltip_title">
{this.props.label} { this.props.label }
</div> </div>
<div className="mx_Tooltip_sub"> <div className="mx_Tooltip_sub">
{shortcut} { shortcut }
</div> </div>
</div>; </div>;

View file

@ -1,6 +1,6 @@
/* /*
Copyright 2015, 2016 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
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.
@ -16,11 +16,9 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames'; import classNames from 'classnames';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import { MatrixClientPeg } from '../../../MatrixClientPeg'; import { MatrixClientPeg } from '../../../MatrixClientPeg';
import RateLimitedFunc from '../../../ratelimitedfunc';
import SettingsStore from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
import RoomHeaderButtons from '../right_panel/RoomHeaderButtons'; import RoomHeaderButtons from '../right_panel/RoomHeaderButtons';
@ -31,54 +29,65 @@ import RoomTopic from "../elements/RoomTopic";
import RoomName from "../elements/RoomName"; import RoomName from "../elements/RoomName";
import { PlaceCallType } from "../../../CallHandler"; import { PlaceCallType } from "../../../CallHandler";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import { throttle } from 'lodash';
import { MatrixEvent, Room, RoomState } from 'matrix-js-sdk/src';
import { E2EStatus } from '../../../utils/ShieldUtils';
import { IOOBData } from '../../../stores/ThreepidInviteStore';
import { SearchScope } from './SearchBar';
export interface ISearchInfo {
searchTerm: string;
searchScope: SearchScope;
searchCount: number;
}
interface IProps {
room: Room;
oobData?: IOOBData;
inRoom: boolean;
onSettingsClick: () => void;
onSearchClick: () => void;
onForgetClick: () => void;
onCallPlaced: (type: PlaceCallType) => void;
onAppsClick: () => void;
e2eStatus: E2EStatus;
appsShown: boolean;
searchInfo: ISearchInfo;
}
@replaceableComponent("views.rooms.RoomHeader") @replaceableComponent("views.rooms.RoomHeader")
export default class RoomHeader extends React.Component { export default class RoomHeader extends React.Component<IProps> {
static propTypes = {
room: PropTypes.object,
oobData: PropTypes.object,
inRoom: PropTypes.bool,
onSettingsClick: PropTypes.func,
onSearchClick: PropTypes.func,
onLeaveClick: PropTypes.func,
e2eStatus: PropTypes.string,
onAppsClick: PropTypes.func,
appsShown: PropTypes.bool,
onCallPlaced: PropTypes.func, // (PlaceCallType) => void;
};
static defaultProps = { static defaultProps = {
editing: false, editing: false,
inRoom: false, inRoom: false,
}; };
componentDidMount() { public componentDidMount() {
const cli = MatrixClientPeg.get(); const cli = MatrixClientPeg.get();
cli.on("RoomState.events", this._onRoomStateEvents); cli.on("RoomState.events", this.onRoomStateEvents);
} }
componentWillUnmount() { public componentWillUnmount() {
const cli = MatrixClientPeg.get(); const cli = MatrixClientPeg.get();
if (cli) { if (cli) {
cli.removeListener("RoomState.events", this._onRoomStateEvents); cli.removeListener("RoomState.events", this.onRoomStateEvents);
} }
} }
_onRoomStateEvents = (event, state) => { private onRoomStateEvents = (event: MatrixEvent, state: RoomState) => {
if (!this.props.room || event.getRoomId() !== this.props.room.roomId) { if (!this.props.room || event.getRoomId() !== this.props.room.roomId) {
return; return;
} }
// redisplay the room name, topic, etc. // redisplay the room name, topic, etc.
this._rateLimitedUpdate(); this.rateLimitedUpdate();
}; };
_rateLimitedUpdate = new RateLimitedFunc(function() { private rateLimitedUpdate = throttle(() => {
/* eslint-disable @babel/no-invalid-this */
this.forceUpdate(); this.forceUpdate();
}, 500); }, 500, { leading: true, trailing: true });
render() { public render() {
let searchStatus = null; let searchStatus = null;
// don't display the search count until the search completes and // don't display the search count until the search completes and

View file

@ -22,7 +22,7 @@ import { useEventEmitter } from "../../../hooks/useEventEmitter";
import SpaceStore from "../../../stores/SpaceStore"; import SpaceStore from "../../../stores/SpaceStore";
interface IProps { interface IProps {
onVisibilityChange?: () => void onVisibilityChange?: () => void;
} }
const RoomListNumResults: React.FC<IProps> = ({ onVisibilityChange }) => { const RoomListNumResults: React.FC<IProps> = ({ onVisibilityChange }) => {

View file

@ -16,31 +16,28 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import { SearchResult } from "matrix-js-sdk/src/models/search-result";
import * as sdk from '../../../index'; import EventTile, { haveTileForEvent } from "./EventTile";
import { haveTileForEvent } from "./EventTile"; import DateSeparator from '../messages/DateSeparator';
import SettingsStore from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
import { UIFeature } from "../../../settings/UIFeature"; import { UIFeature } from "../../../settings/UIFeature";
import { RoomPermalinkCreator } from '../../../utils/permalinks/Permalinks';
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
@replaceableComponent("views.rooms.SearchResultTile") interface IProps {
export default class SearchResultTile extends React.Component {
static propTypes = {
// a matrix-js-sdk SearchResult containing the details of this result // a matrix-js-sdk SearchResult containing the details of this result
searchResult: PropTypes.object.isRequired, searchResult: SearchResult;
// a list of strings to be highlighted in the results // a list of strings to be highlighted in the results
searchHighlights: PropTypes.array, searchHighlights?: string[];
// href for the highlights in this result // href for the highlights in this result
resultLink: PropTypes.string, resultLink?: string;
onHeightChanged?: () => void;
permalinkCreator?: RoomPermalinkCreator;
}
onHeightChanged: PropTypes.func, @replaceableComponent("views.rooms.SearchResultTile")
}; export default class SearchResultTile extends React.Component<IProps> {
public render() {
render() {
const DateSeparator = sdk.getComponent('messages.DateSeparator');
const EventTile = sdk.getComponent('rooms.EventTile');
const result = this.props.searchResult; const result = this.props.searchResult;
const mxEv = result.context.getEvent(); const mxEv = result.context.getEvent();
const eventId = mxEv.getId(); const eventId = mxEv.getId();

View file

@ -1,6 +1,5 @@
/* /*
Copyright 2019 New Vector Ltd Copyright 2019 - 2021 The Matrix.org Foundation C.I.C.
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.
@ -14,42 +13,53 @@ 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 PropTypes from 'prop-types'; import React, { ClipboardEvent, createRef, KeyboardEvent } from 'react';
import EMOJI_REGEX from 'emojibase-regex';
import { IContent, MatrixEvent } from 'matrix-js-sdk/src/models/event';
import { DebouncedFunc, throttle } from 'lodash';
import { EventType, RelationType } from "matrix-js-sdk/src/@types/event";
import dis from '../../../dispatcher/dispatcher'; import dis from '../../../dispatcher/dispatcher';
import EditorModel from '../../../editor/model'; import EditorModel from '../../../editor/model';
import { import {
htmlSerializeIfNeeded,
textSerialize,
containsEmote, containsEmote,
stripEmoteCommand, htmlSerializeIfNeeded,
unescapeMessage,
startsWith, startsWith,
stripEmoteCommand,
stripPrefix, stripPrefix,
textSerialize,
unescapeMessage,
} from '../../../editor/serialize'; } from '../../../editor/serialize';
import { CommandPartCreator } from '../../../editor/parts'; import { CommandPartCreator, Part, PartCreator, SerializedPart } from '../../../editor/parts';
import BasicMessageComposer from "./BasicMessageComposer"; import BasicMessageComposer from "./BasicMessageComposer";
import ReplyThread from "../elements/ReplyThread"; import ReplyThread from "../elements/ReplyThread";
import { findEditableEvent } from '../../../utils/EventUtils'; import { findEditableEvent } from '../../../utils/EventUtils';
import SendHistoryManager from "../../../SendHistoryManager"; import SendHistoryManager from "../../../SendHistoryManager";
import { CommandCategories, getCommand } from '../../../SlashCommands'; import { Command, CommandCategories, getCommand } from '../../../SlashCommands';
import * as sdk from '../../../index';
import Modal from '../../../Modal'; import Modal from '../../../Modal';
import { _t, _td } from '../../../languageHandler'; import { _t, _td } from '../../../languageHandler';
import ContentMessages from '../../../ContentMessages'; import ContentMessages from '../../../ContentMessages';
import MatrixClientContext from "../../../contexts/MatrixClientContext"; import MatrixClientContext from "../../../contexts/MatrixClientContext";
import RateLimitedFunc from '../../../ratelimitedfunc';
import { Action } from "../../../dispatcher/actions"; import { Action } from "../../../dispatcher/actions";
import { containsEmoji } from "../../../effects/utils"; import { containsEmoji } from "../../../effects/utils";
import { CHAT_EFFECTS } from '../../../effects'; import { CHAT_EFFECTS } from '../../../effects';
import CountlyAnalytics from "../../../CountlyAnalytics"; import CountlyAnalytics from "../../../CountlyAnalytics";
import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { MatrixClientPeg } from "../../../MatrixClientPeg";
import EMOJI_REGEX from 'emojibase-regex';
import { getKeyBindingsManager, MessageComposerAction } from '../../../KeyBindingsManager'; import { getKeyBindingsManager, MessageComposerAction } from '../../../KeyBindingsManager';
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import SettingsStore from '../../../settings/SettingsStore'; import SettingsStore from '../../../settings/SettingsStore';
import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
import { Room } from 'matrix-js-sdk/src/models/room';
import ErrorDialog from "../dialogs/ErrorDialog";
import QuestionDialog from "../dialogs/QuestionDialog";
import { ActionPayload } from "../../../dispatcher/payloads";
function addReplyToMessageContent(content, repliedToEvent, permalinkCreator) { function addReplyToMessageContent(
content: IContent,
repliedToEvent: MatrixEvent,
permalinkCreator: RoomPermalinkCreator,
): void {
const replyContent = ReplyThread.makeReplyMixIn(repliedToEvent); const replyContent = ReplyThread.makeReplyMixIn(repliedToEvent);
Object.assign(content, replyContent); Object.assign(content, replyContent);
@ -65,7 +75,11 @@ function addReplyToMessageContent(content, repliedToEvent, permalinkCreator) {
} }
// exported for tests // exported for tests
export function createMessageContent(model, permalinkCreator, replyToEvent) { export function createMessageContent(
model: EditorModel,
permalinkCreator: RoomPermalinkCreator,
replyToEvent: MatrixEvent,
): IContent {
const isEmote = containsEmote(model); const isEmote = containsEmote(model);
if (isEmote) { if (isEmote) {
model = stripEmoteCommand(model); model = stripEmoteCommand(model);
@ -76,7 +90,7 @@ export function createMessageContent(model, permalinkCreator, replyToEvent) {
model = unescapeMessage(model); model = unescapeMessage(model);
const body = textSerialize(model); const body = textSerialize(model);
const content = { const content: IContent = {
msgtype: isEmote ? "m.emote" : "m.text", msgtype: isEmote ? "m.emote" : "m.text",
body: body, body: body,
}; };
@ -94,7 +108,7 @@ export function createMessageContent(model, permalinkCreator, replyToEvent) {
} }
// exported for tests // exported for tests
export function isQuickReaction(model) { export function isQuickReaction(model: EditorModel): boolean {
const parts = model.parts; const parts = model.parts;
if (parts.length == 0) return false; if (parts.length == 0) return false;
const text = textSerialize(model); const text = textSerialize(model);
@ -111,46 +125,48 @@ export function isQuickReaction(model) {
return false; return false;
} }
interface IProps {
room: Room;
placeholder?: string;
permalinkCreator: RoomPermalinkCreator;
replyToEvent?: MatrixEvent;
disabled?: boolean;
onChange?(model: EditorModel): void;
}
@replaceableComponent("views.rooms.SendMessageComposer") @replaceableComponent("views.rooms.SendMessageComposer")
export default class SendMessageComposer extends React.Component { export default class SendMessageComposer extends React.Component<IProps> {
static propTypes = {
room: PropTypes.object.isRequired,
placeholder: PropTypes.string,
permalinkCreator: PropTypes.object.isRequired,
replyToEvent: PropTypes.object,
onChange: PropTypes.func,
disabled: PropTypes.bool,
};
static contextType = MatrixClientContext; static contextType = MatrixClientContext;
context!: React.ContextType<typeof MatrixClientContext>;
constructor(props, context) { private readonly prepareToEncrypt?: DebouncedFunc<() => void>;
super(props, context); private readonly editorRef = createRef<BasicMessageComposer>();
this.model = null; private model: EditorModel = null;
this._editorRef = null; private currentlyComposedEditorState: SerializedPart[] = null;
this.currentlyComposedEditorState = null; private dispatcherRef: string;
private sendHistoryManager: SendHistoryManager;
constructor(props: IProps, context: React.ContextType<typeof MatrixClientContext>) {
super(props);
this.context = context; // otherwise React will only set it prior to render due to type def above
if (this.context.isCryptoEnabled() && this.context.isRoomEncrypted(this.props.room.roomId)) { if (this.context.isCryptoEnabled() && this.context.isRoomEncrypted(this.props.room.roomId)) {
this._prepareToEncrypt = new RateLimitedFunc(() => { this.prepareToEncrypt = throttle(() => {
this.context.prepareToEncrypt(this.props.room); this.context.prepareToEncrypt(this.props.room);
}, 60000); }, 60000, { leading: true, trailing: false });
} }
window.addEventListener("beforeunload", this._saveStoredEditorState); window.addEventListener("beforeunload", this.saveStoredEditorState);
} }
_setEditorRef = ref => { private onKeyDown = (event: KeyboardEvent): void => {
this._editorRef = ref;
};
_onKeyDown = (event) => {
// ignore any keypress while doing IME compositions // ignore any keypress while doing IME compositions
if (this._editorRef.isComposing(event)) { if (this.editorRef.current?.isComposing(event)) {
return; return;
} }
const action = getKeyBindingsManager().getMessageComposerAction(event); const action = getKeyBindingsManager().getMessageComposerAction(event);
switch (action) { switch (action) {
case MessageComposerAction.Send: case MessageComposerAction.Send:
this._sendMessage(); this.sendMessage();
event.preventDefault(); event.preventDefault();
break; break;
case MessageComposerAction.SelectPrevSendHistory: case MessageComposerAction.SelectPrevSendHistory:
@ -165,7 +181,7 @@ export default class SendMessageComposer extends React.Component {
} }
case MessageComposerAction.EditPrevMessage: case MessageComposerAction.EditPrevMessage:
// selection must be collapsed and caret at start // selection must be collapsed and caret at start
if (this._editorRef.isSelectionCollapsed() && this._editorRef.isCaretAtStart()) { if (this.editorRef.current?.isSelectionCollapsed() && this.editorRef.current?.isCaretAtStart()) {
const editEvent = findEditableEvent(this.props.room, false); const editEvent = findEditableEvent(this.props.room, false);
if (editEvent) { if (editEvent) {
// We're selecting history, so prevent the key event from doing anything else // We're selecting history, so prevent the key event from doing anything else
@ -184,29 +200,29 @@ export default class SendMessageComposer extends React.Component {
}); });
break; break;
default: default:
if (this._prepareToEncrypt) { if (this.prepareToEncrypt) {
// This needs to be last! // This needs to be last!
this._prepareToEncrypt(); this.prepareToEncrypt();
} }
} }
}; };
// we keep sent messages/commands in a separate history (separate from undo history) // we keep sent messages/commands in a separate history (separate from undo history)
// so you can alt+up/down in them // so you can alt+up/down in them
selectSendHistory(up) { private selectSendHistory(up: boolean): boolean {
const delta = up ? -1 : 1; const delta = up ? -1 : 1;
// True if we are not currently selecting history, but composing a message // True if we are not currently selecting history, but composing a message
if (this.sendHistoryManager.currentIndex === this.sendHistoryManager.history.length) { if (this.sendHistoryManager.currentIndex === this.sendHistoryManager.history.length) {
// We can't go any further - there isn't any more history, so nop. // We can't go any further - there isn't any more history, so nop.
if (!up) { if (!up) {
return; return false;
} }
this.currentlyComposedEditorState = this.model.serializeParts(); this.currentlyComposedEditorState = this.model.serializeParts();
} else if (this.sendHistoryManager.currentIndex + delta === this.sendHistoryManager.history.length) { } else if (this.sendHistoryManager.currentIndex + delta === this.sendHistoryManager.history.length) {
// True when we return to the message being composed currently // True when we return to the message being composed currently
this.model.reset(this.currentlyComposedEditorState); this.model.reset(this.currentlyComposedEditorState);
this.sendHistoryManager.currentIndex = this.sendHistoryManager.history.length; this.sendHistoryManager.currentIndex = this.sendHistoryManager.history.length;
return; return true;
} }
const { parts, replyEventId } = this.sendHistoryManager.getItem(delta); const { parts, replyEventId } = this.sendHistoryManager.getItem(delta);
dis.dispatch({ dis.dispatch({
@ -215,11 +231,12 @@ export default class SendMessageComposer extends React.Component {
}); });
if (parts) { if (parts) {
this.model.reset(parts); this.model.reset(parts);
this._editorRef.focus(); this.editorRef.current?.focus();
} }
return true;
} }
_isSlashCommand() { private isSlashCommand(): boolean {
const parts = this.model.parts; const parts = this.model.parts;
const firstPart = parts[0]; const firstPart = parts[0];
if (firstPart) { if (firstPart) {
@ -237,17 +254,17 @@ export default class SendMessageComposer extends React.Component {
return false; return false;
} }
_sendQuickReaction() { private sendQuickReaction(): void {
const timeline = this.props.room.getLiveTimeline(); const timeline = this.props.room.getLiveTimeline();
const events = timeline.getEvents(); const events = timeline.getEvents();
const reaction = this.model.parts[1].text; const reaction = this.model.parts[1].text;
for (let i = events.length - 1; i >= 0; i--) { for (let i = events.length - 1; i >= 0; i--) {
if (events[i].getType() === "m.room.message") { if (events[i].getType() === EventType.RoomMessage) {
let shouldReact = true; let shouldReact = true;
const lastMessage = events[i]; const lastMessage = events[i];
const userId = MatrixClientPeg.get().getUserId(); const userId = MatrixClientPeg.get().getUserId();
const messageReactions = this.props.room.getUnfilteredTimelineSet() const messageReactions = this.props.room.getUnfilteredTimelineSet()
.getRelationsForEvent(lastMessage.getId(), "m.annotation", "m.reaction"); .getRelationsForEvent(lastMessage.getId(), RelationType.Annotation, EventType.Reaction);
// if we have already sent this reaction, don't redact but don't re-send // if we have already sent this reaction, don't redact but don't re-send
if (messageReactions) { if (messageReactions) {
@ -258,9 +275,9 @@ export default class SendMessageComposer extends React.Component {
shouldReact = !myReactionKeys.includes(reaction); shouldReact = !myReactionKeys.includes(reaction);
} }
if (shouldReact) { if (shouldReact) {
MatrixClientPeg.get().sendEvent(lastMessage.getRoomId(), "m.reaction", { MatrixClientPeg.get().sendEvent(lastMessage.getRoomId(), EventType.Reaction, {
"m.relates_to": { "m.relates_to": {
"rel_type": "m.annotation", "rel_type": RelationType.Annotation,
"event_id": lastMessage.getId(), "event_id": lastMessage.getId(),
"key": reaction, "key": reaction,
}, },
@ -272,7 +289,7 @@ export default class SendMessageComposer extends React.Component {
} }
} }
_getSlashCommand() { private getSlashCommand(): [Command, string, string] {
const commandText = this.model.parts.reduce((text, part) => { const commandText = this.model.parts.reduce((text, part) => {
// use mxid to textify user pills in a command // use mxid to textify user pills in a command
if (part.type === "user-pill") { if (part.type === "user-pill") {
@ -284,7 +301,7 @@ export default class SendMessageComposer extends React.Component {
return [cmd, args, commandText]; return [cmd, args, commandText];
} }
async _runSlashCommand(cmd, args) { private async runSlashCommand(cmd: Command, args: string): Promise<void> {
const result = cmd.run(this.props.room.roomId, args); const result = cmd.run(this.props.room.roomId, args);
let messageContent; let messageContent;
let error = result.error; let error = result.error;
@ -302,7 +319,6 @@ export default class SendMessageComposer extends React.Component {
} }
if (error) { if (error) {
console.error("Command failure: %s", error); console.error("Command failure: %s", error);
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
// assume the error is a server error when the command is async // assume the error is a server error when the command is async
const isServerError = !!result.promise; const isServerError = !!result.promise;
const title = isServerError ? _td("Server error") : _td("Command error"); const title = isServerError ? _td("Server error") : _td("Command error");
@ -326,7 +342,7 @@ export default class SendMessageComposer extends React.Component {
} }
} }
async _sendMessage() { public async sendMessage(): Promise<void> {
if (this.model.isEmpty) { if (this.model.isEmpty) {
return; return;
} }
@ -335,21 +351,20 @@ export default class SendMessageComposer extends React.Component {
let shouldSend = true; let shouldSend = true;
let content; let content;
if (!containsEmote(this.model) && this._isSlashCommand()) { if (!containsEmote(this.model) && this.isSlashCommand()) {
const [cmd, args, commandText] = this._getSlashCommand(); const [cmd, args, commandText] = this.getSlashCommand();
if (cmd) { if (cmd) {
if (cmd.category === CommandCategories.messages) { if (cmd.category === CommandCategories.messages) {
content = await this._runSlashCommand(cmd, args); content = await this.runSlashCommand(cmd, args);
if (replyToEvent) { if (replyToEvent) {
addReplyToMessageContent(content, replyToEvent, this.props.permalinkCreator); addReplyToMessageContent(content, replyToEvent, this.props.permalinkCreator);
} }
} else { } else {
this._runSlashCommand(cmd, args); this.runSlashCommand(cmd, args);
shouldSend = false; shouldSend = false;
} }
} else { } else {
// ask the user if their unknown command should be sent as a message // ask the user if their unknown command should be sent as a message
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
const { finished } = Modal.createTrackedDialog("Unknown command", "", QuestionDialog, { const { finished } = Modal.createTrackedDialog("Unknown command", "", QuestionDialog, {
title: _t("Unknown Command"), title: _t("Unknown Command"),
description: <div> description: <div>
@ -378,7 +393,7 @@ export default class SendMessageComposer extends React.Component {
if (isQuickReaction(this.model)) { if (isQuickReaction(this.model)) {
shouldSend = false; shouldSend = false;
this._sendQuickReaction(); this.sendQuickReaction();
} }
if (shouldSend) { if (shouldSend) {
@ -411,9 +426,9 @@ export default class SendMessageComposer extends React.Component {
this.sendHistoryManager.save(this.model, replyToEvent); this.sendHistoryManager.save(this.model, replyToEvent);
// clear composer // clear composer
this.model.reset([]); this.model.reset([]);
this._editorRef.clearUndoHistory(); this.editorRef.current?.clearUndoHistory();
this._editorRef.focus(); this.editorRef.current?.focus();
this._clearStoredEditorState(); this.clearStoredEditorState();
if (SettingsStore.getValue("scrollToBottomOnMessageSent")) { if (SettingsStore.getValue("scrollToBottomOnMessageSent")) {
dis.dispatch({ action: "scroll_to_bottom" }); dis.dispatch({ action: "scroll_to_bottom" });
} }
@ -421,33 +436,33 @@ export default class SendMessageComposer extends React.Component {
componentWillUnmount() { componentWillUnmount() {
dis.unregister(this.dispatcherRef); dis.unregister(this.dispatcherRef);
window.removeEventListener("beforeunload", this._saveStoredEditorState); window.removeEventListener("beforeunload", this.saveStoredEditorState);
this._saveStoredEditorState(); this.saveStoredEditorState();
} }
// TODO: [REACT-WARNING] Move this to constructor // TODO: [REACT-WARNING] Move this to constructor
UNSAFE_componentWillMount() { // eslint-disable-line camelcase UNSAFE_componentWillMount() { // eslint-disable-line camelcase
const partCreator = new CommandPartCreator(this.props.room, this.context); const partCreator = new CommandPartCreator(this.props.room, this.context);
const parts = this._restoreStoredEditorState(partCreator) || []; const parts = this.restoreStoredEditorState(partCreator) || [];
this.model = new EditorModel(parts, partCreator); this.model = new EditorModel(parts, partCreator);
this.dispatcherRef = dis.register(this.onAction); this.dispatcherRef = dis.register(this.onAction);
this.sendHistoryManager = new SendHistoryManager(this.props.room.roomId, 'mx_cider_history_'); this.sendHistoryManager = new SendHistoryManager(this.props.room.roomId, 'mx_cider_history_');
} }
get _editorStateKey() { private get editorStateKey() {
return `mx_cider_state_${this.props.room.roomId}`; return `mx_cider_state_${this.props.room.roomId}`;
} }
_clearStoredEditorState() { private clearStoredEditorState(): void {
localStorage.removeItem(this._editorStateKey); localStorage.removeItem(this.editorStateKey);
} }
_restoreStoredEditorState(partCreator) { private restoreStoredEditorState(partCreator: PartCreator): Part[] {
const json = localStorage.getItem(this._editorStateKey); const json = localStorage.getItem(this.editorStateKey);
if (json) { if (json) {
try { try {
const { parts: serializedParts, replyEventId } = JSON.parse(json); const { parts: serializedParts, replyEventId } = JSON.parse(json);
const parts = serializedParts.map(p => partCreator.deserializePart(p)); const parts: Part[] = serializedParts.map(p => partCreator.deserializePart(p));
if (replyEventId) { if (replyEventId) {
dis.dispatch({ dis.dispatch({
action: 'reply_to_event', action: 'reply_to_event',
@ -462,20 +477,20 @@ export default class SendMessageComposer extends React.Component {
} }
// should save state when editor has contents or reply is open // should save state when editor has contents or reply is open
_shouldSaveStoredEditorState = () => { private shouldSaveStoredEditorState = (): boolean => {
return !this.model.isEmpty || this.props.replyToEvent; return !this.model.isEmpty || !!this.props.replyToEvent;
} };
_saveStoredEditorState = () => { private saveStoredEditorState = (): void => {
if (this._shouldSaveStoredEditorState()) { if (this.shouldSaveStoredEditorState()) {
const item = SendHistoryManager.createItem(this.model, this.props.replyToEvent); const item = SendHistoryManager.createItem(this.model, this.props.replyToEvent);
localStorage.setItem(this._editorStateKey, JSON.stringify(item)); localStorage.setItem(this.editorStateKey, JSON.stringify(item));
} else { } else {
this._clearStoredEditorState(); this.clearStoredEditorState();
}
} }
};
onAction = (payload) => { private onAction = (payload: ActionPayload): void => {
// don't let the user into the composer if it is disabled - all of these branches lead // don't let the user into the composer if it is disabled - all of these branches lead
// to the cursor being in the composer // to the cursor being in the composer
if (this.props.disabled) return; if (this.props.disabled) return;
@ -483,21 +498,21 @@ export default class SendMessageComposer extends React.Component {
switch (payload.action) { switch (payload.action) {
case 'reply_to_event': case 'reply_to_event':
case Action.FocusComposer: case Action.FocusComposer:
this._editorRef && this._editorRef.focus(); this.editorRef.current?.focus();
break; break;
case "send_composer_insert": case "send_composer_insert":
if (payload.userId) { if (payload.userId) {
this._editorRef && this._editorRef.insertMention(payload.userId); this.editorRef.current?.insertMention(payload.userId);
} else if (payload.event) { } else if (payload.event) {
this._editorRef && this._editorRef.insertQuotedMessage(payload.event); this.editorRef.current?.insertQuotedMessage(payload.event);
} else if (payload.text) { } else if (payload.text) {
this._editorRef && this._editorRef.insertPlaintext(payload.text); this.editorRef.current?.insertPlaintext(payload.text);
} }
break; break;
} }
}; };
_onPaste = (event) => { private onPaste = (event: ClipboardEvent<HTMLDivElement>): boolean => {
const { clipboardData } = event; const { clipboardData } = event;
// Prioritize text on the clipboard over files as Office on macOS puts a bitmap // Prioritize text on the clipboard over files as Office on macOS puts a bitmap
// in the clipboard as well as the content being copied. // in the clipboard as well as the content being copied.
@ -511,23 +526,27 @@ export default class SendMessageComposer extends React.Component {
); );
return true; // to skip internal onPaste handler return true; // to skip internal onPaste handler
} }
} };
onChange = () => { private onChange = (): void => {
if (this.props.onChange) this.props.onChange(this.model); if (this.props.onChange) this.props.onChange(this.model);
} };
private focusComposer = (): void => {
this.editorRef.current?.focus();
};
render() { render() {
return ( return (
<div className="mx_SendMessageComposer" onClick={this.focusComposer} onKeyDown={this._onKeyDown}> <div className="mx_SendMessageComposer" onClick={this.focusComposer} onKeyDown={this.onKeyDown}>
<BasicMessageComposer <BasicMessageComposer
onChange={this.onChange} onChange={this.onChange}
ref={this._setEditorRef} ref={this.editorRef}
model={this.model} model={this.model}
room={this.props.room} room={this.props.room}
label={this.props.placeholder} label={this.props.placeholder}
placeholder={this.props.placeholder} placeholder={this.props.placeholder}
onPaste={this._onPaste} onPaste={this.onPaste}
disabled={this.props.disabled} disabled={this.props.disabled}
/> />
</div> </div>

View file

@ -27,7 +27,7 @@ export default class SimpleRoomHeader extends React.Component {
static propTypes = { static propTypes = {
title: PropTypes.string, title: PropTypes.string,
// `src` to a TintableSvg. Optional. // `src` to an image. Optional.
icon: PropTypes.string, icon: PropTypes.string,
}; };

View file

@ -18,22 +18,18 @@ import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import React, { ReactNode } from "react"; import React, { ReactNode } from "react";
import { import {
IRecordingUpdate,
RECORDING_PLAYBACK_SAMPLES,
RecordingState, RecordingState,
VoiceRecording, VoiceRecording,
} from "../../../voice/VoiceRecording"; } from "../../../voice/VoiceRecording";
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 classNames from "classnames"; import classNames from "classnames";
import LiveRecordingWaveform from "../voice_messages/LiveRecordingWaveform"; import LiveRecordingWaveform from "../audio_messages/LiveRecordingWaveform";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import { arrayFastResample, arraySeed } from "../../../utils/arrays"; import LiveRecordingClock from "../audio_messages/LiveRecordingClock";
import { percentageOf } from "../../../utils/numbers";
import LiveRecordingClock from "../voice_messages/LiveRecordingClock";
import { VoiceRecordingStore } from "../../../stores/VoiceRecordingStore"; import { VoiceRecordingStore } from "../../../stores/VoiceRecordingStore";
import { UPDATE_EVENT } from "../../../stores/AsyncStore"; import { UPDATE_EVENT } from "../../../stores/AsyncStore";
import RecordingPlayback from "../voice_messages/RecordingPlayback"; import RecordingPlayback from "../audio_messages/RecordingPlayback";
import { MsgType } from "matrix-js-sdk/src/@types/event"; import { MsgType } from "matrix-js-sdk/src/@types/event";
import Modal from "../../../Modal"; import Modal from "../../../Modal";
import ErrorDialog from "../dialogs/ErrorDialog"; import ErrorDialog from "../dialogs/ErrorDialog";
@ -46,8 +42,6 @@ interface IProps {
interface IState { interface IState {
recorder?: VoiceRecording; recorder?: VoiceRecording;
recordingPhase?: RecordingState; recordingPhase?: RecordingState;
relHeights: number[];
seconds: number;
} }
/** /**
@ -55,58 +49,18 @@ interface IState {
*/ */
@replaceableComponent("views.rooms.VoiceRecordComposerTile") @replaceableComponent("views.rooms.VoiceRecordComposerTile")
export default class VoiceRecordComposerTile extends React.PureComponent<IProps, IState> { export default class VoiceRecordComposerTile extends React.PureComponent<IProps, IState> {
private waveform: number[] = [];
private seconds = 0;
private scheduledAnimationFrame = false;
public constructor(props) { public constructor(props) {
super(props); super(props);
this.state = { this.state = {
recorder: null, // no recording started by default recorder: null, // no recording started by default
seconds: 0,
relHeights: arraySeed(0, RECORDING_PLAYBACK_SAMPLES),
}; };
} }
public componentDidUpdate(prevProps, prevState) {
if (!prevState.recorder && this.state.recorder) {
this.state.recorder.liveData.onUpdate(this.onRecordingUpdate);
}
}
public async componentWillUnmount() { public async componentWillUnmount() {
await VoiceRecordingStore.instance.disposeRecording(); await VoiceRecordingStore.instance.disposeRecording();
} }
private onRecordingUpdate = (update: IRecordingUpdate): void => {
this.waveform = update.waveform;
this.seconds = update.timeSeconds;
if (this.scheduledAnimationFrame) {
return;
}
this.scheduledAnimationFrame = true;
// The audio recorder flushes data faster than the screen refresh rate
// Using requestAnimationFrame makes sure that we only flush the data
// to react once per tick to avoid unneeded work.
requestAnimationFrame(() => {
// The waveform and the downsample target are pretty close, so we should be fine to
// do this, despite the docs on arrayFastResample.
const bars = arrayFastResample(Array.from(this.waveform), RECORDING_PLAYBACK_SAMPLES);
this.setState({
// The incoming data is between zero and one, but typically even screaming into a
// microphone won't send you over 0.6, so we artificially adjust the gain for the
// waveform. This results in a slightly more cinematic/animated waveform for the
// user.
relHeights: bars.map(b => percentageOf(b, 0, 0.50)),
seconds: this.seconds,
});
this.scheduledAnimationFrame = false;
});
};
// called by composer // called by composer
public async send() { public async send() {
if (!this.state.recorder) { if (!this.state.recorder) {
@ -228,7 +182,7 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
} }
// only other UI is the recording-in-progress UI // only other UI is the recording-in-progress UI
return <div className="mx_VoiceMessagePrimaryContainer mx_VoiceRecordComposerTile_recording"> return <div className="mx_MediaBody mx_VoiceMessagePrimaryContainer mx_VoiceRecordComposerTile_recording">
<LiveRecordingClock recorder={this.state.recorder} /> <LiveRecordingClock recorder={this.state.recorder} />
<LiveRecordingWaveform recorder={this.state.recorder} /> <LiveRecordingWaveform recorder={this.state.recorder} />
</div>; </div>;

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