Merge branch 'develop' into gsouquet/message-bubbles-4635
This commit is contained in:
commit
10bdb3cefa
148 changed files with 2580 additions and 2149 deletions
|
@ -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/
|
|
25
.eslintrc.js
25
.eslintrc.js
|
@ -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,
|
||||||
|
|
171
CHANGELOG.md
171
CHANGELOG.md
|
@ -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)
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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";
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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;
|
||||||
|
|
68
res/css/views/audio_messages/_AudioPlayer.scss
Normal file
68
res/css/views/audio_messages/_AudioPlayer.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
103
res/css/views/audio_messages/_SeekBar.scss
Normal file
103
res/css/views/audio_messages/_SeekBar.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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%);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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
|
|
2
src/@types/global.d.ts
vendored
2
src/@types/global.d.ts
vendored
|
@ -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 {
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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(() => {
|
||||||
|
|
|
@ -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();
|
||||||
|
|
|
@ -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('');
|
||||||
|
|
|
@ -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);
|
|
@ -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";
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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()) {
|
||||||
|
|
13
src/Terms.ts
13
src/Terms.ts
|
@ -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,
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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";
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -24,7 +24,7 @@ interface IProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IState {
|
interface IState {
|
||||||
toasts: ComponentClass[],
|
toasts: ComponentClass[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@replaceableComponent("structures.NonUrgentToastContainer")
|
@replaceableComponent("structures.NonUrgentToastContainer")
|
||||||
|
|
|
@ -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() {
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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() {
|
||||||
|
|
|
@ -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;
|
|
@ -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>
|
|
@ -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;
|
||||||
}
|
}
|
|
@ -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;
|
||||||
|
|
|
@ -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}`);
|
|
@ -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 {
|
||||||
|
|
124
src/components/views/audio_messages/AudioPlayer.tsx
Normal file
124
src/components/views/audio_messages/AudioPlayer.tsx
Normal 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} />
|
||||||
|
{/* 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>;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
55
src/components/views/audio_messages/DurationClock.tsx
Normal file
55
src/components/views/audio_messages/DurationClock.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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(
|
|
@ -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() {
|
|
@ -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}
|
||||||
/>;
|
/>;
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -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} />;
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -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);
|
|
@ -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} />
|
112
src/components/views/audio_messages/SeekBar.tsx
Normal file
112
src/components/views/audio_messages/SeekBar.tsx
Normal 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}
|
||||||
|
/>;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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,
|
|
@ -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 {
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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";
|
||||||
|
|
|
@ -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";
|
||||||
|
|
|
@ -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?'
|
||||||
|
|
||||||
|
|
|
@ -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")
|
||||||
|
|
|
@ -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 didn’t 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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>;
|
||||||
}
|
}
|
56
src/components/views/elements/BlurhashPlaceholder.tsx
Normal file
56
src/components/views/elements/BlurhashPlaceholder.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
|
|
@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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")
|
||||||
|
|
|
@ -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")
|
||||||
|
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
110
src/components/views/messages/MAudioBody.tsx
Normal file
110
src/components/views/messages/MAudioBody.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
|
|
@ -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>);
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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">
|
||||||
*
|
*
|
||||||
|
@ -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 }
|
|
@ -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>
|
||||||
);
|
);
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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 = [];
|
||||||
|
|
|
@ -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();
|
||||||
|
|
|
@ -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>);
|
|
@ -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;
|
||||||
|
|
|
@ -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();
|
||||||
|
|
|
@ -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,
|
||||||
});
|
});
|
||||||
|
|
|
@ -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>;
|
||||||
|
|
|
@ -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
|
|
@ -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 }) => {
|
||||||
|
|
|
@ -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();
|
|
@ -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>
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -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
Loading…
Reference in a new issue