diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000000..2c068fff33 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @matrix-org/element-web diff --git a/.github/workflows/layered-build.yaml b/.github/workflows/layered-build.yaml new file mode 100644 index 0000000000..c9d7e89a75 --- /dev/null +++ b/.github/workflows/layered-build.yaml @@ -0,0 +1,31 @@ +name: Layered Preview Build +on: + pull_request: + branches: [develop] +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Build + run: scripts/ci/layered.sh && cd element-web && cp element.io/develop/config.json config.json && CI_PACKAGE=true yarn build + - name: Upload Artifact + uses: actions/upload-artifact@v2 + with: + name: previewbuild + path: element-web/webapp + # We'll only use this in a triggered job, then we're done with it + retention-days: 1 + - uses: actions/github-script@v3.1.0 + with: + script: | + var fs = require('fs'); + fs.writeFileSync('${{github.workspace}}/pr.json', JSON.stringify(context.payload.pull_request)); + - name: Upload PR Info + uses: actions/upload-artifact@v2 + with: + name: pr.json + path: pr.json + # We'll only use this in a triggered job, then we're done with it + retention-days: 1 + diff --git a/.github/workflows/netflify.yaml b/.github/workflows/netflify.yaml new file mode 100644 index 0000000000..a6a408bdbd --- /dev/null +++ b/.github/workflows/netflify.yaml @@ -0,0 +1,80 @@ +name: Upload Preview Build to Netlify +on: + workflow_run: + workflows: ["Layered Preview Build"] + types: + - completed +jobs: + build: + runs-on: ubuntu-latest + if: > + ${{ github.event.workflow_run.conclusion == 'success' }} + steps: + # There's a 'download artifact' action but it hasn't been updated for the + # workflow_run action (https://github.com/actions/download-artifact/issues/60) + # so instead we get this mess: + - name: 'Download artifact' + uses: actions/github-script@v3.1.0 + with: + script: | + var artifacts = await github.actions.listWorkflowRunArtifacts({ + owner: context.repo.owner, + repo: context.repo.repo, + run_id: ${{github.event.workflow_run.id }}, + }); + var matchArtifact = artifacts.data.artifacts.filter((artifact) => { + return artifact.name == "previewbuild" + })[0]; + var download = await github.actions.downloadArtifact({ + owner: context.repo.owner, + repo: context.repo.repo, + artifact_id: matchArtifact.id, + archive_format: 'zip', + }); + var fs = require('fs'); + fs.writeFileSync('${{github.workspace}}/previewbuild.zip', Buffer.from(download.data)); + + var prInfoArtifact = artifacts.data.artifacts.filter((artifact) => { + return artifact.name == "pr.json" + })[0]; + var download = await github.actions.downloadArtifact({ + owner: context.repo.owner, + repo: context.repo.repo, + artifact_id: prInfoArtifact.id, + archive_format: 'zip', + }); + var fs = require('fs'); + fs.writeFileSync('${{github.workspace}}/pr.json.zip', Buffer.from(download.data)); + - name: Extract Artifacts + run: unzip -d webapp previewbuild.zip && rm previewbuild.zip && unzip pr.json.zip && rm pr.json.zip + - name: 'Read PR Info' + id: readctx + uses: actions/github-script@v3.1.0 + with: + script: | + var fs = require('fs'); + var pr = JSON.parse(fs.readFileSync('${{github.workspace}}/pr.json')); + console.log(`::set-output name=prnumber::${pr.number}`); + - name: Deploy to Netlify + id: netlify + uses: nwtgck/actions-netlify@v1.2 + with: + publish-dir: webapp + deploy-message: "Deploy from GitHub Actions" + # These don't work because we're in workflow_run + enable-pull-request-comment: false + enable-commit-comment: false + env: + NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} + NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }} + timeout-minutes: 1 + - name: Edit PR Description + uses: velas/pr-description@v1.0.1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + pull-request-number: ${{ steps.readctx.outputs.prnumber }} + description-message: | + Preview: ${{ steps.netlify.outputs.deploy-url }} + ⚠️ Do you trust the author of this PR? Maybe this build will steal your keys or give you malware. Exercise caution. Use test accounts. + diff --git a/.github/workflows/preview_changelog.yaml b/.github/workflows/preview_changelog.yaml new file mode 100644 index 0000000000..d68d19361d --- /dev/null +++ b/.github/workflows/preview_changelog.yaml @@ -0,0 +1,12 @@ +name: Preview Changelog +on: + pull_request_target: + types: [ opened, edited, labeled ] +jobs: + changelog: + runs-on: ubuntu-latest + steps: + - name: Preview Changelog + uses: matrix-org/allchange@main + with: + ghToken: ${{ secrets.GITHUB_TOKEN }} diff --git a/.node-version b/.node-version new file mode 100644 index 0000000000..8351c19397 --- /dev/null +++ b/.node-version @@ -0,0 +1 @@ +14 diff --git a/CHANGELOG.md b/CHANGELOG.md index 73b383d76d..4d65a524d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,122 @@ +Changes in [3.27.0](https://github.com/vector-im/element-desktop/releases/tag/v3.27.0) (2021-07-02) +=================================================================================================== + +## 🔒 SECURITY FIXES + * Sanitize untrusted variables from message previews before translation + Fixes vector-im/element-web#18314 + +## ✨ Features + * Fix editing of `` & ` & `` + [\#6469](https://github.com/matrix-org/matrix-react-sdk/pull/6469) + Fixes vector-im/element-web#18211 + * Zoom images in lightbox to where the cursor points + [\#6418](https://github.com/matrix-org/matrix-react-sdk/pull/6418) + Fixes vector-im/element-web#17870 + * Avoid hitting the settings store from TextForEvent + [\#6205](https://github.com/matrix-org/matrix-react-sdk/pull/6205) + Fixes vector-im/element-web#17650 + * Initial MSC3083 + MSC3244 support + [\#6212](https://github.com/matrix-org/matrix-react-sdk/pull/6212) + Fixes vector-im/element-web#17686 and vector-im/element-web#17661 + * Navigate to the first room with notifications when clicked on space notification dot + [\#5974](https://github.com/matrix-org/matrix-react-sdk/pull/5974) + * Add matrix: to the list of permitted URL schemes + [\#6388](https://github.com/matrix-org/matrix-react-sdk/pull/6388) + * Add "Copy Link" to room context menu + [\#6374](https://github.com/matrix-org/matrix-react-sdk/pull/6374) + * 💭 Message bubble layout + [\#6291](https://github.com/matrix-org/matrix-react-sdk/pull/6291) + Fixes vector-im/element-web#4635, vector-im/element-web#17773 vector-im/element-web#16220 and vector-im/element-web#7687 + * Play only one audio file at a time + [\#6417](https://github.com/matrix-org/matrix-react-sdk/pull/6417) + Fixes vector-im/element-web#17439 + * Move download button for media to the action bar + [\#6386](https://github.com/matrix-org/matrix-react-sdk/pull/6386) + Fixes vector-im/element-web#17943 + * Improved display of one-to-one call history with summary boxes for each call + [\#6121](https://github.com/matrix-org/matrix-react-sdk/pull/6121) + Fixes vector-im/element-web#16409 + * Notification settings UI refresh + [\#6352](https://github.com/matrix-org/matrix-react-sdk/pull/6352) + Fixes vector-im/element-web#17782 + * Fix EventIndex double handling events and erroring + [\#6385](https://github.com/matrix-org/matrix-react-sdk/pull/6385) + Fixes vector-im/element-web#18008 + * Improve reply rendering + [\#3553](https://github.com/matrix-org/matrix-react-sdk/pull/3553) + Fixes vector-im/riot-web#9217, vector-im/riot-web#7633, vector-im/riot-web#7530, vector-im/riot-web#7169, vector-im/riot-web#7151, vector-im/riot-web#6692 vector-im/riot-web#6579 and vector-im/element-web#17440 + +## 🐛 Bug Fixes + * Fix CreateRoomDialog exploding when making public room outside of a space + [\#6493](https://github.com/matrix-org/matrix-react-sdk/pull/6493) + * Fix regression where registration would soft-crash on captcha + [\#6505](https://github.com/matrix-org/matrix-react-sdk/pull/6505) + Fixes vector-im/element-web#18284 + * only send join rule event if we have a join rule to put in it + [\#6517](https://github.com/matrix-org/matrix-react-sdk/pull/6517) + * Improve the new download button's discoverability and interactions. + [\#6510](https://github.com/matrix-org/matrix-react-sdk/pull/6510) + * Fix voice recording UI looking broken while microphone permissions are being requested. + [\#6479](https://github.com/matrix-org/matrix-react-sdk/pull/6479) + Fixes vector-im/element-web#18223 + * Match colors of room and user avatars in DMs + [\#6393](https://github.com/matrix-org/matrix-react-sdk/pull/6393) + Fixes vector-im/element-web#2449 + * Fix onPaste handler to work with copying files from Finder + [\#5389](https://github.com/matrix-org/matrix-react-sdk/pull/5389) + Fixes vector-im/element-web#15536 and vector-im/element-web#16255 + * Fix infinite pagination loop when offline + [\#6478](https://github.com/matrix-org/matrix-react-sdk/pull/6478) + Fixes vector-im/element-web#18242 + * Fix blurhash rounded corners missing regression + [\#6467](https://github.com/matrix-org/matrix-react-sdk/pull/6467) + Fixes vector-im/element-web#18110 + * Fix position of the space hierarchy spinner + [\#6462](https://github.com/matrix-org/matrix-react-sdk/pull/6462) + Fixes vector-im/element-web#18182 + * Fix display of image messages that lack thumbnails + [\#6456](https://github.com/matrix-org/matrix-react-sdk/pull/6456) + Fixes vector-im/element-web#18175 + * Fix crash with large audio files. + [\#6436](https://github.com/matrix-org/matrix-react-sdk/pull/6436) + Fixes vector-im/element-web#18149 + * Make diff colors in codeblocks more pleasant + [\#6355](https://github.com/matrix-org/matrix-react-sdk/pull/6355) + Fixes vector-im/element-web#17939 + * Show the correct audio file duration while loading the file. + [\#6435](https://github.com/matrix-org/matrix-react-sdk/pull/6435) + Fixes vector-im/element-web#18160 + * Fix various timeline settings not applying immediately. + [\#6261](https://github.com/matrix-org/matrix-react-sdk/pull/6261) + Fixes vector-im/element-web#17748 + * Fix issues with room list duplication + [\#6391](https://github.com/matrix-org/matrix-react-sdk/pull/6391) + Fixes vector-im/element-web#14508 + * Fix grecaptcha throwing useless error sometimes + [\#6401](https://github.com/matrix-org/matrix-react-sdk/pull/6401) + Fixes vector-im/element-web#15142 + * Update Emojibase and Twemoji and switch to IamCal (Slack-style) shortcodes + [\#6347](https://github.com/matrix-org/matrix-react-sdk/pull/6347) + Fixes vector-im/element-web#13857 and vector-im/element-web#13334 + * Respect compound emojis in default avatar initial generation + [\#6397](https://github.com/matrix-org/matrix-react-sdk/pull/6397) + Fixes vector-im/element-web#18040 + * Fix bug where the 'other homeserver' field in the server selection dialog would become briefly focus and then unfocus when clicked. + [\#6394](https://github.com/matrix-org/matrix-react-sdk/pull/6394) + Fixes vector-im/element-web#18031 + * Standardise spelling and casing of homeserver, identity server, and integration manager + [\#6365](https://github.com/matrix-org/matrix-react-sdk/pull/6365) + * Fix widgets not receiving decrypted events when they have permission. + [\#6371](https://github.com/matrix-org/matrix-react-sdk/pull/6371) + Fixes vector-im/element-web#17615 + * Prevent client hangs when calculating blurhashes + [\#6366](https://github.com/matrix-org/matrix-react-sdk/pull/6366) + Fixes vector-im/element-web#17945 + * Exclude state events from widgets reading room events + [\#6378](https://github.com/matrix-org/matrix-react-sdk/pull/6378) + * Cache feature_spaces\* flags to improve performance + [\#6381](https://github.com/matrix-org/matrix-react-sdk/pull/6381) + Changes in [3.26.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.26.0) (2021-07-19) ===================================================================================================== [Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.26.0-rc.1...v3.26.0) diff --git a/README.md b/README.md index b3e96ef001..67e5e12f59 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ All code lands on the `develop` branch - `master` is only used for stable releas **Please file PRs against `develop`!!** Please follow the standard Matrix contributor's guide: -https://github.com/matrix-org/matrix-js-sdk/blob/develop/CONTRIBUTING.rst +https://github.com/matrix-org/matrix-js-sdk/blob/develop/CONTRIBUTING.md Please follow the Matrix JS/React code style as per: https://github.com/matrix-org/matrix-react-sdk/blob/master/code_style.md diff --git a/package.json b/package.json index b73462d188..2445e3c973 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-react-sdk", - "version": "3.26.0", + "version": "3.27.0", "description": "SDK for matrix.org using React", "author": "matrix.org", "repository": { @@ -80,13 +80,14 @@ "katex": "^0.12.0", "linkifyjs": "^2.1.9", "lodash": "^4.17.20", - "matrix-js-sdk": "12.1.0", + "matrix-js-sdk": "12.2.0", "matrix-widget-api": "^0.1.0-beta.15", "minimist": "^1.2.5", "opus-recorder": "^8.0.3", "pako": "^2.0.3", "parse5": "^6.0.1", "png-chunks-extract": "^1.0.0", + "posthog-js": "1.12.2", "prop-types": "^15.7.2", "qrcode": "^1.4.4", "re-resizable": "^6.9.0", @@ -123,6 +124,7 @@ "@babel/traverse": "^7.12.12", "@matrix-org/olm": "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.3.tgz", "@peculiar/webcrypto": "^1.1.4", + "@sentry/types": "^6.10.0", "@sinonjs/fake-timers": "^7.0.2", "@types/classnames": "^2.2.11", "@types/commonmark": "^0.27.4", @@ -147,13 +149,14 @@ "@typescript-eslint/eslint-plugin": "^4.17.0", "@typescript-eslint/parser": "^4.17.0", "@wojtekmaj/enzyme-adapter-react-17": "^0.6.1", + "allchange": "github:matrix-org/allchange", "babel-jest": "^26.6.3", "chokidar": "^3.5.1", "concurrently": "^5.3.0", "enzyme": "^3.11.0", "eslint": "7.18.0", "eslint-config-google": "^0.14.0", - "eslint-plugin-matrix-org": "github:matrix-org/eslint-plugin-matrix-org#main", + "eslint-plugin-matrix-org": "github:matrix-org/eslint-plugin-matrix-org#2306b3d4da4eba908b256014b979f1d3d43d2945", "eslint-plugin-react": "^7.22.0", "eslint-plugin-react-hooks": "^4.2.0", "glob": "^7.1.6", @@ -166,6 +169,7 @@ "matrix-web-i18n": "github:matrix-org/matrix-web-i18n", "react-test-renderer": "^17.0.2", "rimraf": "^3.0.2", + "rrweb-snapshot": "1.1.7", "stylelint": "^13.9.0", "stylelint-config-standard": "^20.0.0", "stylelint-scss": "^3.18.0", @@ -189,7 +193,8 @@ "decoderWorker\\.min\\.js": "/__mocks__/empty.js", "decoderWorker\\.min\\.wasm": "/__mocks__/empty.js", "waveWorker\\.min\\.js": "/__mocks__/empty.js", - "workers/(.+)\\.worker\\.ts": "/__mocks__/workerMock.js" + "workers/(.+)\\.worker\\.ts": "/__mocks__/workerMock.js", + "RecorderWorklet": "/__mocks__/empty.js" }, "transformIgnorePatterns": [ "/node_modules/(?!matrix-js-sdk).+$" diff --git a/release_config.yaml b/release_config.yaml new file mode 100644 index 0000000000..12e857cbdd --- /dev/null +++ b/release_config.yaml @@ -0,0 +1,4 @@ +subprojects: + matrix-js-sdk: + includeByDefault: false + diff --git a/res/css/_components.scss b/res/css/_components.scss index 004984f097..823028a301 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -75,6 +75,7 @@ @import "./views/dialogs/_CreateCommunityPrototypeDialog.scss"; @import "./views/dialogs/_CreateGroupDialog.scss"; @import "./views/dialogs/_CreateRoomDialog.scss"; +@import "./views/dialogs/_CreateSpaceFromCommunityDialog.scss"; @import "./views/dialogs/_CreateSubspaceDialog.scss"; @import "./views/dialogs/_DeactivateAccountDialog.scss"; @import "./views/dialogs/_DevtoolsDialog.scss"; @@ -240,6 +241,7 @@ @import "./views/settings/_E2eAdvancedPanel.scss"; @import "./views/settings/_EmailAddresses.scss"; @import "./views/settings/_IntegrationManager.scss"; +@import "./views/settings/_LayoutSwitcher.scss"; @import "./views/settings/_Notifications.scss"; @import "./views/settings/_PhoneNumbers.scss"; @import "./views/settings/_ProfileSettings.scss"; @@ -266,12 +268,15 @@ @import "./views/spaces/_SpacePublicShare.scss"; @import "./views/terms/_InlineTermsAgreement.scss"; @import "./views/toasts/_AnalyticsToast.scss"; +@import "./views/toasts/_IncomingCallToast.scss"; @import "./views/toasts/_NonUrgentEchoFailureToast.scss"; @import "./views/verification/_VerificationShowSas.scss"; +@import "./views/voip/CallView/_CallViewButtons.scss"; @import "./views/voip/_CallContainer.scss"; @import "./views/voip/_CallPreview.scss"; @import "./views/voip/_CallView.scss"; @import "./views/voip/_CallViewForRoom.scss"; +@import "./views/voip/_CallViewHeader.scss"; @import "./views/voip/_CallViewSidebar.scss"; @import "./views/voip/_DialPad.scss"; @import "./views/voip/_DialPadContextMenu.scss"; diff --git a/res/css/structures/_GroupView.scss b/res/css/structures/_GroupView.scss index 60f9ebdd08..fb660f4194 100644 --- a/res/css/structures/_GroupView.scss +++ b/res/css/structures/_GroupView.scss @@ -368,6 +368,65 @@ limitations under the License. padding: 40px 20px; } +.mx_GroupView_spaceUpgradePrompt { + padding: 16px 50px; + background-color: $header-panel-bg-color; + border-radius: 8px; + max-width: 632px; + font-size: $font-15px; + line-height: $font-24px; + margin-top: 24px; + position: relative; + + > h2 { + font-size: inherit; + font-weight: $font-semi-bold; + } + + > p, h2 { + margin: 0; + } + + &::before { + content: ""; + position: absolute; + height: $font-24px; + width: 20px; + left: 18px; + mask-repeat: no-repeat; + mask-position: center; + mask-size: contain; + mask-image: url('$(res)/img/element-icons/room/room-summary.svg'); + background-color: $secondary-fg-color; + } + + .mx_AccessibleButton_kind_link { + padding: 0; + } + + .mx_GroupView_spaceUpgradePrompt_close { + width: 16px; + height: 16px; + border-radius: 8px; + background-color: $input-darker-bg-color; + position: absolute; + top: 16px; + right: 16px; + + &::before { + content: ""; + position: absolute; + width: inherit; + height: inherit; + mask-repeat: no-repeat; + mask-position: center; + mask-size: 8px; + mask-image: url('$(res)/img/image-view/close.svg'); + background-color: $secondary-fg-color; + } + } +} + .mx_GroupView .mx_MemberInfo .mx_AutoHideScrollbar > :not(.mx_MemberInfo_avatar) { padding-left: 16px; padding-right: 16px; diff --git a/res/css/structures/_SpaceHierarchy.scss b/res/css/structures/_SpaceHierarchy.scss index f5810b8dfe..0cefaf252f 100644 --- a/res/css/structures/_SpaceHierarchy.scss +++ b/res/css/structures/_SpaceHierarchy.scss @@ -255,7 +255,7 @@ limitations under the License. } } - &:hover { + &:hover, &:focus-within { background-color: $groupFilterPanel-bg-color; .mx_AccessibleButton { @@ -264,6 +264,10 @@ limitations under the License. } } + li.mx_SpaceRoomDirectory_roomTileWrapper { + list-style: none; + } + .mx_SpaceHierarchy_roomTile, .mx_SpaceHierarchy_subspace_children { &::before { diff --git a/res/css/structures/_SpacePanel.scss b/res/css/structures/_SpacePanel.scss index e64057d16c..1dea6332f5 100644 --- a/res/css/structures/_SpacePanel.scss +++ b/res/css/structures/_SpacePanel.scss @@ -297,7 +297,7 @@ $activeBorderColor: $secondary-fg-color; .mx_SpaceButton:hover, .mx_SpaceButton:focus-within, .mx_SpaceButton_hasMenuOpen { - &:not(.mx_SpaceButton_home):not(.mx_SpaceButton_invite) { + &:not(.mx_SpaceButton_invite) { // Hide the badge container on hover because it'll be a menu button .mx_SpacePanel_badgeContainer { width: 0; @@ -368,6 +368,14 @@ $activeBorderColor: $secondary-fg-color; .mx_SpacePanel_iconExplore::before { mask-image: url('$(res)/img/element-icons/roomlist/browse.svg'); } + + .mx_SpacePanel_noIcon { + display: none; + + & + .mx_IconizedContextMenu_label { + padding-left: 5px !important; // override default iconized label style to align with header + } + } } diff --git a/res/css/structures/_SpaceRoomView.scss b/res/css/structures/_SpaceRoomView.scss index 37ddbcf5ef..57507c1690 100644 --- a/res/css/structures/_SpaceRoomView.scss +++ b/res/css/structures/_SpaceRoomView.scss @@ -180,6 +180,18 @@ $SpaceRoomViewInnerWidth: 428px; } } + .mx_SpaceRoomView_preview_migratedCommunity { + margin-bottom: 16px; + padding: 8px 12px; + border-radius: 8px; + border: 1px solid $input-border-color; + width: max-content; + + .mx_BaseAvatar { + margin-right: 4px; + } + } + .mx_SpaceRoomView_preview_inviter { display: flex; align-items: center; @@ -342,7 +354,7 @@ $SpaceRoomViewInnerWidth: 428px; .mx_SpaceFeedbackPrompt { padding: 7px; // 8px - 1px border - border: 1px solid $menu-border-color; + border: 1px solid rgba($primary-fg-color, .1); border-radius: 8px; width: max-content; margin: 0 0 -40px auto; // collapse its own height to not push other components down diff --git a/res/css/structures/_ToastContainer.scss b/res/css/structures/_ToastContainer.scss index d248568740..2c3f1c705c 100644 --- a/res/css/structures/_ToastContainer.scss +++ b/res/css/structures/_ToastContainer.scss @@ -28,7 +28,7 @@ limitations under the License. margin: 0 4px; grid-row: 2 / 4; grid-column: 1; - background-color: $dark-panel-bg-color; + background-color: $toast-bg-color; box-shadow: 0px 4px 20px rgba(0, 0, 0, 0.5); border-radius: 8px; } @@ -37,7 +37,7 @@ limitations under the License. grid-row: 1 / 3; grid-column: 1; color: $primary-fg-color; - background-color: $dark-panel-bg-color; + background-color: $toast-bg-color; box-shadow: 0px 4px 20px rgba(0, 0, 0, 0.5); border-radius: 8px; overflow: hidden; diff --git a/res/css/views/auth/_InteractiveAuthEntryComponents.scss b/res/css/views/auth/_InteractiveAuthEntryComponents.scss index ffaad3cd7a..ec07b765fd 100644 --- a/res/css/views/auth/_InteractiveAuthEntryComponents.scss +++ b/res/css/views/auth/_InteractiveAuthEntryComponents.scss @@ -85,7 +85,7 @@ limitations under the License. .mx_InteractiveAuthEntryComponents_termsPolicy { display: flex; flex-direction: row; - justify-content: start; + justify-content: flex-start; align-items: center; } diff --git a/res/css/views/context_menus/_IconizedContextMenu.scss b/res/css/views/context_menus/_IconizedContextMenu.scss index 2aa43283b5..ff176eef7e 100644 --- a/res/css/views/context_menus/_IconizedContextMenu.scss +++ b/res/css/views/context_menus/_IconizedContextMenu.scss @@ -149,12 +149,17 @@ limitations under the License. } } - .mx_IconizedContextMenu_checked { + .mx_IconizedContextMenu_checked, + .mx_IconizedContextMenu_unchecked { margin-left: 16px; margin-right: -5px; + } - &::before { - mask-image: url('$(res)/img/element-icons/roomlist/checkmark.svg'); - } + .mx_IconizedContextMenu_checked::before { + mask-image: url('$(res)/img/element-icons/roomlist/checkmark.svg'); + } + + .mx_IconizedContextMenu_unchecked::before { + content: unset; } } diff --git a/res/css/views/context_menus/_TagTileContextMenu.scss b/res/css/views/context_menus/_TagTileContextMenu.scss index d707f4ce7c..14f5ec817e 100644 --- a/res/css/views/context_menus/_TagTileContextMenu.scss +++ b/res/css/views/context_menus/_TagTileContextMenu.scss @@ -51,6 +51,10 @@ limitations under the License. mask-image: url('$(res)/img/element-icons/hide.svg'); } +.mx_TagTileContextMenu_createSpace::before { + mask-image: url('$(res)/img/element-icons/message/fwd.svg'); +} + .mx_TagTileContextMenu_separator { margin-top: 0; margin-bottom: 0; diff --git a/res/css/views/dialogs/_CreateSpaceFromCommunityDialog.scss b/res/css/views/dialogs/_CreateSpaceFromCommunityDialog.scss new file mode 100644 index 0000000000..afa722e05e --- /dev/null +++ b/res/css/views/dialogs/_CreateSpaceFromCommunityDialog.scss @@ -0,0 +1,187 @@ +/* +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_CreateSpaceFromCommunityDialog_wrapper { + .mx_Dialog { + display: flex; + flex-direction: column; + } +} + +.mx_CreateSpaceFromCommunityDialog { + width: 480px; + color: $primary-fg-color; + display: flex; + flex-direction: column; + flex-wrap: nowrap; + min-height: 0; + + .mx_CreateSpaceFromCommunityDialog_content { + > p { + font-size: $font-15px; + line-height: $font-24px; + + &:first-of-type { + margin-top: 0; + } + + &.mx_CreateSpaceFromCommunityDialog_flairNotice { + font-size: $font-12px; + line-height: $font-15px; + } + } + + .mx_SpaceBasicSettings { + > p { + font-size: $font-12px; + line-height: $font-15px; + margin: 16px 0; + } + + .mx_Field_textarea { + margin-bottom: 0; + } + } + + .mx_JoinRuleDropdown .mx_Dropdown_menu { + width: auto !important; // override fixed width + } + + .mx_CreateSpaceFromCommunityDialog_nonPublicSpacer { + height: 63px; // balance the height of the missing room alias field to prevent modal bouncing + } + } + + .mx_CreateSpaceFromCommunityDialog_footer { + display: flex; + margin-top: 20px; + + > span { + flex-grow: 1; + font-size: $font-12px; + line-height: $font-15px; + color: $secondary-fg-color; + + .mx_ProgressBar { + height: 8px; + width: 100%; + + @mixin ProgressBarBorderRadius 8px; + } + + .mx_CreateSpaceFromCommunityDialog_progressText { + margin-top: 8px; + font-size: $font-15px; + line-height: $font-24px; + color: $primary-fg-color; + } + + > * { + vertical-align: middle; + } + } + + .mx_CreateSpaceFromCommunityDialog_error { + padding-left: 12px; + + > img { + align-self: center; + } + + .mx_CreateSpaceFromCommunityDialog_errorHeading { + font-weight: $font-semi-bold; + font-size: $font-15px; + line-height: $font-18px; + color: $notice-primary-color; + } + + .mx_CreateSpaceFromCommunityDialog_errorCaption { + margin-top: 4px; + font-size: $font-12px; + line-height: $font-15px; + color: $primary-fg-color; + } + } + + .mx_AccessibleButton { + display: inline-block; + align-self: center; + } + + .mx_AccessibleButton_kind_primary { + padding: 8px 36px; + margin-left: 24px; + } + + .mx_AccessibleButton_kind_primary_outline { + margin-left: auto; + } + + .mx_CreateSpaceFromCommunityDialog_retryButton { + margin-left: 12px; + padding-left: 24px; + position: relative; + + &::before { + content: ''; + position: absolute; + background-color: $primary-fg-color; + mask-repeat: no-repeat; + mask-position: center; + mask-size: contain; + mask-image: url('$(res)/img/element-icons/retry.svg'); + width: 18px; + height: 18px; + left: 0; + } + } + + .mx_AccessibleButton_kind_link { + padding: 0; + } + } +} + +.mx_CreateSpaceFromCommunityDialog_SuccessInfoDialog { + .mx_InfoDialog { + max-width: 500px; + } + + .mx_AccessibleButton_kind_link { + padding: 0; + } + + .mx_CreateSpaceFromCommunityDialog_SuccessInfoDialog_checkmark { + position: relative; + border-radius: 50%; + border: 3px solid $accent-color; + width: 68px; + height: 68px; + margin: 12px auto 32px; + + &::before { + width: inherit; + height: inherit; + content: ''; + position: absolute; + background-color: $accent-color; + mask-repeat: no-repeat; + mask-position: center; + mask-image: url('$(res)/img/element-icons/roomlist/checkmark.svg'); + mask-size: 48px; + } + } +} diff --git a/res/css/views/elements/_DesktopCapturerSourcePicker.scss b/res/css/views/elements/_DesktopCapturerSourcePicker.scss index 49a0a44417..b4a2c69b86 100644 --- a/res/css/views/elements/_DesktopCapturerSourcePicker.scss +++ b/res/css/views/elements/_DesktopCapturerSourcePicker.scss @@ -24,35 +24,33 @@ limitations under the License. align-items: flex-start; height: 500px; overflow: overlay; - } - .mx_desktopCapturerSourcePicker_source { - display: flex; - flex-direction: column; - margin: 8px; - } + .mx_desktopCapturerSourcePicker_source { + width: 50%; + display: flex; + flex-direction: column; - .mx_desktopCapturerSourcePicker_source_thumbnail { - margin: 4px; - padding: 4px; - width: 312px; - border-width: 2px; - border-radius: 8px; - border-style: solid; - border-color: transparent; + .mx_desktopCapturerSourcePicker_source_thumbnail { + margin: 4px; + padding: 4px; + border-width: 2px; + border-radius: 8px; + border-style: solid; + border-color: transparent; - &.mx_desktopCapturerSourcePicker_source_thumbnail_selected, - &:hover, - &:focus { - border-color: $accent-color; + &.mx_desktopCapturerSourcePicker_source_thumbnail_selected, + &:hover, + &:focus { + border-color: $accent-color; + } + } + + .mx_desktopCapturerSourcePicker_source_name { + margin: 0 4px; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + } } } - - .mx_desktopCapturerSourcePicker_source_name { - margin: 0 4px; - white-space: nowrap; - text-overflow: ellipsis; - overflow: hidden; - width: 312px; - } } diff --git a/res/css/views/elements/_Field.scss b/res/css/views/elements/_Field.scss index cae81dcc97..50cd14c4da 100644 --- a/res/css/views/elements/_Field.scss +++ b/res/css/views/elements/_Field.scss @@ -38,6 +38,7 @@ limitations under the License. .mx_Field input, .mx_Field select, .mx_Field textarea { + font-family: inherit; font-weight: normal; font-size: $font-14px; border: none; diff --git a/res/css/views/messages/_MFileBody.scss b/res/css/views/messages/_MFileBody.scss index 403f671673..d941a8132f 100644 --- a/res/css/views/messages/_MFileBody.scss +++ b/res/css/views/messages/_MFileBody.scss @@ -1,5 +1,5 @@ /* -Copyright 2015, 2016, 2021 The Matrix.org Foundation C.I.C. +Copyright 2015 - 2021 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -60,6 +60,8 @@ limitations under the License. } .mx_MFileBody_info { + cursor: pointer; + .mx_MFileBody_info_icon { background-color: $message-body-panel-icon-bg-color; border-radius: 20px; diff --git a/res/css/views/rooms/_BasicMessageComposer.scss b/res/css/views/rooms/_BasicMessageComposer.scss index e1ba468204..544a96daba 100644 --- a/res/css/views/rooms/_BasicMessageComposer.scss +++ b/res/css/views/rooms/_BasicMessageComposer.scss @@ -65,6 +65,14 @@ limitations under the License. font-size: $font-10-4px; } } + + span.mx_UserPill { + cursor: pointer; + } + + span.mx_RoomPill { + cursor: default; + } } &.mx_BasicMessageComposer_input_disabled { diff --git a/res/css/views/rooms/_EventBubbleTile.scss b/res/css/views/rooms/_EventBubbleTile.scss index 1e25deba26..c6170bf7c0 100644 --- a/res/css/views/rooms/_EventBubbleTile.scss +++ b/res/css/views/rooms/_EventBubbleTile.scss @@ -271,7 +271,7 @@ limitations under the License. display: flex; align-items: center; - justify-content: start; + justify-content: flex-start; padding: 5px 0; .mx_EventTile_avatar { diff --git a/res/css/views/rooms/_EventTile.scss b/res/css/views/rooms/_EventTile.scss index 5de9a9f9d1..56cede0895 100644 --- a/res/css/views/rooms/_EventTile.scss +++ b/res/css/views/rooms/_EventTile.scss @@ -310,14 +310,12 @@ $hover-select-border: 4px; } .mx_RoomView_timeline_rr_enabled { - - .mx_EventTile:not([data-layout=bubble]) { + .mx_EventTile[data-layout=group] { .mx_EventTile_line { /* ideally should be 100px, but 95px gives us a max thumbnail size of 800x600, which is nice */ margin-right: 110px; } } - // on ELS we need the margin to allow interaction with the expand/collapse button which is normally in the RR gutter } @@ -459,8 +457,14 @@ $hover-select-border: 4px; /* Various markdown overrides */ -.mx_EventTile_body pre { - border: 1px solid transparent; +.mx_EventTile_body { + a:hover { + text-decoration: underline; + } + + pre { + border: 1px solid transparent; + } } .mx_EventTile_content .markdown-body { @@ -485,6 +489,10 @@ $hover-select-border: 4px; // https://github.com/vector-im/vector-web/issues/754 overflow-x: overlay; overflow-y: visible; + + &::-webkit-scrollbar-corner { + background: transparent; + } } } diff --git a/res/css/views/rooms/_VoiceRecordComposerTile.scss b/res/css/views/rooms/_VoiceRecordComposerTile.scss index 5501ab343e..8196d5c67a 100644 --- a/res/css/views/rooms/_VoiceRecordComposerTile.scss +++ b/res/css/views/rooms/_VoiceRecordComposerTile.scss @@ -20,7 +20,7 @@ limitations under the License. height: 28px; border: 2px solid $voice-record-stop-border-color; border-radius: 32px; - margin-right: 16px; // between us and the send button + margin-right: 8px; // between us and the waveform component position: relative; &::after { @@ -46,9 +46,28 @@ limitations under the License. mask-image: url('$(res)/img/element-icons/trashcan.svg'); } +.mx_VoiceRecordComposerTile_uploadingState { + margin-right: 10px; + color: $secondary-fg-color; +} + +.mx_VoiceRecordComposerTile_failedState { + margin-right: 21px; + + .mx_VoiceRecordComposerTile_uploadState_badge { + display: inline-block; + margin-right: 4px; + vertical-align: middle; + } +} + .mx_MessageComposer_row .mx_VoiceMessagePrimaryContainer { // Note: remaining class properties are in the PlayerContainer CSS. + // fixed height to reduce layout jumps with the play button appearing + // https://github.com/vector-im/element-web/issues/18431 + height: 32px; + margin: 6px; // force the composer area to put a gutter around us margin-right: 12px; // isolate from stop/send button @@ -68,7 +87,7 @@ limitations under the License. height: 10px; position: absolute; left: 12px; // 12px from the left edge for container padding - top: 18px; // vertically center (middle align with clock) + top: 17px; // vertically center (middle align with clock) border-radius: 10px; } } diff --git a/res/css/views/settings/_LayoutSwitcher.scss b/res/css/views/settings/_LayoutSwitcher.scss new file mode 100644 index 0000000000..924fe5ae1b --- /dev/null +++ b/res/css/views/settings/_LayoutSwitcher.scss @@ -0,0 +1,91 @@ +/* +Copyright 2020 - 2021 The Matrix.org Foundation C.I.C. +Copyright 2021 Šimon Brandner + +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_LayoutSwitcher { + .mx_LayoutSwitcher_RadioButtons { + display: flex; + flex-direction: row; + gap: 24px; + + color: $primary-fg-color; + + > .mx_LayoutSwitcher_RadioButton { + flex-grow: 0; + flex-shrink: 1; + display: flex; + flex-direction: column; + + width: 300px; + + border: 1px solid $appearance-tab-border-color; + border-radius: 10px; + + .mx_EventTile_msgOption, + .mx_MessageActionBar { + display: none; + } + + .mx_LayoutSwitcher_RadioButton_preview { + flex-grow: 1; + display: flex; + align-items: center; + padding: 10px; + pointer-events: none; + } + + .mx_RadioButton { + flex-grow: 0; + padding: 10px; + } + + .mx_EventTile_content { + margin-right: 0; + } + + &.mx_LayoutSwitcher_RadioButton_selected { + border-color: $accent-color; + } + } + + .mx_RadioButton { + border-top: 1px solid $appearance-tab-border-color; + + > input + div { + border-color: rgba($muted-fg-color, 0.2); + } + } + + .mx_RadioButton_checked { + background-color: rgba($accent-color, 0.08); + } + + .mx_EventTile { + margin: 0; + &[data-layout=bubble] { + margin-right: 40px; + } + &[data-layout=irc] { + > a { + display: none; + } + } + .mx_EventTile_line { + max-width: 90%; + } + } + } +} diff --git a/res/css/views/settings/_ProfileSettings.scss b/res/css/views/settings/_ProfileSettings.scss index 4cbcb8e708..63a5fa7edf 100644 --- a/res/css/views/settings/_ProfileSettings.scss +++ b/res/css/views/settings/_ProfileSettings.scss @@ -16,6 +16,7 @@ limitations under the License. .mx_ProfileSettings_controls_topic { & > textarea { + font-family: inherit; resize: vertical; } } diff --git a/res/css/views/settings/tabs/_SettingsTab.scss b/res/css/views/settings/tabs/_SettingsTab.scss index 8e6b99871c..3290a998ab 100644 --- a/res/css/views/settings/tabs/_SettingsTab.scss +++ b/res/css/views/settings/tabs/_SettingsTab.scss @@ -50,15 +50,21 @@ limitations under the License. } .mx_SettingsTab_section { + $right-gutter: 80px; + margin-bottom: 24px; .mx_SettingsFlag { - margin-right: 80px; + margin-right: $right-gutter; margin-bottom: 10px; } + > p { + margin-right: $right-gutter; + } + &.mx_SettingsTab_subsectionText .mx_SettingsFlag { - margin-right: 0px !important; + margin-right: 0 !important; } } @@ -72,6 +78,13 @@ limitations under the License. padding-right: 10px; } +.mx_SettingsTab_section .mx_SettingsFlag .mx_SettingsFlag_microcopy { + margin-top: 4px; + font-size: $font-12px; + line-height: $font-15px; + color: $secondary-fg-color; +} + .mx_SettingsTab_section .mx_SettingsFlag .mx_ToggleSwitch { float: right; } diff --git a/res/css/views/settings/tabs/user/_AppearanceUserSettingsTab.scss b/res/css/views/settings/tabs/user/_AppearanceUserSettingsTab.scss index ca5a6f0a66..d8e617a40d 100644 --- a/res/css/views/settings/tabs/user/_AppearanceUserSettingsTab.scss +++ b/res/css/views/settings/tabs/user/_AppearanceUserSettingsTab.scss @@ -1,5 +1,5 @@ /* -Copyright 2020 The Matrix.org Foundation C.I.C. +Copyright 2020 - 2021 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -155,79 +155,6 @@ limitations under the License. margin-left: calc($font-16px + 10px); } -.mx_AppearanceUserSettingsTab_Layout_RadioButtons { - display: flex; - flex-direction: row; - gap: 24px; - - color: $primary-fg-color; - - > .mx_AppearanceUserSettingsTab_Layout_RadioButton { - flex-grow: 0; - flex-shrink: 1; - display: flex; - flex-direction: column; - - width: 300px; - - border: 1px solid $appearance-tab-border-color; - border-radius: 10px; - - .mx_EventTile_msgOption, - .mx_MessageActionBar { - display: none; - } - - .mx_AppearanceUserSettingsTab_Layout_RadioButton_preview { - flex-grow: 1; - display: flex; - align-items: center; - padding: 10px; - pointer-events: none; - } - - .mx_RadioButton { - flex-grow: 0; - padding: 10px; - } - - .mx_EventTile_content { - margin-right: 0; - } - - &.mx_AppearanceUserSettingsTab_Layout_RadioButton_selected { - border-color: $accent-color; - } - } - - .mx_RadioButton { - border-top: 1px solid $appearance-tab-border-color; - - > input + div { - border-color: rgba($muted-fg-color, 0.2); - } - } - - .mx_RadioButton_checked { - background-color: rgba($accent-color, 0.08); - } - - .mx_EventTile { - margin: 0; - &[data-layout=bubble] { - margin-right: 40px; - } - &[data-layout=irc] { - > a { - display: none; - } - } - .mx_EventTile_line { - max-width: 90%; - } - } -} - .mx_AppearanceUserSettingsTab_Advanced { color: $primary-fg-color; diff --git a/res/css/views/settings/tabs/user/_HelpUserSettingsTab.scss b/res/css/views/settings/tabs/user/_HelpUserSettingsTab.scss index 0f879d209e..fbbe9909e7 100644 --- a/res/css/views/settings/tabs/user/_HelpUserSettingsTab.scss +++ b/res/css/views/settings/tabs/user/_HelpUserSettingsTab.scss @@ -28,28 +28,32 @@ limitations under the License. user-select: all; } -.mx_HelpUserSettingsTab_accessToken { +.mx_HelpUserSettingsTab_copy { display: flex; - justify-content: space-between; border-radius: 5px; border: solid 1px $light-fg-color; margin-bottom: 10px; margin-top: 10px; padding: 10px; -} + width: max-content; -.mx_HelpUserSettingsTab_accessToken_copy { - flex-shrink: 0; - cursor: pointer; - margin-left: 20px; - display: inherit; -} + .mx_HelpUserSettingsTab_copyButton { + flex-shrink: 0; + width: 20px; + height: 20px; + cursor: pointer; + margin-left: 20px; + display: block; -.mx_HelpUserSettingsTab_accessToken_copy > div { - mask-image: url($copy-button-url); - background-color: $message-action-bar-fg-color; - margin-left: 5px; - width: 20px; - height: 20px; - background-repeat: no-repeat; + &::before { + content: ""; + + mask-image: url($copy-button-url); + background-color: $message-action-bar-fg-color; + width: 20px; + height: 20px; + display: block; + background-repeat: no-repeat; + } + } } diff --git a/res/css/views/settings/tabs/user/_PreferencesUserSettingsTab.scss b/res/css/views/settings/tabs/user/_PreferencesUserSettingsTab.scss index be0af9123b..4cdfa0b40f 100644 --- a/res/css/views/settings/tabs/user/_PreferencesUserSettingsTab.scss +++ b/res/css/views/settings/tabs/user/_PreferencesUserSettingsTab.scss @@ -22,4 +22,25 @@ limitations under the License. .mx_SettingsTab_section { margin-bottom: 30px; } + + .mx_PreferencesUserSettingsTab_CommunityMigrator { + margin-right: 200px; + + > div { + font-weight: $font-semi-bold; + font-size: $font-15px; + line-height: $font-18px; + color: $primary-fg-color; + margin: 16px 0; + + .mx_BaseAvatar { + margin-right: 12px; + vertical-align: middle; + } + + .mx_AccessibleButton { + float: right; + } + } + } } diff --git a/res/css/views/spaces/_SpaceCreateMenu.scss b/res/css/views/spaces/_SpaceCreateMenu.scss index 097b2b648e..41536bc8b1 100644 --- a/res/css/views/spaces/_SpaceCreateMenu.scss +++ b/res/css/views/spaces/_SpaceCreateMenu.scss @@ -41,7 +41,6 @@ $spacePanelWidth: 71px; > p { font-size: $font-15px; color: $secondary-fg-color; - margin: 0; } .mx_SpaceFeedbackPrompt { @@ -51,13 +50,6 @@ $spacePanelWidth: 71px; } } - // XXX remove this when spaces leaves Beta - .mx_BetaCard_betaPill { - position: absolute; - top: 24px; - right: 24px; - } - .mx_SpaceCreateMenuType { @mixin SpacePillButton; } @@ -100,6 +92,11 @@ $spacePanelWidth: 71px; width: min-content; } + .mx_AccessibleButton_kind_link { + padding: 0; + font-size: inherit; + } + .mx_AccessibleButton_disabled { cursor: not-allowed; } diff --git a/res/css/views/toasts/_IncomingCallToast.scss b/res/css/views/toasts/_IncomingCallToast.scss new file mode 100644 index 0000000000..975628f948 --- /dev/null +++ b/res/css/views/toasts/_IncomingCallToast.scss @@ -0,0 +1,149 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. +Copyright 2021 Šimon Brandner + +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_IncomingCallToast { + display: flex; + flex-direction: row; + pointer-events: initial; // restore pointer events so the user can accept/decline + + .mx_IncomingCallToast_content { + display: flex; + flex-direction: column; + margin-left: 8px; + + .mx_CallEvent_caller { + font-weight: bold; + font-size: $font-15px; + line-height: $font-18px; + + margin-top: 2px; + } + + .mx_CallEvent_type { + font-size: $font-12px; + line-height: $font-15px; + color: $tertiary-fg-color; + + margin-top: 4px; + margin-bottom: 6px; + + display: flex; + flex-direction: row; + align-items: center; + + .mx_CallEvent_type_icon { + height: 16px; + width: 16px; + margin-right: 6px; + + &::before { + content: ''; + position: absolute; + height: inherit; + width: inherit; + background-color: $tertiary-fg-color; + mask-repeat: no-repeat; + mask-size: contain; + } + } + } + + &.mx_IncomingCallToast_content_voice { + .mx_CallEvent_type .mx_CallEvent_type_icon::before, + .mx_IncomingCallToast_buttons .mx_IncomingCallToast_button_accept span::before { + mask-image: url('$(res)/img/element-icons/call/voice-call.svg'); + } + } + + &.mx_IncomingCallToast_content_video { + .mx_CallEvent_type .mx_CallEvent_type_icon::before, + .mx_IncomingCallToast_buttons .mx_IncomingCallToast_button_accept span::before { + mask-image: url('$(res)/img/element-icons/call/video-call.svg'); + } + } + + .mx_IncomingCallToast_buttons { + margin-top: 8px; + display: flex; + flex-direction: row; + gap: 12px; + + .mx_IncomingCallToast_button { + height: 24px; + padding: 0px 8px; + flex-shrink: 0; + flex-grow: 1; + margin-right: 0; + font-size: $font-15px; + line-height: $font-24px; + + span { + padding: 8px 0; + display: flex; + align-items: center; + + &::before { + content: ''; + display: inline-block; + background-color: $button-fg-color; + mask-position: center; + mask-repeat: no-repeat; + margin-right: 8px; + } + } + + &.mx_IncomingCallToast_button_accept span::before { + mask-size: 13px; + width: 13px; + height: 13px; + } + + &.mx_IncomingCallToast_button_decline span::before { + mask-image: url('$(res)/img/element-icons/call/hangup.svg'); + mask-size: 16px; + width: 16px; + height: 16px; + } + } + } + } + + .mx_IncomingCallToast_iconButton { + display: flex; + height: 20px; + width: 20px; + + &::before { + content: ''; + + height: inherit; + width: inherit; + background-color: $tertiary-fg-color; + mask-repeat: no-repeat; + mask-size: contain; + mask-position: center; + } + } + + .mx_IncomingCallToast_silence::before { + mask-image: url('$(res)/img/voip/silence.svg'); + } + + .mx_IncomingCallToast_unSilence::before { + mask-image: url('$(res)/img/voip/un-silence.svg'); + } +} diff --git a/res/css/views/voip/CallView/_CallViewButtons.scss b/res/css/views/voip/CallView/_CallViewButtons.scss new file mode 100644 index 0000000000..8e343f0ff3 --- /dev/null +++ b/res/css/views/voip/CallView/_CallViewButtons.scss @@ -0,0 +1,102 @@ +/* +Copyright 2015, 2016 OpenMarket Ltd +Copyright 2020 - 2021 The Matrix.org Foundation C.I.C. +Copyright 2021 Šimon Brandner + +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_CallViewButtons { + position: absolute; + display: flex; + justify-content: center; + bottom: 5px; + opacity: 1; + transition: opacity 0.5s; + z-index: 200; // To be above _all_ feeds + + &.mx_CallViewButtons_hidden { + opacity: 0.001; // opacity 0 can cause a re-layout + pointer-events: none; + } + + .mx_CallViewButtons_button { + cursor: pointer; + margin-left: 2px; + margin-right: 2px; + + + &::before { + content: ''; + display: inline-block; + + height: 48px; + width: 48px; + + background-repeat: no-repeat; + background-size: contain; + background-position: center; + } + + + &.mx_CallViewButtons_dialpad::before { + background-image: url('$(res)/img/voip/dialpad.svg'); + } + + &.mx_CallViewButtons_button_micOn::before { + background-image: url('$(res)/img/voip/mic-on.svg'); + } + + &.mx_CallViewButtons_button_micOff::before { + background-image: url('$(res)/img/voip/mic-off.svg'); + } + + &.mx_CallViewButtons_button_vidOn::before { + background-image: url('$(res)/img/voip/vid-on.svg'); + } + + &.mx_CallViewButtons_button_vidOff::before { + background-image: url('$(res)/img/voip/vid-off.svg'); + } + + &.mx_CallViewButtons_button_screensharingOn::before { + background-image: url('$(res)/img/voip/screensharing-on.svg'); + } + + &.mx_CallViewButtons_button_screensharingOff::before { + background-image: url('$(res)/img/voip/screensharing-off.svg'); + } + + &.mx_CallViewButtons_button_sidebarOn::before { + background-image: url('$(res)/img/voip/sidebar-on.svg'); + } + + &.mx_CallViewButtons_button_sidebarOff::before { + background-image: url('$(res)/img/voip/sidebar-off.svg'); + } + + &.mx_CallViewButtons_button_hangup::before { + background-image: url('$(res)/img/voip/hangup.svg'); + } + + &.mx_CallViewButtons_button_more::before { + background-image: url('$(res)/img/voip/more.svg'); + } + + &.mx_CallViewButtons_button_invisible { + visibility: hidden; + pointer-events: none; + position: absolute; + } + } +} diff --git a/res/css/views/voip/_CallContainer.scss b/res/css/views/voip/_CallContainer.scss index 0c09070334..d11ab9bf9f 100644 --- a/res/css/views/voip/_CallContainer.scss +++ b/res/css/views/voip/_CallContainer.scss @@ -28,7 +28,6 @@ limitations under the License. .mx_CallPreview { pointer-events: initial; // restore pointer events so the user can leave/interact - cursor: pointer; .mx_VideoFeed_remote.mx_VideoFeed_voice { min-height: 150px; @@ -43,84 +42,4 @@ limitations under the License. .mx_AppTile_persistedWrapper div { min-width: 350px; } - - .mx_IncomingCallBox { - min-width: 250px; - background-color: $voipcall-plinth-color; - padding: 8px; - box-shadow: 0px 14px 24px rgba(0, 0, 0, 0.08); - border-radius: 8px; - - pointer-events: initial; // restore pointer events so the user can accept/decline - cursor: pointer; - - .mx_IncomingCallBox_CallerInfo { - display: flex; - direction: row; - - img, .mx_BaseAvatar_initial { - margin: 8px; - } - - > div { - display: flex; - flex-direction: column; - - justify-content: center; - } - - h1, p { - margin: 0px; - padding: 0px; - font-size: $font-14px; - line-height: $font-16px; - } - - h1 { - font-weight: bold; - } - } - - .mx_IncomingCallBox_buttons { - padding: 8px; - display: flex; - flex-direction: row; - - > .mx_IncomingCallBox_spacer { - width: 8px; - } - - > * { - flex-shrink: 0; - flex-grow: 1; - margin-right: 0; - font-size: $font-15px; - line-height: $font-24px; - } - } - - .mx_IncomingCallBox_iconButton { - position: absolute; - right: 8px; - - &::before { - content: ''; - - height: 20px; - width: 20px; - background-color: $icon-button-color; - mask-repeat: no-repeat; - mask-size: contain; - mask-position: center; - } - } - - .mx_IncomingCallBox_silence::before { - mask-image: url('$(res)/img/voip/silence.svg'); - } - - .mx_IncomingCallBox_unSilence::before { - mask-image: url('$(res)/img/voip/un-silence.svg'); - } - } } diff --git a/res/css/views/voip/_CallView.scss b/res/css/views/voip/_CallView.scss index 104e2993d8..498dd8e096 100644 --- a/res/css/views/voip/_CallView.scss +++ b/res/css/views/voip/_CallView.scss @@ -39,7 +39,7 @@ limitations under the License. .mx_CallView_pip { width: 320px; padding-bottom: 8px; - background-color: $voipcall-plinth-color; + background-color: $toast-bg-color; box-shadow: 0px 4px 20px rgba(0, 0, 0, 0.20); border-radius: 8px; @@ -47,11 +47,11 @@ limitations under the License. height: 180px; } - .mx_CallView_callControls { + .mx_CallViewButtons { bottom: 0px; } - .mx_CallView_callControls_button { + .mx_CallViewButtons_button { &::before { width: 36px; height: 36px; @@ -75,17 +75,21 @@ limitations under the License. height: 100%; &.mx_VideoFeed_voice { - // We don't want to collide with the call controls that have 52px of height - padding-bottom: 52px; background-color: $inverted-bg-color; display: flex; justify-content: center; align-items: center; } - &.mx_VideoFeed_video { + .mx_VideoFeed_video { + height: 100%; background-color: #000; } + + .mx_VideoFeed_mic { + left: 10px; + bottom: 10px; + } } } @@ -195,133 +199,6 @@ limitations under the License. } } -.mx_CallView_header { - height: 44px; - display: flex; - flex-direction: row; - align-items: center; - justify-content: left; - flex-shrink: 0; -} - -.mx_CallView_header_callType { - font-size: 1.2rem; - font-weight: bold; - vertical-align: middle; -} - -.mx_CallView_header_secondaryCallInfo { - &::before { - content: '·'; - margin-left: 6px; - margin-right: 6px; - } -} - -.mx_CallView_header_controls { - margin-left: auto; -} - -.mx_CallView_header_button { - display: inline-block; - vertical-align: middle; - cursor: pointer; - - &::before { - content: ''; - display: inline-block; - height: 20px; - width: 20px; - vertical-align: middle; - background-color: $secondary-fg-color; - mask-repeat: no-repeat; - mask-size: contain; - mask-position: center; - } -} - -.mx_CallView_header_button_fullscreen { - &::before { - mask-image: url('$(res)/img/element-icons/call/fullscreen.svg'); - } -} - -.mx_CallView_header_button_expand { - &::before { - mask-image: url('$(res)/img/element-icons/call/expand.svg'); - } -} - -.mx_CallView_header_callInfo { - margin-left: 12px; - margin-right: 16px; -} - -.mx_CallView_header_roomName { - font-weight: bold; - font-size: 12px; - line-height: initial; - height: 15px; -} - -.mx_CallView_secondaryCall_roomName { - margin-left: 4px; -} - -.mx_CallView_header_callTypeSmall { - font-size: 12px; - color: $secondary-fg-color; - line-height: initial; - height: 15px; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - max-width: 240px; -} - -.mx_CallView_header_callTypeIcon { - display: inline-block; - margin-right: 6px; - height: 16px; - width: 16px; - vertical-align: middle; - - &::before { - content: ''; - display: inline-block; - vertical-align: top; - - height: 16px; - width: 16px; - background-color: $secondary-fg-color; - mask-repeat: no-repeat; - mask-size: contain; - mask-position: center; - } - - &.mx_CallView_header_callTypeIcon_voice::before { - mask-image: url('$(res)/img/element-icons/call/voice-call.svg'); - } - - &.mx_CallView_header_callTypeIcon_video::before { - mask-image: url('$(res)/img/element-icons/call/video-call.svg'); - } -} - -.mx_CallView_callControls { - position: absolute; - display: flex; - justify-content: center; - bottom: 5px; - opacity: 1; - transition: opacity 0.5s; - z-index: 200; // To be above _all_ feeds -} - -.mx_CallView_callControls_hidden { - opacity: 0.001; // opacity 0 can cause a re-layout - pointer-events: none; -} .mx_CallView_presenting { opacity: 1; @@ -341,94 +218,3 @@ limitations under the License. opacity: 0.001; // opacity 0 can cause a re-layout pointer-events: none; } - -.mx_CallView_callControls_button { - cursor: pointer; - margin-left: 2px; - margin-right: 2px; - - - &::before { - content: ''; - display: inline-block; - - height: 48px; - width: 48px; - - background-repeat: no-repeat; - background-size: contain; - background-position: center; - } -} - -.mx_CallView_callControls_dialpad { - &::before { - background-image: url('$(res)/img/voip/dialpad.svg'); - } -} - -.mx_CallView_callControls_button_micOn { - &::before { - background-image: url('$(res)/img/voip/mic-on.svg'); - } -} - -.mx_CallView_callControls_button_micOff { - &::before { - background-image: url('$(res)/img/voip/mic-off.svg'); - } -} - -.mx_CallView_callControls_button_vidOn { - &::before { - background-image: url('$(res)/img/voip/vid-on.svg'); - } -} - -.mx_CallView_callControls_button_vidOff { - &::before { - background-image: url('$(res)/img/voip/vid-off.svg'); - } -} - -.mx_CallView_callControls_button_screensharingOn { - &::before { - background-image: url('$(res)/img/voip/screensharing-on.svg'); - } -} - -.mx_CallView_callControls_button_screensharingOff { - &::before { - background-image: url('$(res)/img/voip/screensharing-off.svg'); - } -} - -.mx_CallView_callControls_button_sidebarOn { - &::before { - background-image: url('$(res)/img/voip/sidebar-on.svg'); - } -} - -.mx_CallView_callControls_button_sidebarOff { - &::before { - background-image: url('$(res)/img/voip/sidebar-off.svg'); - } -} - -.mx_CallView_callControls_button_hangup { - &::before { - background-image: url('$(res)/img/voip/hangup.svg'); - } -} - -.mx_CallView_callControls_button_more { - &::before { - background-image: url('$(res)/img/voip/more.svg'); - } -} - -.mx_CallView_callControls_button_invisible { - visibility: hidden; - pointer-events: none; - position: absolute; -} diff --git a/res/css/views/voip/_CallViewHeader.scss b/res/css/views/voip/_CallViewHeader.scss new file mode 100644 index 0000000000..014cfce478 --- /dev/null +++ b/res/css/views/voip/_CallViewHeader.scss @@ -0,0 +1,129 @@ +/* +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_CallViewHeader { + height: 44px; + display: flex; + flex-direction: row; + align-items: center; + justify-content: left; + flex-shrink: 0; + cursor: pointer; +} + +.mx_CallViewHeader_callType { + font-size: 1.2rem; + font-weight: bold; + vertical-align: middle; +} + +.mx_CallViewHeader_secondaryCallInfo { + &::before { + content: '·'; + margin-left: 6px; + margin-right: 6px; + } +} + +.mx_CallViewHeader_controls { + margin-left: auto; +} + +.mx_CallViewHeader_button { + display: inline-block; + vertical-align: middle; + cursor: pointer; + + &::before { + content: ''; + display: inline-block; + height: 20px; + width: 20px; + vertical-align: middle; + background-color: $secondary-fg-color; + mask-repeat: no-repeat; + mask-size: contain; + mask-position: center; + } +} + +.mx_CallViewHeader_button_fullscreen { + &::before { + mask-image: url('$(res)/img/element-icons/call/fullscreen.svg'); + } +} + +.mx_CallViewHeader_button_expand { + &::before { + mask-image: url('$(res)/img/element-icons/call/expand.svg'); + } +} + +.mx_CallViewHeader_callInfo { + margin-left: 12px; + margin-right: 16px; +} + +.mx_CallViewHeader_roomName { + font-weight: bold; + font-size: 12px; + line-height: initial; + height: 15px; +} + +.mx_CallView_secondaryCall_roomName { + margin-left: 4px; +} + +.mx_CallViewHeader_callTypeSmall { + font-size: 12px; + color: $secondary-fg-color; + line-height: initial; + height: 15px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + max-width: 240px; +} + +.mx_CallViewHeader_callTypeIcon { + display: inline-block; + margin-right: 6px; + height: 16px; + width: 16px; + vertical-align: middle; + + &::before { + content: ''; + display: inline-block; + vertical-align: top; + + height: 16px; + width: 16px; + background-color: $secondary-fg-color; + mask-repeat: no-repeat; + mask-size: contain; + mask-position: center; + } + + &.mx_CallViewHeader_callTypeIcon_voice::before { + mask-image: url('$(res)/img/element-icons/call/voice-call.svg'); + } + + &.mx_CallViewHeader_callTypeIcon_video::before { + mask-image: url('$(res)/img/element-icons/call/video-call.svg'); + } +} diff --git a/res/css/views/voip/_CallViewSidebar.scss b/res/css/views/voip/_CallViewSidebar.scss index 79bf3cbf09..dbadc22028 100644 --- a/res/css/views/voip/_CallViewSidebar.scss +++ b/res/css/views/voip/_CallViewSidebar.scss @@ -35,11 +35,20 @@ limitations under the License. width: 100%; &.mx_VideoFeed_voice { + border-radius: 4px; + display: flex; align-items: center; justify-content: center; + } - aspect-ratio: 16 / 9; + .mx_VideoFeed_video { + border-radius: 4px; + } + + .mx_VideoFeed_mic { + left: 6px; + bottom: 6px; } } diff --git a/res/css/views/voip/_DialPadContextMenu.scss b/res/css/views/voip/_DialPadContextMenu.scss index 0019994e72..527d223ffc 100644 --- a/res/css/views/voip/_DialPadContextMenu.scss +++ b/res/css/views/voip/_DialPadContextMenu.scss @@ -69,7 +69,6 @@ limitations under the License. overflow: hidden; max-width: 185px; text-align: left; - direction: rtl; padding: 8px 0px; background-color: rgb(0, 0, 0, 0); } diff --git a/res/css/views/voip/_VideoFeed.scss b/res/css/views/voip/_VideoFeed.scss index 07a4a0e530..7a8d39dfe3 100644 --- a/res/css/views/voip/_VideoFeed.scss +++ b/res/css/views/voip/_VideoFeed.scss @@ -15,18 +15,53 @@ limitations under the License. */ .mx_VideoFeed { - border-radius: 4px; - + overflow: hidden; + position: relative; &.mx_VideoFeed_voice { background-color: $inverted-bg-color; + aspect-ratio: 16 / 9; } - &.mx_VideoFeed_video { + .mx_VideoFeed_video { + width: 100%; background-color: transparent; + + &.mx_VideoFeed_video_mirror { + transform: scale(-1, 1); + } + } + + .mx_VideoFeed_mic { + position: absolute; + display: flex; + align-items: center; + justify-content: center; + + width: 24px; + height: 24px; + + background-color: rgba(0, 0, 0, 0.5); // Same on both themes + border-radius: 100%; + + &::before { + position: absolute; + content: ""; + width: 16px; + height: 16px; + mask-repeat: no-repeat; + mask-size: contain; + mask-position: center; + background-color: white; // Same on both themes + border-radius: 7px; + } + + &.mx_VideoFeed_mic_muted::before { + mask-image: url('$(res)/img/voip/mic-muted.svg'); + } + + &.mx_VideoFeed_mic_unmuted::before { + mask-image: url('$(res)/img/voip/mic-unmuted.svg'); + } } } - -.mx_VideoFeed_mirror { - transform: scale(-1, 1); -} diff --git a/res/img/element-icons/room/pin.svg b/res/img/element-icons/room/pin.svg index 2448fc61c5..f090f60be8 100644 --- a/res/img/element-icons/room/pin.svg +++ b/res/img/element-icons/room/pin.svg @@ -1,7 +1,3 @@ - - - - - + diff --git a/res/img/voip/mic-muted.svg b/res/img/voip/mic-muted.svg new file mode 100644 index 0000000000..0cb7ad1c9e --- /dev/null +++ b/res/img/voip/mic-muted.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/res/img/voip/mic-unmuted.svg b/res/img/voip/mic-unmuted.svg new file mode 100644 index 0000000000..8334cafa0a --- /dev/null +++ b/res/img/voip/mic-unmuted.svg @@ -0,0 +1,4 @@ + + + + diff --git a/res/themes/dark/css/_dark.scss b/res/themes/dark/css/_dark.scss index 655492661c..9a85c9d2b0 100644 --- a/res/themes/dark/css/_dark.scss +++ b/res/themes/dark/css/_dark.scss @@ -1,15 +1,35 @@ +// Colors from Figma Compound https://www.figma.com/file/X4XTH9iS2KGJ2wFKDqkyed/Compound?node-id=559%3A741 +$accent: #0DBD8B; +$alert: #FF5B55; +$links: #0086e6; +$primary-content: #ffffff; +$secondary-content: #A9B2BC; +$tertiary-content: #8E99A4; +$quaternary-content: #6F7882; +$quinary-content: #394049; +$system-dark: #21262C; +$background: #15191E; +$panels: rgba($system-dark, 0.9); +$panel-base: #8D97A5; // This color is not intended for use in the app +$panel-selected: rgba($panel-base, 0.3); +$panel-hover: rgba($panel-base, 0.1); +$panel-actions: rgba($panel-base, 0.2); +$space-nav: rgba($panel-base, 0.1); + +// TODO: Move userId colors here + // unified palette // try to use these colors when possible -$bg-color: #15191E; +$bg-color: $background; $base-color: $bg-color; -$base-text-color: #ffffff; +$base-text-color: $primary-content; $header-panel-bg-color: #20252B; $header-panel-border-color: #000000; $header-panel-text-primary-color: #B9BEC6; $header-panel-text-secondary-color: #c8c8cd; -$text-primary-color: #ffffff; +$text-primary-color: $primary-content; $text-secondary-color: #B9BEC6; -$quaternary-fg-color: #6F7882; +$quaternary-fg-color: $quaternary-content; $search-bg-color: #181b21; $search-placeholder-color: #61708b; $room-highlight-color: #343a46; @@ -20,8 +40,8 @@ $primary-bg-color: $bg-color; $muted-fg-color: $header-panel-text-primary-color; // additional text colors -$secondary-fg-color: #A9B2BC; -$tertiary-fg-color: #8E99A4; +$secondary-fg-color: $secondary-content; +$tertiary-fg-color: $tertiary-content; // used for dialog box text $light-fg-color: $header-panel-text-secondary-color; @@ -47,7 +67,7 @@ $inverted-bg-color: $base-color; $selected-color: $room-highlight-color; // selected for hoverover & selected event tiles -$event-selected-color: #21262c; +$event-selected-color: $system-dark; // used for the hairline dividers in RoomView $primary-hairline-color: transparent; @@ -91,7 +111,7 @@ $lightbox-background-bg-color: #000; $lightbox-background-bg-opacity: 0.85; $settings-grey-fg-color: #a2a2a2; -$settings-profile-placeholder-bg-color: #21262c; +$settings-profile-placeholder-bg-color: $system-dark; $settings-profile-overlay-placeholder-fg-color: #454545; $settings-profile-button-bg-color: #e7e7e7; $settings-profile-button-fg-color: $settings-profile-overlay-placeholder-fg-color; @@ -105,20 +125,19 @@ $roomheader-addroom-fg-color: $text-primary-color; $groupFilterPanel-button-color: $header-panel-text-primary-color; $groupheader-button-color: $header-panel-text-primary-color; $rightpanel-button-color: $header-panel-text-primary-color; -$icon-button-color: #8E99A4; +$icon-button-color: $tertiary-content; $roomtopic-color: $text-secondary-color; $eventtile-meta-color: $roomtopic-color; $header-divider-color: $header-panel-text-primary-color; $composer-e2e-icon-color: $header-panel-text-primary-color; -// this probably shouldn't have it's own colour -$voipcall-plinth-color: #394049; +$toast-bg-color: $quinary-content; // ******************** $theme-button-bg-color: #e3e8f0; -$dialpad-button-bg-color: #394049; +$dialpad-button-bg-color: $quinary-content; $roomlist-button-bg-color: rgba(141, 151, 165, 0.2); // Buttons include the filter box, explore button, and sublist buttons $roomlist-filter-active-bg-color: $bg-color; @@ -161,12 +180,12 @@ $tab-label-icon-bg-color: $text-primary-color; $tab-label-active-icon-bg-color: $text-primary-color; // Buttons -$button-primary-fg-color: #ffffff; +$button-primary-fg-color: $primary-content; $button-primary-bg-color: $accent-color; $button-secondary-bg-color: transparent; -$button-danger-fg-color: #ffffff; +$button-danger-fg-color: $primary-content; $button-danger-bg-color: $notice-primary-color; -$button-danger-disabled-fg-color: #ffffff; +$button-danger-disabled-fg-color: $primary-content; $button-danger-disabled-bg-color: #f5b6bb; // TODO: Verify color $button-link-fg-color: $accent-color; $button-link-bg-color: transparent; @@ -175,7 +194,7 @@ $button-link-bg-color: transparent; $togglesw-off-color: $room-highlight-color; $progressbar-fg-color: $accent-color; -$progressbar-bg-color: #21262c; +$progressbar-bg-color: $system-dark; $visual-bell-bg-color: #800; @@ -198,19 +217,19 @@ $reaction-row-button-selected-border-color: $accent-color; $kbd-border-color: #000000; $tooltip-timeline-bg-color: $groupFilterPanel-bg-color; -$tooltip-timeline-fg-color: #ffffff; +$tooltip-timeline-fg-color: $primary-content; $interactive-tooltip-bg-color: $base-color; -$interactive-tooltip-fg-color: #ffffff; +$interactive-tooltip-fg-color: $primary-content; $breadcrumb-placeholder-bg-color: #272c35; $user-tile-hover-bg-color: $header-panel-bg-color; $message-body-panel-fg-color: $secondary-fg-color; -$message-body-panel-bg-color: #394049; // "Dark Tile" +$message-body-panel-bg-color: $quinary-content; $message-body-panel-icon-fg-color: $secondary-fg-color; -$message-body-panel-icon-bg-color: #21262C; // "System Dark" +$message-body-panel-icon-bg-color: $system-dark; // "System Dark" $voice-record-stop-border-color: $quaternary-fg-color; $voice-record-waveform-incomplete-fg-color: $quaternary-fg-color; @@ -228,9 +247,9 @@ $groupFilterPanel-background-blur-amount: 30px; $composer-shadow-color: rgba(0, 0, 0, 0.28); // Bubble tiles -$eventbubble-self-bg: #143A34; -$eventbubble-others-bg: #394049; -$eventbubble-bg-hover: #433C23; +$eventbubble-self-bg: #14322E; +$eventbubble-others-bg: $event-selected-color; +$eventbubble-bg-hover: #1C2026; $eventbubble-avatar-outline: $bg-color; $eventbubble-reply-color: #C1C6CD; diff --git a/res/themes/legacy-dark/css/_legacy-dark.scss b/res/themes/legacy-dark/css/_legacy-dark.scss index 0c0197cfb0..b9429318ac 100644 --- a/res/themes/legacy-dark/css/_legacy-dark.scss +++ b/res/themes/legacy-dark/css/_legacy-dark.scss @@ -111,8 +111,8 @@ $eventtile-meta-color: $roomtopic-color; $header-divider-color: $header-panel-text-primary-color; $composer-e2e-icon-color: $header-panel-text-primary-color; -// this probably shouldn't have it's own colour -$voipcall-plinth-color: #394049; +$quinary-content-color: #394049; +$toast-bg-color: $quinary-content-color; // ******************** @@ -222,6 +222,13 @@ $appearance-tab-border-color: $room-highlight-color; $composer-shadow-color: tranparent; +// Bubble tiles +$eventbubble-self-bg: #14322E; +$eventbubble-others-bg: $event-selected-color; +$eventbubble-bg-hover: #1C2026; +$eventbubble-avatar-outline: $bg-color; +$eventbubble-reply-color: #C1C6CD; + // ***** Mixins! ***** @define-mixin mx_DialogButton { diff --git a/res/themes/legacy-light/css/_legacy-light.scss b/res/themes/legacy-light/css/_legacy-light.scss index b7d45452ff..1a63c9bd07 100644 --- a/res/themes/legacy-light/css/_legacy-light.scss +++ b/res/themes/legacy-light/css/_legacy-light.scss @@ -8,9 +8,12 @@ /* Noto Color Emoji contains digits, in fixed-width, therefore causing digits in flowed text to stand out. TODO: Consider putting all emoji fonts to the end rather than the front. */ -$font-family: Nunito, Twemoji, 'Apple Color Emoji', 'Segoe UI Emoji', Arial, Helvetica, Sans-Serif, 'Noto Color Emoji'; +$font-family: 'Nunito', 'Twemoji', 'Apple Color Emoji', 'Segoe UI Emoji', 'Arial', 'Helvetica', sans-serif, 'Noto Color Emoji'; -$monospace-font-family: Inconsolata, Twemoji, 'Apple Color Emoji', 'Segoe UI Emoji', Courier, monospace, 'Noto Color Emoji'; +$monospace-font-family: 'Inconsolata', 'Twemoji', 'Apple Color Emoji', 'Segoe UI Emoji', 'Courier', monospace, 'Noto Color Emoji'; + +// Colors from Figma Compound https://www.figma.com/file/X4XTH9iS2KGJ2wFKDqkyed/Compound?node-id=557%3A0 +$system-light: #F4F6FA; // unified palette // try to use these colors when possible @@ -178,8 +181,8 @@ $eventtile-meta-color: $roomtopic-color; $composer-e2e-icon-color: #91a1c0; $header-divider-color: #91a1c0; -// this probably shouldn't have it's own colour -$voipcall-plinth-color: #F4F6FA; +$toast-bg-color: $system-light; +$voipcall-plinth-color: $system-light; // ******************** @@ -331,7 +334,7 @@ $user-tile-hover-bg-color: $header-panel-bg-color; $message-body-panel-fg-color: $secondary-fg-color; $message-body-panel-bg-color: #E3E8F0; $message-body-panel-icon-fg-color: $secondary-fg-color; -$message-body-panel-icon-bg-color: #F4F6FA; +$message-body-panel-icon-bg-color: $system-light; // See non-legacy _light for variable information $voice-record-stop-symbol-color: #ff4b55; @@ -348,9 +351,9 @@ $appearance-tab-border-color: $input-darker-bg-color; $composer-shadow-color: tranparent; // Bubble tiles -$eventbubble-self-bg: #F8FDFC; -$eventbubble-others-bg: #F7F8F9; -$eventbubble-bg-hover: rgb(242, 242, 242); +$eventbubble-self-bg: #F0FBF8; +$eventbubble-others-bg: $system-light; +$eventbubble-bg-hover: #FAFBFD; $eventbubble-avatar-outline: #fff; $eventbubble-reply-color: #C1C6CD; @@ -390,7 +393,7 @@ $eventbubble-reply-color: #C1C6CD; @define-mixin mx_DialogButton_secondary { // flip colours for the secondary ones font-weight: 600; - border: 1px solid $accent-color ! important; + border: 1px solid $accent-color !important; color: $accent-color; background-color: $button-secondary-bg-color; } diff --git a/res/themes/light-custom/css/_custom.scss b/res/themes/light-custom/css/_custom.scss index 1b9254d100..6c37351414 100644 --- a/res/themes/light-custom/css/_custom.scss +++ b/res/themes/light-custom/css/_custom.scss @@ -140,3 +140,10 @@ $event-highlight-bg-color: var(--timeline-highlights-color); // // redirect some variables away from their hardcoded values in the light theme $settings-grey-fg-color: $primary-fg-color; + +// --eventbubble colors +$eventbubble-self-bg: var(--eventbubble-self-bg, $eventbubble-self-bg); +$eventbubble-others-bg: var(--eventbubble-others-bg, $eventbubble-others-bg); +$eventbubble-bg-hover: var(--eventbubble-bg-hover, $eventbubble-bg-hover); +$eventbubble-avatar-outline: var(--eventbubble-avatar-outline, $eventbubble-avatar-outline); +$eventbubble-reply-color: var(--eventbubble-reply-color, $eventbubble-reply-color); diff --git a/res/themes/light/css/_light.scss b/res/themes/light/css/_light.scss index 32722515d8..0f7ede4ec9 100644 --- a/res/themes/light/css/_light.scss +++ b/res/themes/light/css/_light.scss @@ -8,24 +8,43 @@ /* Noto Color Emoji contains digits, in fixed-width, therefore causing digits in flowed text to stand out. TODO: Consider putting all emoji fonts to the end rather than the front. */ -$font-family: Inter, Twemoji, 'Apple Color Emoji', 'Segoe UI Emoji', Arial, Helvetica, Sans-Serif, 'Noto Color Emoji'; +$font-family: 'Inter', 'Twemoji', 'Apple Color Emoji', 'Segoe UI Emoji', 'Arial', 'Helvetica', sans-serif, 'Noto Color Emoji'; -$monospace-font-family: Inconsolata, Twemoji, 'Apple Color Emoji', 'Segoe UI Emoji', Courier, monospace, 'Noto Color Emoji'; +$monospace-font-family: 'Inconsolata', 'Twemoji', 'Apple Color Emoji', 'Segoe UI Emoji', 'Courier', monospace, 'Noto Color Emoji'; + +// Colors from Figma Compound https://www.figma.com/file/X4XTH9iS2KGJ2wFKDqkyed/Compound?node-id=559%3A120 +$accent: #0DBD8B; +$alert: #FF5B55; +$links: #0086e6; +$primary-content: #17191C; +$secondary-content: #737D8C; +$tertiary-content: #8D97A5; +$quaternary-content: #c1c6cd; +$quinary-content: #E3E8F0; +$system-light: #F4F6FA; +$background: #ffffff; +$panels: rgba($system-light, 0.9); +$panel-selected: rgba($tertiary-content, 0.3); +$panel-hover: rgba($tertiary-content, 0.1); +$panel-actions: rgba($tertiary-content, 0.2); +$space-nav: rgba($tertiary-content, 0.15); + +// TODO: Move userId colors here // unified palette // try to use these colors when possible -$accent-color: #0DBD8B; +$accent-color: $accent; $accent-bg-color: rgba(3, 179, 129, 0.16); $notice-primary-color: #ff4b55; $notice-primary-bg-color: rgba(255, 75, 85, 0.16); $primary-fg-color: #2e2f32; -$secondary-fg-color: #737D8C; +$secondary-fg-color: $secondary-content; $tertiary-fg-color: #8D99A5; -$quaternary-fg-color: #C1C6CD; +$quaternary-fg-color: $quaternary-content; $header-panel-bg-color: #f3f8fd; // typical text (dark-on-white in light skin) -$primary-bg-color: #ffffff; +$primary-bg-color: $background; $muted-fg-color: #61708b; // Commonly used in headings and relevant alt text // used for dialog box text @@ -35,7 +54,7 @@ $light-fg-color: #747474; $focus-bg-color: #dddddd; // button UI (white-on-green in light skin) -$accent-fg-color: #ffffff; +$accent-fg-color: $background; $accent-color-50pct: rgba($accent-color, 0.5); $accent-color-darker: #92caad; $accent-color-alt: #238CF5; @@ -79,7 +98,7 @@ $primary-hairline-color: transparent; // used for the border of input text fields $input-border-color: #e7e7e7; -$input-darker-bg-color: #e3e8f0; +$input-darker-bg-color: $quinary-content; $input-darker-fg-color: #9fa9ba; $input-lighter-bg-color: #f2f5f8; $input-lighter-fg-color: $input-darker-fg-color; @@ -87,7 +106,7 @@ $input-focused-border-color: #238cf5; $input-valid-border-color: $accent-color; $input-invalid-border-color: $warning-color; -$field-focused-label-bg-color: #ffffff; +$field-focused-label-bg-color: $background; $button-bg-color: $accent-color; $button-fg-color: white; @@ -109,8 +128,8 @@ $menu-bg-color: #fff; $menu-box-shadow-color: rgba(118, 131, 156, 0.6); $menu-selected-color: #f5f8fa; -$avatar-initial-color: #ffffff; -$avatar-bg-color: #ffffff; +$avatar-initial-color: $background; +$avatar-bg-color: $background; $h3-color: #3d3b39; @@ -138,7 +157,7 @@ $blockquote-bar-color: #ddd; $blockquote-fg-color: #777; $settings-grey-fg-color: #a2a2a2; -$settings-profile-placeholder-bg-color: #f4f6fa; +$settings-profile-placeholder-bg-color: $system-light; $settings-profile-overlay-placeholder-fg-color: #2e2f32; $settings-profile-button-bg-color: #e7e7e7; $settings-profile-button-fg-color: $settings-profile-overlay-placeholder-fg-color; @@ -160,24 +179,24 @@ $roomheader-addroom-fg-color: #5c6470; $groupFilterPanel-button-color: #91A1C0; $groupheader-button-color: #91A1C0; $rightpanel-button-color: #91A1C0; -$icon-button-color: #C1C6CD; +$icon-button-color: $quaternary-content; $roomtopic-color: #9e9e9e; $eventtile-meta-color: $roomtopic-color; $composer-e2e-icon-color: #91A1C0; $header-divider-color: #91A1C0; -// this probably shouldn't have it's own colour -$voipcall-plinth-color: #F4F6FA; +$toast-bg-color: $system-light; +$voipcall-plinth-color: $system-light; // ******************** -$theme-button-bg-color: #e3e8f0; -$dialpad-button-bg-color: #e3e8f0; +$theme-button-bg-color: $quinary-content; +$dialpad-button-bg-color: $quinary-content; $roomlist-button-bg-color: rgba(141, 151, 165, 0.2); // Buttons include the filter box, explore button, and sublist buttons -$roomlist-filter-active-bg-color: #ffffff; +$roomlist-filter-active-bg-color: $background; $roomlist-bg-color: rgba(245, 245, 245, 0.90); $roomlist-header-color: $tertiary-fg-color; $roomsublist-divider-color: $primary-fg-color; @@ -191,7 +210,7 @@ $roomtile-selected-bg-color: #FFF; $presence-online: $accent-color; $presence-away: #d9b072; -$presence-offline: #E3E8F0; +$presence-offline: $quinary-content; // ******************** @@ -254,7 +273,7 @@ $lightbox-border-color: #ffffff; // Tabbed views $tab-label-fg-color: #45474a; -$tab-label-active-fg-color: #ffffff; +$tab-label-active-fg-color: $background; $tab-label-bg-color: transparent; $tab-label-active-bg-color: $accent-color; $tab-label-icon-bg-color: #454545; @@ -264,9 +283,9 @@ $tab-label-active-icon-bg-color: $tab-label-active-fg-color; $button-primary-fg-color: #ffffff; $button-primary-bg-color: $accent-color; $button-secondary-bg-color: $accent-fg-color; -$button-danger-fg-color: #ffffff; +$button-danger-fg-color: $background; $button-danger-bg-color: $notice-primary-color; -$button-danger-disabled-fg-color: #ffffff; +$button-danger-disabled-fg-color: $background; $button-danger-disabled-bg-color: #f5b6bb; // TODO: Verify color $button-link-fg-color: $accent-color; $button-link-bg-color: transparent; @@ -291,7 +310,7 @@ $memberstatus-placeholder-color: $muted-fg-color; $authpage-bg-color: #2e3649; $authpage-modal-bg-color: rgba(245, 245, 245, 0.90); -$authpage-body-bg-color: #ffffff; +$authpage-body-bg-color: $background; $authpage-focus-bg-color: #dddddd; $authpage-lang-color: #4e5054; $authpage-primary-color: #232f32; @@ -315,26 +334,26 @@ $kbd-border-color: $reaction-row-button-border-color; $inverted-bg-color: #27303a; $tooltip-timeline-bg-color: $inverted-bg-color; -$tooltip-timeline-fg-color: #ffffff; +$tooltip-timeline-fg-color: $background; $interactive-tooltip-bg-color: #27303a; -$interactive-tooltip-fg-color: #ffffff; +$interactive-tooltip-fg-color: $background; $breadcrumb-placeholder-bg-color: #e8eef5; $user-tile-hover-bg-color: $header-panel-bg-color; $message-body-panel-fg-color: $secondary-fg-color; -$message-body-panel-bg-color: #E3E8F0; // "Separator" +$message-body-panel-bg-color: $quinary-content; $message-body-panel-icon-fg-color: $secondary-fg-color; -$message-body-panel-icon-bg-color: #F4F6FA; +$message-body-panel-icon-bg-color: $system-light; // These two don't change between themes. They are the $warning-color, but we don't // want custom themes to affect them by accident. $voice-record-stop-symbol-color: #ff4b55; $voice-record-live-circle-color: #ff4b55; -$voice-record-stop-border-color: #E3E8F0; // "Separator" +$voice-record-stop-border-color: $quinary-content; $voice-record-waveform-incomplete-fg-color: $quaternary-fg-color; $voice-record-icon-color: $tertiary-fg-color; $voice-playback-button-bg-color: $message-body-panel-icon-bg-color; @@ -350,11 +369,11 @@ $groupFilterPanel-background-blur-amount: 20px; $composer-shadow-color: rgba(0, 0, 0, 0.04); // Bubble tiles -$eventbubble-self-bg: #F8FDFC; -$eventbubble-others-bg: #F7F8F9; -$eventbubble-bg-hover: #FEFCF5; +$eventbubble-self-bg: #F0FBF8; +$eventbubble-others-bg: $system-light; +$eventbubble-bg-hover: #FAFBFD; $eventbubble-avatar-outline: $primary-bg-color; -$eventbubble-reply-color: #C1C6CD; +$eventbubble-reply-color: $quaternary-content; // ***** Mixins! ***** @@ -392,7 +411,7 @@ $eventbubble-reply-color: #C1C6CD; @define-mixin mx_DialogButton_secondary { // flip colours for the secondary ones font-weight: 600; - border: 1px solid $accent-color ! important; + border: 1px solid $accent-color !important; color: $accent-color; background-color: $button-secondary-bg-color; } diff --git a/src/ActiveRoomObserver.ts b/src/ActiveRoomObserver.ts index 0be49a24ea..c7423fab8f 100644 --- a/src/ActiveRoomObserver.ts +++ b/src/ActiveRoomObserver.ts @@ -14,8 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ -import RoomViewStore from './stores/RoomViewStore'; import { EventSubscription } from 'fbemitter'; +import RoomViewStore from './stores/RoomViewStore'; type Listener = (isActive: boolean) => void; diff --git a/src/CallHandler.tsx b/src/CallHandler.tsx index e7c1dda54f..41571666c3 100644 --- a/src/CallHandler.tsx +++ b/src/CallHandler.tsx @@ -1,7 +1,8 @@ /* Copyright 2015, 2016 OpenMarket Ltd Copyright 2017, 2018 New Vector Ltd -Copyright 2019, 2020 The Matrix.org Foundation C.I.C. +Copyright 2019 - 2021 The Matrix.org Foundation C.I.C. +Copyright 2021 Šimon Brandner Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -60,7 +61,6 @@ import Modal from './Modal'; import { _t } from './languageHandler'; import dis from './dispatcher/dispatcher'; import WidgetUtils from './utils/WidgetUtils'; -import WidgetEchoStore from './stores/WidgetEchoStore'; import SettingsStore from './settings/SettingsStore'; import { Jitsi } from "./widgets/Jitsi"; import { WidgetType } from "./widgets/WidgetType"; @@ -86,6 +86,12 @@ import { randomUppercaseString, randomLowercaseString } from "matrix-js-sdk/src/ import EventEmitter from 'events'; import SdkConfig from './SdkConfig'; import { ensureDMExists, findDMForUser } from './createRoom'; +import { IPushRule, RuleId, TweakName, Tweaks } from "matrix-js-sdk/src/@types/PushRules"; +import { PushProcessor } from 'matrix-js-sdk/src/pushprocessor'; +import { WidgetLayoutStore, Container } from './stores/widgets/WidgetLayoutStore'; +import { getIncomingCallToastKey } from './toasts/IncomingCallToast'; +import ToastStore from './stores/ToastStore'; +import IncomingCallToast from "./toasts/IncomingCallToast"; export const PROTOCOL_PSTN = 'm.protocol.pstn'; export const PROTOCOL_PSTN_PREFIXED = 'im.vector.protocol.pstn'; @@ -476,26 +482,44 @@ export default class CallHandler extends EventEmitter { } switch (newState) { - case CallState.Ringing: - this.play(AudioID.Ring); + case CallState.Ringing: { + const incomingCallPushRule = ( + new PushProcessor(MatrixClientPeg.get()).getPushRuleById(RuleId.IncomingCall) as IPushRule + ); + const pushRuleEnabled = incomingCallPushRule?.enabled; + const tweakSetToRing = incomingCallPushRule?.actions.some((action: Tweaks) => ( + action.set_tweak === TweakName.Sound && + action.value === "ring" + )); + + if (pushRuleEnabled && tweakSetToRing) { + this.play(AudioID.Ring); + } else { + this.silenceCall(call.callId); + } break; - case CallState.InviteSent: + } + case CallState.InviteSent: { this.play(AudioID.Ringback); break; - case CallState.Ended: - { + } + case CallState.Ended: { const hangupReason = call.hangupReason; Analytics.trackEvent('voip', 'callEnded', 'hangupReason', hangupReason); this.removeCallForRoom(mappedRoomId); if (oldState === CallState.InviteSent && call.hangupParty === CallParty.Remote) { this.play(AudioID.Busy); + + // Don't show a modal when we got rejected/the call was hung up + if (!hangupReason || [CallErrorCode.UserHangup, "user hangup"].includes(hangupReason)) break; + let title; let description; // TODO: We should either do away with these or figure out a copy for each code (expect user_hangup...) if (call.hangupReason === CallErrorCode.UserBusy) { title = _t("User Busy"); description = _t("The user you called is busy."); - } else if (hangupReason && ![CallErrorCode.UserHangup, "user hangup"].includes(hangupReason)) { + } else { title = _t("Call Failed"); description = _t("The call could not be established"); } @@ -624,6 +648,19 @@ export default class CallHandler extends EventEmitter { `Call state in ${mappedRoomId} changed to ${status}`, ); + const toastKey = getIncomingCallToastKey(call.callId); + if (status === CallState.Ringing) { + ToastStore.sharedInstance().addOrReplaceToast({ + key: toastKey, + priority: 100, + component: IncomingCallToast, + bodyClassName: "mx_IncomingCallToast", + props: { call }, + }); + } else { + ToastStore.sharedInstance().dismissToast(toastKey); + } + dis.dispatch({ action: 'call_state', room_id: mappedRoomId, @@ -914,6 +951,8 @@ export default class CallHandler extends EventEmitter { action: 'view_room', room_id: roomId, }); + + await this.placeCall(roomId, PlaceCallType.Voice, null); } private async startTransferToPhoneNumber(call: MatrixCall, destination: string, consultFirst: boolean) { @@ -993,14 +1032,10 @@ export default class CallHandler extends EventEmitter { // prevent double clicking the call button const room = MatrixClientPeg.get().getRoom(roomId); - const currentJitsiWidgets = WidgetUtils.getRoomWidgetsOfType(room, WidgetType.JITSI); - const hasJitsi = currentJitsiWidgets.length > 0 - || WidgetEchoStore.roomHasPendingWidgetsOfType(roomId, currentJitsiWidgets, WidgetType.JITSI); - if (hasJitsi) { - Modal.createTrackedDialog('Call already in progress', '', ErrorDialog, { - title: _t('Call in Progress'), - description: _t('A call is currently being placed!'), - }); + const jitsiWidget = WidgetStore.instance.getApps(roomId).find((app) => WidgetType.JITSI.matches(app.type)); + if (jitsiWidget) { + // If there already is a Jitsi widget pin it + WidgetLayoutStore.instance.moveToContainer(room, jitsiWidget, Container.Top); return; } diff --git a/src/ContentMessages.tsx b/src/ContentMessages.tsx index c5bcb226ff..14a0c1ed51 100644 --- a/src/ContentMessages.tsx +++ b/src/ContentMessages.tsx @@ -209,6 +209,14 @@ async function loadImageElement(imageFile: File) { return { width, height, img }; } +// Minimum size for image files before we generate a thumbnail for them. +const IMAGE_SIZE_THRESHOLD_THUMBNAIL = 1 << 15; // 32KB +// Minimum size improvement for image thumbnails, if both are not met then don't bother uploading thumbnail. +const IMAGE_THUMBNAIL_MIN_REDUCTION_SIZE = 1 << 16; // 1MB +const IMAGE_THUMBNAIL_MIN_REDUCTION_PERCENT = 0.1; // 10% +// We don't apply these thresholds to video thumbnails as a poster image is always useful +// and videos tend to be much larger. + /** * Read the metadata for an image file and create and upload a thumbnail of the image. * @@ -217,23 +225,33 @@ async function loadImageElement(imageFile: File) { * @param {File} imageFile The image to read and thumbnail. * @return {Promise} A promise that resolves with the attachment info. */ -function infoForImageFile(matrixClient, roomId, imageFile) { +async function infoForImageFile(matrixClient: MatrixClient, roomId: string, imageFile: File) { let thumbnailType = "image/png"; if (imageFile.type === "image/jpeg") { thumbnailType = "image/jpeg"; } - let imageInfo; - return loadImageElement(imageFile).then((r) => { - return createThumbnail(r.img, r.width, r.height, thumbnailType); - }).then((result) => { - imageInfo = result.info; - return uploadFile(matrixClient, roomId, result.thumbnail); - }).then((result) => { - imageInfo.thumbnail_url = result.url; - imageInfo.thumbnail_file = result.file; + const imageElement = await loadImageElement(imageFile); + + const result = await createThumbnail(imageElement.img, imageElement.width, imageElement.height, thumbnailType); + const imageInfo = result.info; + + // we do all sizing checks here because we still rely on thumbnail generation for making a blurhash from. + const sizeDifference = imageFile.size - imageInfo.thumbnail_info.size; + if ( + imageFile.size <= IMAGE_SIZE_THRESHOLD_THUMBNAIL || // image is small enough already + (sizeDifference <= IMAGE_THUMBNAIL_MIN_REDUCTION_SIZE && // thumbnail is not sufficiently smaller than original + sizeDifference <= (imageFile.size * IMAGE_THUMBNAIL_MIN_REDUCTION_PERCENT)) + ) { + delete imageInfo["thumbnail_info"]; return imageInfo; - }); + } + + const uploadResult = await uploadFile(matrixClient, roomId, result.thumbnail); + + imageInfo["thumbnail_url"] = uploadResult.url; + imageInfo["thumbnail_file"] = uploadResult.file; + return imageInfo; } /** diff --git a/src/DateUtils.ts b/src/DateUtils.ts index e4a1175d88..e8b81ca315 100644 --- a/src/DateUtils.ts +++ b/src/DateUtils.ts @@ -123,6 +123,19 @@ export function formatTime(date: Date, showTwelveHour = false): string { return pad(date.getHours()) + ':' + pad(date.getMinutes()); } +export function formatCallTime(delta: Date): string { + const hours = delta.getUTCHours(); + const minutes = delta.getUTCMinutes(); + const seconds = delta.getUTCSeconds(); + + let output = ""; + if (hours) output += `${hours}h `; + if (minutes || output) output += `${minutes}m `; + if (seconds || output) output += `${seconds}s`; + + return output; +} + const MILLIS_IN_DAY = 86400000; export function wantsDateSeparator(prevEventDate: Date, nextEventDate: Date): boolean { if (!nextEventDate || !prevEventDate) { diff --git a/src/HtmlUtils.tsx b/src/HtmlUtils.tsx index af5d2b3019..2eee5214af 100644 --- a/src/HtmlUtils.tsx +++ b/src/HtmlUtils.tsx @@ -57,7 +57,33 @@ const BIGEMOJI_REGEX = new RegExp(`^(${EMOJIBASE_REGEX.source})+$`, 'i'); const COLOR_REGEX = /^#[0-9a-fA-F]{6}$/; -export const PERMITTED_URL_SCHEMES = ['http', 'https', 'ftp', 'mailto', 'magnet', 'matrix']; +export const PERMITTED_URL_SCHEMES = [ + "bitcoin", + "ftp", + "geo", + "http", + "https", + "im", + "irc", + "ircs", + "magnet", + "mailto", + "matrix", + "mms", + "news", + "nntp", + "openpgp4fpr", + "sip", + "sftp", + "sms", + "smsto", + "ssh", + "tel", + "urn", + "webcal", + "wtai", + "xmpp", +]; const MEDIA_API_MXC_REGEX = /\/_matrix\/media\/r0\/(?:download|thumbnail)\/(.+?)\/(.+?)(?:[?/]|$)/; diff --git a/src/IdentityAuthClient.js b/src/IdentityAuthClient.js index e91e1d72cf..ffece510de 100644 --- a/src/IdentityAuthClient.js +++ b/src/IdentityAuthClient.js @@ -146,23 +146,23 @@ export default class IdentityAuthClient { const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); const { finished } = Modal.createTrackedDialog('Default identity server terms warning', '', QuestionDialog, { - title: _t("Identity server has no terms of service"), - description: ( -
-

{ _t( - "This action requires accessing the default identity server " + + title: _t("Identity server has no terms of service"), + description: ( +

+

{ _t( + "This action requires accessing the default identity server " + " to validate an email address or phone number, " + "but the server does not have any terms of service.", {}, - { - server: () => { abbreviateUrl(identityServerUrl) }, - }, - ) }

-

{ _t( - "Only continue if you trust the owner of the server.", - ) }

-
- ), - button: _t("Trust"), + { + server: () => { abbreviateUrl(identityServerUrl) }, + }, + ) }

+

{ _t( + "Only continue if you trust the owner of the server.", + ) }

+
+ ), + button: _t("Trust"), }); const [confirmed] = await finished; if (confirmed) { diff --git a/src/Lifecycle.ts b/src/Lifecycle.ts index 410124a637..e48fd52cb1 100644 --- a/src/Lifecycle.ts +++ b/src/Lifecycle.ts @@ -48,6 +48,7 @@ import { Jitsi } from "./widgets/Jitsi"; import { SSO_HOMESERVER_URL_KEY, SSO_ID_SERVER_URL_KEY, SSO_IDP_ID_KEY } from "./BasePlatform"; import ThreepidInviteStore from "./stores/ThreepidInviteStore"; import CountlyAnalytics from "./CountlyAnalytics"; +import { PosthogAnalytics } from "./PosthogAnalytics"; import CallHandler from './CallHandler'; import LifecycleCustomisations from "./customisations/Lifecycle"; import ErrorDialog from "./components/views/dialogs/ErrorDialog"; @@ -573,6 +574,8 @@ async function doSetLoggedIn( await abortLogin(); } + PosthogAnalytics.instance.updateAnonymityFromSettings(credentials.userId); + Analytics.setLoggedIn(credentials.guest, credentials.homeserverUrl); MatrixClientPeg.replaceUsingCreds(credentials); @@ -700,6 +703,8 @@ export function logout(): void { CountlyAnalytics.instance.enable(/* anonymous = */ true); } + PosthogAnalytics.instance.logout(); + if (MatrixClientPeg.get().isGuest()) { // logout doesn't work for guest sessions // Also we sometimes want to re-log in a guest session if we abort the login. diff --git a/src/PosthogAnalytics.ts b/src/PosthogAnalytics.ts new file mode 100644 index 0000000000..860a155aff --- /dev/null +++ b/src/PosthogAnalytics.ts @@ -0,0 +1,355 @@ +/* +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 posthog, { PostHog } from 'posthog-js'; +import PlatformPeg from './PlatformPeg'; +import SdkConfig from './SdkConfig'; +import SettingsStore from './settings/SettingsStore'; + +/* Posthog analytics tracking. + * + * Anonymity behaviour is as follows: + * + * - If Posthog isn't configured in `config.json`, events are not sent. + * - If [Do Not Track](https://developer.mozilla.org/en-US/docs/Web/API/Navigator/doNotTrack) is + * enabled, events are not sent (this detection is built into posthog and turned on via the + * `respect_dnt` flag being passed to `posthog.init`). + * - If the `feature_pseudonymous_analytics_opt_in` labs flag is `true`, track pseudonomously, i.e. + * hash all matrix identifiers in tracking events (user IDs, room IDs etc) using SHA-256. + * - Otherwise, if the existing `analyticsOptIn` flag is `true`, track anonymously, i.e. + * redact all matrix identifiers in tracking events. + * - If both flags are false or not set, events are not sent. + */ + +interface IEvent { + // The event name that will be used by PostHog. Event names should use snake_case. + eventName: string; + + // The properties of the event that will be stored in PostHog. This is just a placeholder, + // extending interfaces must override this with a concrete definition to do type validation. + properties: {}; +} + +export enum Anonymity { + Disabled, + Anonymous, + Pseudonymous +} + +// If an event extends IPseudonymousEvent, the event contains pseudonymous data +// that won't be sent unless the user has explicitly consented to pseudonymous tracking. +// For example, it might contain hashed user IDs or room IDs. +// Such events will be automatically dropped if PosthogAnalytics.anonymity isn't set to Pseudonymous. +export interface IPseudonymousEvent extends IEvent {} + +// If an event extends IAnonymousEvent, the event strictly contains *only* anonymous data; +// i.e. no identifiers that can be associated with the user. +export interface IAnonymousEvent extends IEvent {} + +export interface IRoomEvent extends IPseudonymousEvent { + hashedRoomId: string; +} + +interface IPageView extends IAnonymousEvent { + eventName: "$pageview"; + properties: { + durationMs?: number; + screen?: string; + }; +} + +const hashHex = async (input: string): Promise => { + const buf = new TextEncoder().encode(input); + const digestBuf = await window.crypto.subtle.digest("sha-256", buf); + return [...new Uint8Array(digestBuf)].map((b: number) => b.toString(16).padStart(2, "0")).join(""); +}; + +const whitelistedScreens = new Set([ + "register", "login", "forgot_password", "soft_logout", "new", "settings", "welcome", "home", "start", "directory", + "start_sso", "start_cas", "groups", "complete_security", "post_registration", "room", "user", "group", +]); + +export async function getRedactedCurrentLocation( + origin: string, + hash: string, + pathname: string, + anonymity: Anonymity, +): Promise { + // Redact PII from the current location. + // If anonymous is true, redact entirely, if false, substitute it with a hash. + // For known screens, assumes a URL structure of //might/be/pii + if (origin.startsWith('file://')) { + pathname = "//"; + } + + let hashStr; + if (hash == "") { + hashStr = ""; + } else { + let [beforeFirstSlash, screen, ...parts] = hash.split("/"); + + if (!whitelistedScreens.has(screen)) { + screen = ""; + } + + for (let i = 0; i < parts.length; i++) { + parts[i] = anonymity === Anonymity.Anonymous ? `` : await hashHex(parts[i]); + } + + hashStr = `${beforeFirstSlash}/${screen}/${parts.join("/")}`; + } + return origin + pathname + hashStr; +} + +interface PlatformProperties { + appVersion: string; + appPlatform: string; +} + +export class PosthogAnalytics { + /* Wrapper for Posthog analytics. + * 3 modes of anonymity are supported, governed by this.anonymity + * - Anonymity.Disabled means *no data* is passed to posthog + * - Anonymity.Anonymous means all identifers will be redacted before being passed to posthog + * - Anonymity.Pseudonymous means all identifiers will be hashed via SHA-256 before being passed + * to Posthog + * + * To update anonymity, call updateAnonymityFromSettings() or you can set it directly via setAnonymity(). + * + * To pass an event to Posthog: + * + * 1. Declare a type for the event, extending IAnonymousEvent, IPseudonymousEvent or IRoomEvent. + * 2. Call the appropriate track*() method. Pseudonymous events will be dropped when anonymity is + * Anonymous or Disabled; Anonymous events will be dropped when anonymity is Disabled. + */ + + private anonymity = Anonymity.Disabled; + // set true during the constructor if posthog config is present, otherwise false + private enabled = false; + private static _instance = null; + private platformSuperProperties = {}; + + public static get instance(): PosthogAnalytics { + if (!this._instance) { + this._instance = new PosthogAnalytics(posthog); + } + return this._instance; + } + + constructor(private readonly posthog: PostHog) { + const posthogConfig = SdkConfig.get()["posthog"]; + if (posthogConfig) { + this.posthog.init(posthogConfig.projectApiKey, { + api_host: posthogConfig.apiHost, + autocapture: false, + mask_all_text: true, + mask_all_element_attributes: true, + // This only triggers on page load, which for our SPA isn't particularly useful. + // Plus, the .capture call originating from somewhere in posthog makes it hard + // to redact URLs, which requires async code. + // + // To raise this manually, just call .capture("$pageview") or posthog.capture_pageview. + capture_pageview: false, + sanitize_properties: this.sanitizeProperties, + respect_dnt: true, + }); + this.enabled = true; + } else { + this.enabled = false; + } + } + + private sanitizeProperties = (properties: posthog.Properties): posthog.Properties => { + // Callback from posthog to sanitize properties before sending them to the server. + // + // Here we sanitize posthog's built in properties which leak PII e.g. url reporting. + // See utils.js _.info.properties in posthog-js. + + // Replace the $current_url with a redacted version. + // $redacted_current_url is injected by this class earlier in capture(), as its generation + // is async and can't be done in this non-async callback. + if (!properties['$redacted_current_url']) { + console.log("$redacted_current_url not set in sanitizeProperties, will drop $current_url entirely"); + } + properties['$current_url'] = properties['$redacted_current_url']; + delete properties['$redacted_current_url']; + + if (this.anonymity == Anonymity.Anonymous) { + // drop referrer information for anonymous users + properties['$referrer'] = null; + properties['$referring_domain'] = null; + properties['$initial_referrer'] = null; + properties['$initial_referring_domain'] = null; + + // drop device ID, which is a UUID persisted in local storage + properties['$device_id'] = null; + } + + return properties; + }; + + private static getAnonymityFromSettings(): Anonymity { + // determine the current anonymity level based on current user settings + + // "Send anonymous usage data which helps us improve Element. This will use a cookie." + const analyticsOptIn = SettingsStore.getValue("analyticsOptIn", null, true); + + // (proposed wording) "Send pseudonymous usage data which helps us improve Element. This will use a cookie." + // + // TODO: Currently, this is only a labs flag, for testing purposes. + const pseudonumousOptIn = SettingsStore.getValue("feature_pseudonymous_analytics_opt_in", null, true); + + let anonymity; + if (pseudonumousOptIn) { + anonymity = Anonymity.Pseudonymous; + } else if (analyticsOptIn) { + anonymity = Anonymity.Anonymous; + } else { + anonymity = Anonymity.Disabled; + } + + return anonymity; + } + + private registerSuperProperties(properties: posthog.Properties) { + if (this.enabled) { + this.posthog.register(properties); + } + } + + private static async getPlatformProperties(): Promise { + const platform = PlatformPeg.get(); + let appVersion; + try { + appVersion = await platform.getAppVersion(); + } catch (e) { + // this happens if no version is set i.e. in dev + appVersion = "unknown"; + } + + return { + appVersion, + appPlatform: platform.getHumanReadableName(), + }; + } + + private async capture(eventName: string, properties: posthog.Properties) { + if (!this.enabled) { + return; + } + const { origin, hash, pathname } = window.location; + properties['$redacted_current_url'] = await getRedactedCurrentLocation( + origin, hash, pathname, this.anonymity); + this.posthog.capture(eventName, properties); + } + + public isEnabled(): boolean { + return this.enabled; + } + + public setAnonymity(anonymity: Anonymity): void { + // Update this.anonymity. + // This is public for testing purposes, typically you want to call updateAnonymityFromSettings + // to ensure this value is in step with the user's settings. + if (this.enabled && (anonymity == Anonymity.Disabled || anonymity == Anonymity.Anonymous)) { + // when transitioning to Disabled or Anonymous ensure we clear out any prior state + // set in posthog e.g. distinct ID + this.posthog.reset(); + // Restore any previously set platform super properties + this.registerSuperProperties(this.platformSuperProperties); + } + this.anonymity = anonymity; + } + + public async identifyUser(userId: string): Promise { + if (this.anonymity == Anonymity.Pseudonymous) { + this.posthog.identify(await hashHex(userId)); + } + } + + public getAnonymity(): Anonymity { + return this.anonymity; + } + + public logout(): void { + if (this.enabled) { + this.posthog.reset(); + } + this.setAnonymity(Anonymity.Anonymous); + } + + public async trackPseudonymousEvent( + eventName: E["eventName"], + properties: E["properties"] = {}, + ) { + if (this.anonymity == Anonymity.Anonymous || this.anonymity == Anonymity.Disabled) return; + await this.capture(eventName, properties); + } + + public async trackAnonymousEvent( + eventName: E["eventName"], + properties: E["properties"] = {}, + ): Promise { + if (this.anonymity == Anonymity.Disabled) return; + await this.capture(eventName, properties); + } + + public async trackRoomEvent( + eventName: E["eventName"], + roomId: string, + properties: Omit, + ): Promise { + const updatedProperties = { + ...properties, + hashedRoomId: roomId ? await hashHex(roomId) : null, + }; + await this.trackPseudonymousEvent(eventName, updatedProperties); + } + + public async trackPageView(durationMs: number): Promise { + const hash = window.location.hash; + + let screen = null; + const split = hash.split("/"); + if (split.length >= 2) { + screen = split[1]; + } + + await this.trackAnonymousEvent("$pageview", { + durationMs, + screen, + }); + } + + public async updatePlatformSuperProperties(): Promise { + // Update super properties in posthog with our platform (app version, platform). + // These properties will be subsequently passed in every event. + // + // This only needs to be done once per page lifetime. Note that getPlatformProperties + // is async and can involve a network request if we are running in a browser. + this.platformSuperProperties = await PosthogAnalytics.getPlatformProperties(); + this.registerSuperProperties(this.platformSuperProperties); + } + + public async updateAnonymityFromSettings(userId?: string): Promise { + // Update this.anonymity based on the user's analytics opt-in settings + // Identify the user (via hashed user ID) to posthog if anonymity is pseudonmyous + this.setAnonymity(PosthogAnalytics.getAnonymityFromSettings()); + if (userId && this.getAnonymity() == Anonymity.Pseudonymous) { + await this.identifyUser(userId); + } + } +} diff --git a/src/TextForEvent.tsx b/src/TextForEvent.tsx index 7bad8eb50e..b9295be3ed 100644 --- a/src/TextForEvent.tsx +++ b/src/TextForEvent.tsx @@ -25,11 +25,44 @@ import { Action } from './dispatcher/actions'; import defaultDispatcher from './dispatcher/dispatcher'; import { SetRightPanelPhasePayload } from './dispatcher/payloads/SetRightPanelPhasePayload'; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { MatrixClientPeg } from "./MatrixClientPeg"; // These functions are frequently used just to check whether an event has // any text to display at all. For this reason they return deferred values // to avoid the expense of looking up translations when they're not needed. +function textForCallInviteEvent(event: MatrixEvent): () => string | null { + const getSenderName = () => event.sender ? event.sender.name : _t('Someone'); + // FIXME: Find a better way to determine this from the event? + let isVoice = true; + if (event.getContent().offer && event.getContent().offer.sdp && + event.getContent().offer.sdp.indexOf('m=video') !== -1) { + isVoice = false; + } + const isSupported = MatrixClientPeg.get().supportsVoip(); + + // This ladder could be reduced down to a couple string variables, however other languages + // can have a hard time translating those strings. In an effort to make translations easier + // and more accurate, we break out the string-based variables to a couple booleans. + if (isVoice && isSupported) { + return () => _t("%(senderName)s placed a voice call.", { + senderName: getSenderName(), + }); + } else if (isVoice && !isSupported) { + return () => _t("%(senderName)s placed a voice call. (not supported by this browser)", { + senderName: getSenderName(), + }); + } else if (!isVoice && isSupported) { + return () => _t("%(senderName)s placed a video call.", { + senderName: getSenderName(), + }); + } else if (!isVoice && !isSupported) { + return () => _t("%(senderName)s placed a video call. (not supported by this browser)", { + senderName: getSenderName(), + }); + } +} + function textForMemberEvent(ev: MatrixEvent, allowJSX: boolean, showHiddenEvents?: boolean): () => string | null { // XXX: SYJS-16 "sender is sometimes null for join messages" const senderName = ev.sender ? ev.sender.name : ev.getSender(); @@ -567,6 +600,7 @@ interface IHandlers { const handlers: IHandlers = { 'm.room.message': textForMessageEvent, + 'm.call.invite': textForCallInviteEvent, }; const stateHandlers: IHandlers = { diff --git a/src/accessibility/KeyboardShortcuts.tsx b/src/accessibility/KeyboardShortcuts.tsx index 9cc7b60c99..c66984191f 100644 --- a/src/accessibility/KeyboardShortcuts.tsx +++ b/src/accessibility/KeyboardShortcuts.tsx @@ -163,7 +163,7 @@ const shortcuts: Record = { modifiers: [Modifiers.SHIFT], key: Key.PAGE_UP, }], - description: _td("Jump to oldest unread message"), + description: _td("Jump to oldest unread message"), }, { keybinds: [{ modifiers: [CMD_OR_CTRL, Modifiers.SHIFT], diff --git a/src/accessibility/RovingTabIndex.tsx b/src/accessibility/RovingTabIndex.tsx index 87f525bdfc..68e10049fd 100644 --- a/src/accessibility/RovingTabIndex.tsx +++ b/src/accessibility/RovingTabIndex.tsx @@ -150,13 +150,14 @@ const reducer = (state: IState, action: IAction) => { interface IProps { handleHomeEnd?: boolean; + handleUpDown?: boolean; children(renderProps: { onKeyDownHandler(ev: React.KeyboardEvent); }); onKeyDown?(ev: React.KeyboardEvent, state: IState); } -export const RovingTabIndexProvider: React.FC = ({ children, handleHomeEnd, onKeyDown }) => { +export const RovingTabIndexProvider: React.FC = ({ children, handleHomeEnd, handleUpDown, onKeyDown }) => { const [state, dispatch] = useReducer>(reducer, { activeRef: null, refs: [], @@ -167,21 +168,50 @@ export const RovingTabIndexProvider: React.FC = ({ children, handleHomeE const onKeyDownHandler = useCallback((ev) => { let handled = false; // Don't interfere with input default keydown behaviour - if (handleHomeEnd && ev.target.tagName !== "INPUT" && ev.target.tagName !== "TEXTAREA") { + if (ev.target.tagName !== "INPUT" && ev.target.tagName !== "TEXTAREA") { // check if we actually have any items switch (ev.key) { case Key.HOME: - handled = true; - // move focus to first item - if (context.state.refs.length > 0) { - context.state.refs[0].current.focus(); + if (handleHomeEnd) { + handled = true; + // move focus to first item + if (context.state.refs.length > 0) { + context.state.refs[0].current.focus(); + } } break; + case Key.END: - handled = true; - // move focus to last item - if (context.state.refs.length > 0) { - context.state.refs[context.state.refs.length - 1].current.focus(); + if (handleHomeEnd) { + handled = true; + // move focus to last item + if (context.state.refs.length > 0) { + context.state.refs[context.state.refs.length - 1].current.focus(); + } + } + break; + + case Key.ARROW_UP: + if (handleUpDown) { + handled = true; + if (context.state.refs.length > 0) { + const idx = context.state.refs.indexOf(context.state.activeRef); + if (idx > 0) { + context.state.refs[idx - 1].current.focus(); + } + } + } + break; + + case Key.ARROW_DOWN: + if (handleUpDown) { + handled = true; + if (context.state.refs.length > 0) { + const idx = context.state.refs.indexOf(context.state.activeRef); + if (idx < context.state.refs.length - 1) { + context.state.refs[idx + 1].current.focus(); + } + } } break; } @@ -193,7 +223,7 @@ export const RovingTabIndexProvider: React.FC = ({ children, handleHomeE } else if (onKeyDown) { return onKeyDown(ev, context.state); } - }, [context.state, onKeyDown, handleHomeEnd]); + }, [context.state, onKeyDown, handleHomeEnd, handleUpDown]); return { children({ onKeyDownHandler }) } diff --git a/src/audio/Playback.ts b/src/audio/Playback.ts index 33d346629a..9dad828a79 100644 --- a/src/audio/Playback.ts +++ b/src/audio/Playback.ts @@ -38,17 +38,9 @@ function makePlaybackWaveform(input: number[]): number[] { // First, convert negative amplitudes to positive so we don't detect zero as "noisy". const noiseWaveform = input.map(v => Math.abs(v)); - // Next, we'll resample the waveform using a smoothing approach so we can keep the same rough shape. - // We also rescale the waveform to be 0-1 for the remaining function logic. - const resampled = arrayRescale(arraySmoothingResample(noiseWaveform, PLAYBACK_WAVEFORM_SAMPLES), 0, 1); - - // Then, we'll do a high and low pass filter to isolate actual speaking volumes within the rescaled - // waveform. Most speech happens below the 0.5 mark. - const filtered = resampled.map(v => clamp(v, 0.1, 0.5)); - - // Finally, we'll rescale the filtered waveform (0.1-0.5 becomes 0-1 again) so the user sees something - // sensible. This is what we return to keep our contract of "values between zero and one". - return arrayRescale(filtered, 0, 1); + // Then, we'll resample the waveform using a smoothing approach so we can keep the same rough shape. + // We also rescale the waveform to be 0-1 so we end up with a clamped waveform to rely upon. + return arrayRescale(arraySmoothingResample(noiseWaveform, PLAYBACK_WAVEFORM_SAMPLES), 0, 1); } export class Playback extends EventEmitter implements IDestroyable { diff --git a/src/audio/VoiceRecording.ts b/src/audio/VoiceRecording.ts index efd616e5ae..67b2acda0c 100644 --- a/src/audio/VoiceRecording.ts +++ b/src/audio/VoiceRecording.ts @@ -30,6 +30,7 @@ import { IEncryptedFile } from "matrix-js-sdk/src/@types/event"; import { uploadFile } from "../ContentMessages"; import { FixedRollingArray } from "../utils/FixedRollingArray"; import { clamp } from "../utils/numbers"; +import mxRecorderWorkletPath from "./RecorderWorklet"; const CHANNELS = 1; // stereo isn't important export const SAMPLE_RATE = 48000; // 48khz is what WebRTC uses. 12khz is where we lose quality. @@ -113,16 +114,10 @@ export class VoiceRecording extends EventEmitter implements IDestroyable { }); this.recorderSource = this.recorderContext.createMediaStreamSource(this.recorderStream); - // Set up our worklet. We use this for timing information and waveform analysis: the - // web audio API prefers this be done async to avoid holding the main thread with math. - const mxRecorderWorkletPath = document.body.dataset.vectorRecorderWorkletScript; - if (!mxRecorderWorkletPath) { - // noinspection ExceptionCaughtLocallyJS - throw new Error("Unable to create recorder: no worklet script registered"); - } - // Connect our inputs and outputs if (this.recorderContext.audioWorklet) { + // Set up our worklet. We use this for timing information and waveform analysis: the + // web audio API prefers this be done async to avoid holding the main thread with math. await this.recorderContext.audioWorklet.addModule(mxRecorderWorkletPath); this.recorderWorklet = new AudioWorkletNode(this.recorderContext, WORKLET_NAME); this.recorderSource.connect(this.recorderWorklet); diff --git a/src/components/structures/AutoHideScrollbar.tsx b/src/components/structures/AutoHideScrollbar.tsx index 184d883dda..a60df45770 100644 --- a/src/components/structures/AutoHideScrollbar.tsx +++ b/src/components/structures/AutoHideScrollbar.tsx @@ -61,7 +61,9 @@ export default class AutoHideScrollbar extends React.Component { style={style} className={["mx_AutoHideScrollbar", className].join(" ")} onWheel={onWheel} - tabIndex={tabIndex} + // Firefox sometimes makes this element focusable due to + // overflow:scroll;, so force it out of tab order by default. + tabIndex={tabIndex ?? -1} > { children } ); diff --git a/src/components/structures/CallEventGrouper.ts b/src/components/structures/CallEventGrouper.ts index ce3b530858..b48bb32efe 100644 --- a/src/components/structures/CallEventGrouper.ts +++ b/src/components/structures/CallEventGrouper.ts @@ -27,9 +27,15 @@ export enum CallEventGrouperEvent { SilencedChanged = "silenced_changed", } +const CONNECTING_STATES = [ + CallState.Connecting, + CallState.WaitLocalMedia, + CallState.CreateOffer, + CallState.CreateAnswer, +]; + const SUPPORTED_STATES = [ CallState.Connected, - CallState.Connecting, CallState.Ringing, ]; @@ -61,6 +67,10 @@ export default class CallEventGrouper extends EventEmitter { return [...this.events].find((event) => event.getType() === EventType.CallReject); } + private get selectAnswer(): MatrixEvent { + return [...this.events].find((event) => event.getType() === EventType.CallSelectAnswer); + } + public get isVoice(): boolean { const invite = this.invite; if (!invite) return; @@ -82,6 +92,11 @@ export default class CallEventGrouper extends EventEmitter { return Boolean(this.reject); } + public get duration(): Date { + if (!this.hangup || !this.selectAnswer) return; + return new Date(this.hangup.getDate().getTime() - this.selectAnswer.getDate().getTime()); + } + /** * Returns true if there are only events from the other side - we missed the call */ @@ -127,7 +142,9 @@ export default class CallEventGrouper extends EventEmitter { } private setState = () => { - if (SUPPORTED_STATES.includes(this.call?.state)) { + if (CONNECTING_STATES.includes(this.call?.state)) { + this.state = CallState.Connecting; + } else if (SUPPORTED_STATES.includes(this.call?.state)) { this.state = this.call.state; } else { if (this.callWasMissed) this.state = CustomCallState.Missed; diff --git a/src/components/structures/ContextMenu.tsx b/src/components/structures/ContextMenu.tsx index 01c086b73c..332b6cd318 100644 --- a/src/components/structures/ContextMenu.tsx +++ b/src/components/structures/ContextMenu.tsx @@ -16,7 +16,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { CSSProperties, RefObject, useRef, useState } from "react"; +import React, { CSSProperties, RefObject, SyntheticEvent, useRef, useState } from "react"; import ReactDOM from "react-dom"; import classNames from "classnames"; @@ -471,10 +471,14 @@ type ContextMenuTuple = [boolean, RefObject, () => void, () => void, (val: export const useContextMenu = (): ContextMenuTuple => { const button = useRef(null); const [isOpen, setIsOpen] = useState(false); - const open = () => { + const open = (ev?: SyntheticEvent) => { + ev?.preventDefault(); + ev?.stopPropagation(); setIsOpen(true); }; - const close = () => { + const close = (ev?: SyntheticEvent) => { + ev?.preventDefault(); + ev?.stopPropagation(); setIsOpen(false); }; diff --git a/src/components/structures/GroupView.js b/src/components/structures/GroupView.js index 99fa94e62b..f4f1d50d63 100644 --- a/src/components/structures/GroupView.js +++ b/src/components/structures/GroupView.js @@ -41,6 +41,9 @@ import RightPanelStore from "../../stores/RightPanelStore"; import AutoHideScrollbar from "./AutoHideScrollbar"; import { mediaFromMxc } from "../../customisations/Media"; import { replaceableComponent } from "../../utils/replaceableComponent"; +import { createSpaceFromCommunity } from "../../utils/space"; +import { Action } from "../../dispatcher/actions"; +import { RightPanelPhases } from "../../stores/RightPanelStorePhases"; const LONG_DESC_PLACEHOLDER = _td( `

HTML for your community's page

@@ -399,6 +402,8 @@ class FeaturedUser extends React.Component { const GROUP_JOINPOLICY_OPEN = "open"; const GROUP_JOINPOLICY_INVITE = "invite"; +const UPGRADE_NOTICE_LS_KEY = "mx_hide_community_upgrade_notice"; + @replaceableComponent("structures.GroupView") export default class GroupView extends React.Component { static propTypes = { @@ -422,6 +427,7 @@ export default class GroupView extends React.Component { publicityBusy: false, inviterProfile: null, showRightPanel: RightPanelStore.getSharedInstance().isOpenForGroup, + showUpgradeNotice: !localStorage.getItem(UPGRADE_NOTICE_LS_KEY), }; componentDidMount() { @@ -807,6 +813,22 @@ export default class GroupView extends React.Component { showGroupAddRoomDialog(this.props.groupId); }; + _dismissUpgradeNotice = () => { + localStorage.setItem(UPGRADE_NOTICE_LS_KEY, "true"); + this.setState({ showUpgradeNotice: false }); + } + + _onCreateSpaceClick = () => { + createSpaceFromCommunity(this._matrixClient, this.props.groupId); + }; + + _onAdminsLinkClick = () => { + dis.dispatch({ + action: Action.SetRightPanelPhase, + phase: RightPanelPhases.GroupMemberList, + }); + }; + _getGroupSection() { const groupSettingsSectionClasses = classnames({ "mx_GroupView_group": this.state.editing, @@ -843,10 +865,46 @@ export default class GroupView extends React.Component { }, ) } :
; + + let communitiesUpgradeNotice; + if (this.state.showUpgradeNotice) { + let text; + if (this.state.isUserPrivileged) { + text = _t("You can create a Space from this community here.", {}, { + a: sub => + { sub } + , + }); + } else { + text = _t("Ask the admins of this community to make it into a Space " + + "and keep a look out for the invite.", {}, { + a: sub => + { sub } + , + }); + } + + communitiesUpgradeNotice =
+

{ _t("Communities can now be made into Spaces") }

+

+ { _t("Spaces are a new way to make a community, with new features coming.") } +   + { text } +   + { _t("Communities won't receive further updates.") } +

+ +
; + } + return
{ header } { hostingSignup } { changeDelayWarning } + { communitiesUpgradeNotice } { this._getJoinableNode() } { this._getLongDescriptionNode() } { this._getRoomsNode() } diff --git a/src/components/structures/LeftPanel.tsx b/src/components/structures/LeftPanel.tsx index 3bd2c68c6c..ff5d15d44d 100644 --- a/src/components/structures/LeftPanel.tsx +++ b/src/components/structures/LeftPanel.tsx @@ -392,9 +392,6 @@ export default class LeftPanel extends React.Component { diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index 8cfe35c4cf..60c78b5f9e 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -107,6 +107,7 @@ import UIStore, { UI_EVENTS } from "../../stores/UIStore"; import SoftLogout from './auth/SoftLogout'; import { makeRoomPermalink } from "../../utils/permalinks/Permalinks"; import { copyPlaintext } from "../../utils/strings"; +import { PosthogAnalytics } from '../../PosthogAnalytics'; /** constants for MatrixChat.state.view */ export enum Views { @@ -387,6 +388,10 @@ export default class MatrixChat extends React.PureComponent { if (SettingsStore.getValue("analyticsOptIn")) { Analytics.enable(); } + + PosthogAnalytics.instance.updateAnonymityFromSettings(); + PosthogAnalytics.instance.updatePlatformSuperProperties(); + CountlyAnalytics.instance.enable(/* anonymous = */ true); } @@ -443,6 +448,7 @@ export default class MatrixChat extends React.PureComponent { const durationMs = this.stopPageChangeTimer(); Analytics.trackPageChange(durationMs); CountlyAnalytics.instance.trackPageChange(durationMs); + PosthogAnalytics.instance.trackPageView(durationMs); } if (this.focusComposer) { dis.fire(Action.FocusSendMessageComposer); diff --git a/src/components/structures/MessagePanel.tsx b/src/components/structures/MessagePanel.tsx index 698a36127b..1691d90651 100644 --- a/src/components/structures/MessagePanel.tsx +++ b/src/components/structures/MessagePanel.tsx @@ -51,7 +51,12 @@ import EditorStateTransfer from "../../utils/EditorStateTransfer"; const CONTINUATION_MAX_INTERVAL = 5 * 60 * 1000; // 5 minutes const continuedTypes = [EventType.Sticker, EventType.RoomMessage]; -const membershipTypes = [EventType.RoomMember, EventType.RoomThirdPartyInvite, EventType.RoomServerAcl]; +const groupedEvents = [ + EventType.RoomMember, + EventType.RoomThirdPartyInvite, + EventType.RoomServerAcl, + EventType.RoomPinnedEvents, +]; // check if there is a previous event and it has the same sender as this event // and the types are the same/is in continuedTypes and the time between them is <= CONTINUATION_MAX_INTERVAL @@ -1234,7 +1239,7 @@ class RedactionGrouper extends BaseGrouper { // Wrap consecutive member events in a ListSummary, ignore if redacted class MemberGrouper extends BaseGrouper { static canStartGroup = function(panel: MessagePanel, ev: MatrixEvent): boolean { - return panel.shouldShowEvent(ev) && membershipTypes.includes(ev.getType() as EventType); + return panel.shouldShowEvent(ev) && groupedEvents.includes(ev.getType() as EventType); }; constructor( @@ -1252,7 +1257,7 @@ class MemberGrouper extends BaseGrouper { if (this.panel.wantsDateSeparator(this.events[0], ev.getDate())) { return false; } - return membershipTypes.includes(ev.getType() as EventType); + return groupedEvents.includes(ev.getType() as EventType); } public add(ev: MatrixEvent, showHiddenEvents?: boolean): void { diff --git a/src/components/structures/SpaceHierarchy.tsx b/src/components/structures/SpaceHierarchy.tsx index cea6a568e7..120f82f717 100644 --- a/src/components/structures/SpaceHierarchy.tsx +++ b/src/components/structures/SpaceHierarchy.tsx @@ -14,7 +14,16 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import React, { + ReactNode, + useCallback, + useEffect, + useMemo, + useRef, + useState, + KeyboardEvent, + KeyboardEventHandler, +} from "react"; import { Room } from "matrix-js-sdk/src/models/room"; import { RoomHierarchy } from "matrix-js-sdk/src/room-hierarchy"; import { EventType, RoomType } from "matrix-js-sdk/src/@types/event"; @@ -41,6 +50,8 @@ import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton"; import { linkifyElement } from "../../HtmlUtils"; import { useDispatcher } from "../../hooks/useDispatcher"; import { Action } from "../../dispatcher/actions"; +import { Key } from "../../Keyboard"; +import { IState, RovingTabIndexProvider, useRovingTabIndex } from "../../accessibility/RovingTabIndex"; import { getDisplayAliasForRoom } from "./RoomDirectory"; interface IProps { @@ -76,6 +87,7 @@ const Tile: React.FC = ({ || (room.room_type === RoomType.Space ? _t("Unnamed Space") : _t("Unnamed Room")); const [showChildren, toggleShowChildren] = useStateToggle(true); + const [onFocus, isActive, ref] = useRovingTabIndex(); const onPreviewClick = (ev: ButtonEvent) => { ev.preventDefault(); @@ -90,11 +102,21 @@ const Tile: React.FC = ({ let button; if (joinedRoom) { - button = + button = { _t("View") } ; } else if (onJoinClick) { - button = + button = { _t("Join") } ; } @@ -102,13 +124,13 @@ const Tile: React.FC = ({ let checkbox; if (onToggleClick) { if (hasPermissions) { - checkbox = ; + checkbox = ; } else { checkbox = { ev.stopPropagation(); }} > - + ; } } @@ -168,8 +190,9 @@ const Tile: React.FC = ({
; - let childToggle; - let childSection; + let childToggle: JSX.Element; + let childSection: JSX.Element; + let onKeyDown: KeyboardEventHandler; if (children) { // the chevron is purposefully a div rather than a button as it should be ignored for a11y childToggle =
= ({ toggleShowChildren(); }} />; + if (showChildren) { - childSection =
+ const onChildrenKeyDown = (e) => { + if (e.key === Key.ARROW_LEFT) { + e.preventDefault(); + e.stopPropagation(); + ref.current?.focus(); + } + }; + + childSection =
{ children }
; } + + onKeyDown = (e) => { + let handled = false; + + switch (e.key) { + case Key.ARROW_LEFT: + if (showChildren) { + handled = true; + toggleShowChildren(); + } + break; + + case Key.ARROW_RIGHT: + handled = true; + if (showChildren) { + const childSection = ref.current?.nextElementSibling; + childSection?.querySelector(".mx_SpaceRoomDirectory_roomTile")?.focus(); + } else { + toggleShowChildren(); + } + break; + } + + if (handled) { + e.preventDefault(); + e.stopPropagation(); + } + }; } - return <> + return
  • { content } { childToggle } { childSection } - ; +
  • ; }; export const showRoom = (hierarchy: RoomHierarchy, roomId: string, autoJoin = false) => { @@ -439,165 +511,181 @@ const SpaceHierarchy = ({ return

    { _t("Your server does not support showing space hierarchies.") }

    ; } - let content: JSX.Element; - let loader: JSX.Element; - if (loading && !rooms.length) { - content = ; - } else { - let manageButtons; - if (space.getMyMembership() === "join" && space.currentState.maySendStateEvent(EventType.SpaceChild, userId)) { - const selectedRelations = Array.from(selected.keys()).flatMap(parentId => { - return [...selected.get(parentId).values()].map(childId => [parentId, childId]) as [string, string][]; - }); + const onKeyDown = (ev: KeyboardEvent, state: IState) => { + if (ev.key === Key.ARROW_DOWN && ev.currentTarget.classList.contains("mx_SpaceRoomDirectory_search")) { + state.refs[0]?.current?.focus(); + } + }; - const selectionAllSuggested = selectedRelations.every(([parentId, childId]) => { - return hierarchy.isSuggested(parentId, childId); - }); + // TODO loading state/error state + return + { ({ onKeyDownHandler }) => { + let content: JSX.Element; + let loader: JSX.Element; - const disabled = !selectedRelations.length || removing || saving; + if (loading && !rooms.length) { + content = ; + } else { + let manageButtons; + if (space.getMyMembership() === "join" && space.currentState.maySendStateEvent(EventType.SpaceChild, userId)) { + const selectedRelations = Array.from(selected.keys()).flatMap(parentId => { + return [ + ...selected.get(parentId).values(), + ].map(childId => [parentId, childId]) as [string, string][]; + }); - let Button: React.ComponentType> = AccessibleButton; - let props = {}; - if (!selectedRelations.length) { - Button = AccessibleTooltipButton; - props = { - tooltip: _t("Select a room below first"), - yOffset: -40, - }; - } + const selectionAllSuggested = selectedRelations.every(([parentId, childId]) => { + return hierarchy.isSuggested(parentId, childId); + }); - manageButtons = <> - - - ; - } - let results: JSX.Element; - if (filteredRoomSet.size) { - const hasPermissions = space?.currentState.maySendStateEvent(EventType.SpaceChild, cli.getUserId()); + manageButtons = <> + + + ; + } + + let results: JSX.Element; + if (filteredRoomSet.size) { + const hasPermissions = space?.currentState.maySendStateEvent(EventType.SpaceChild, cli.getUserId()); + + results = <> + { + setError(""); + if (!selected.has(parentId)) { + setSelected(new Map(selected.set(parentId, new Set([childId])))); + return; + } + + const parentSet = selected.get(parentId); + if (!parentSet.has(childId)) { + setSelected(new Map(selected.set(parentId, new Set([...parentSet, childId])))); + return; + } + + parentSet.delete(childId); + setSelected(new Map(selected.set(parentId, new Set(parentSet)))); + } : undefined} + onViewRoomClick={(roomId, autoJoin) => { + showRoom(hierarchy, roomId, autoJoin); + }} + /> + ; + + if (hierarchy.canLoadMore) { + loader =
    + +
    ; + } + } else { + results =
    +

    { _t("No results found") }

    +
    { _t("You may want to try a different search or check for typos.") }
    +
    ; + } + + content = <> +
    +

    { query.trim() ? _t("Results") : _t("Rooms and spaces") }

    + + { additionalButtons } + { manageButtons } + +
    + { error &&
    + { error } +
    } +
      + { results } +
    + { loader } + ; } - } else { - results =
    -

    { _t("No results found") }

    -
    { _t("You may want to try a different search or check for typos.") }
    -
    ; - } - content = <> -
    -

    { query.trim() ? _t("Results") : _t("Rooms and spaces") }

    - - { additionalButtons } - { manageButtons } - -
    - { error &&
    - { error } -
    } + return <> + - { results } - { loader } - ; - } - - return <> - - - { content } - ; + { content } + ; + } } +
    ; }; export default SpaceHierarchy; diff --git a/src/components/structures/SpaceRoomView.tsx b/src/components/structures/SpaceRoomView.tsx index 01feaefc6e..767e0999c3 100644 --- a/src/components/structures/SpaceRoomView.tsx +++ b/src/components/structures/SpaceRoomView.tsx @@ -74,6 +74,10 @@ import { BetaPill } from "../views/beta/BetaCard"; import { UserTab } from "../views/dialogs/UserSettingsDialog"; import { EffectiveMembership, getEffectiveMembership } from "../../utils/membership"; import { SpaceFeedbackPrompt } from "../views/spaces/SpaceCreateMenu"; +import { CreateEventField, IGroupSummary } from "../views/dialogs/CreateSpaceFromCommunityDialog"; +import { useAsyncMemo } from "../../hooks/useAsyncMemo"; +import Spinner from "../views/elements/Spinner"; +import GroupAvatar from "../views/avatars/GroupAvatar"; interface IProps { space: Room; @@ -158,7 +162,33 @@ const onBetaClick = () => { }); }; -const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) => { +// XXX: temporary community migration component +const GroupTile = ({ groupId }: { groupId: string }) => { + const cli = useContext(MatrixClientContext); + const groupSummary = useAsyncMemo(() => cli.getGroupSummary(groupId), [cli, groupId]); + + if (!groupSummary) return ; + + return <> + + { groupSummary.profile.name } + ; +}; + +interface ISpacePreviewProps { + space: Room; + onJoinButtonClicked(): void; + onRejectButtonClicked(): void; +} + +const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }: ISpacePreviewProps) => { const cli = useContext(MatrixClientContext); const myMembership = useMyRoomMembership(space); @@ -192,11 +222,11 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) => if (inviteSender) { inviterSection =
    - +
    { _t(" invites you", {}, { - inviter: () => { inviter.name || inviteSender }, + inviter: () => { inviter?.name || inviteSender }, }) }
    { inviter ?
    @@ -270,8 +300,18 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) =>
    ; } + let migratedCommunitySection: JSX.Element; + const createContent = space.currentState.getStateEvents(EventType.RoomCreate, "")?.getContent(); + if (createContent[CreateEventField]) { + migratedCommunitySection =
    + { _t("Created from ", {}, { + Community: () => , + }) } +
    ; + } + return
    - + { migratedCommunitySection } { inviterSection }

    diff --git a/src/components/structures/TimelinePanel.tsx b/src/components/structures/TimelinePanel.tsx index 0899b1c72a..f62676a4fc 100644 --- a/src/components/structures/TimelinePanel.tsx +++ b/src/components/structures/TimelinePanel.tsx @@ -757,16 +757,20 @@ class TimelinePanel extends React.Component { } this.lastRMSentEventId = this.state.readMarkerEventId; + const roomId = this.props.timelineSet.room.roomId; + const hiddenRR = SettingsStore.getValue("feature_hidden_read_receipts", roomId); + debuglog('TimelinePanel: Sending Read Markers for ', this.props.timelineSet.room.roomId, 'rm', this.state.readMarkerEventId, lastReadEvent ? 'rr ' + lastReadEvent.getId() : '', + ' hidden:' + hiddenRR, ); MatrixClientPeg.get().setRoomReadMarkers( - this.props.timelineSet.room.roomId, + roomId, this.state.readMarkerEventId, lastReadEvent, // Could be null, in which case no RR is sent - {}, + { hidden: hiddenRR }, ).catch((e) => { // /read_markers API is not implemented on this HS, fallback to just RR if (e.errcode === 'M_UNRECOGNIZED' && lastReadEvent) { diff --git a/src/components/structures/ToastContainer.tsx b/src/components/structures/ToastContainer.tsx index b7b0b7c652..0b0e871975 100644 --- a/src/components/structures/ToastContainer.tsx +++ b/src/components/structures/ToastContainer.tsx @@ -58,28 +58,39 @@ export default class ToastContainer extends React.Component<{}, IState> { let containerClasses; if (totalCount !== 0) { const topToast = this.state.toasts[0]; - const { title, icon, key, component, className, props } = topToast; - const toastClasses = classNames("mx_Toast_toast", { + const { title, icon, key, component, className, bodyClassName, props } = topToast; + const bodyClasses = classNames("mx_Toast_body", bodyClassName); + const toastClasses = classNames("mx_Toast_toast", className, { "mx_Toast_hasIcon": icon, [`mx_Toast_icon_${icon}`]: icon, - }, className); - - let countIndicator; - if (isStacked || this.state.countSeen > 0) { - countIndicator = ` (${this.state.countSeen + 1}/${this.state.countSeen + totalCount})`; - } - + }); const toastProps = Object.assign({}, props, { key, toastKey: key, }); - toast = (
    -
    -

    { title }

    - { countIndicator } + const content = React.createElement(component, toastProps); + + let countIndicator; + if (title && isStacked || this.state.countSeen > 0) { + countIndicator = ` (${this.state.countSeen + 1}/${this.state.countSeen + totalCount})`; + } + + let titleElement; + if (title) { + titleElement = ( +
    +

    { title }

    + { countIndicator } +
    + ); + } + + toast = ( +
    + { titleElement } +
    { content }
    -
    { React.createElement(component, toastProps) }
    -
    ); + ); containerClasses = classNames("mx_ToastContainer", { "mx_ToastContainer_stacked": isStacked, diff --git a/src/components/views/audio_messages/LiveRecordingWaveform.tsx b/src/components/views/audio_messages/LiveRecordingWaveform.tsx index 9c33889884..73e18626fe 100644 --- a/src/components/views/audio_messages/LiveRecordingWaveform.tsx +++ b/src/components/views/audio_messages/LiveRecordingWaveform.tsx @@ -17,8 +17,7 @@ limitations under the License. import React from "react"; import { IRecordingUpdate, RECORDING_PLAYBACK_SAMPLES, VoiceRecording } from "../../../audio/VoiceRecording"; import { replaceableComponent } from "../../../utils/replaceableComponent"; -import { arrayFastResample } from "../../../utils/arrays"; -import { percentageOf } from "../../../utils/numbers"; +import { arrayFastResample, arraySeed } from "../../../utils/arrays"; import Waveform from "./Waveform"; import { MarkedExecution } from "../../../utils/MarkedExecution"; @@ -48,18 +47,14 @@ export default class LiveRecordingWaveform extends React.PureComponent { - 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)); + // The incoming data is between zero and one, so we don't need to clamp/rescale it. + this.waveform = arrayFastResample(Array.from(update.waveform), RECORDING_PLAYBACK_SAMPLES); this.scheduledUpdate.mark(); }); } diff --git a/src/components/views/context_menus/DialpadContextMenu.tsx b/src/components/views/context_menus/DialpadContextMenu.tsx index aead3a266e..0bb96f9397 100644 --- a/src/components/views/context_menus/DialpadContextMenu.tsx +++ b/src/components/views/context_menus/DialpadContextMenu.tsx @@ -49,6 +49,13 @@ export default class DialpadContextMenu extends React.Component this.props.onFinished(); }; + onKeyDown = (ev) => { + // Prevent Backspace and Delete keys from functioning in the entry field + if (ev.code === "Backspace" || ev.code === "Delete") { + ev.preventDefault(); + } + }; + onChange = (ev) => { this.setState({ value: ev.target.value }); }; @@ -64,6 +71,7 @@ export default class DialpadContextMenu extends React.Component className="mx_DialPadContextMenu_dialled" value={this.state.value} autoFocus={true} + onKeyDown={this.onKeyDown} onChange={this.onChange} />
    diff --git a/src/components/views/context_menus/IconizedContextMenu.tsx b/src/components/views/context_menus/IconizedContextMenu.tsx index c9506d7c98..571b0b39bf 100644 --- a/src/components/views/context_menus/IconizedContextMenu.tsx +++ b/src/components/views/context_menus/IconizedContextMenu.tsx @@ -86,7 +86,10 @@ export const IconizedContextMenuCheckbox: React.FC = ({ > { label } - { active && } + ; }; diff --git a/src/components/views/context_menus/SpaceContextMenu.tsx b/src/components/views/context_menus/SpaceContextMenu.tsx new file mode 100644 index 0000000000..3da00e71aa --- /dev/null +++ b/src/components/views/context_menus/SpaceContextMenu.tsx @@ -0,0 +1,216 @@ +/* +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, { useContext } from "react"; +import { Room } from "matrix-js-sdk/src/models/room"; +import { EventType } from "matrix-js-sdk/src/@types/event"; + +import { + IProps as IContextMenuProps, +} from "../../structures/ContextMenu"; +import IconizedContextMenu, { IconizedContextMenuOption, IconizedContextMenuOptionList } from "./IconizedContextMenu"; +import { _t } from "../../../languageHandler"; +import { + leaveSpace, + shouldShowSpaceSettings, + showAddExistingRooms, + showCreateNewRoom, + showCreateNewSubspace, + showSpaceInvite, + showSpaceSettings, +} from "../../../utils/space"; +import MatrixClientContext from "../../../contexts/MatrixClientContext"; +import { ButtonEvent } from "../elements/AccessibleButton"; +import defaultDispatcher from "../../../dispatcher/dispatcher"; +import RoomViewStore from "../../../stores/RoomViewStore"; +import { SetRightPanelPhasePayload } from "../../../dispatcher/payloads/SetRightPanelPhasePayload"; +import { Action } from "../../../dispatcher/actions"; +import { RightPanelPhases } from "../../../stores/RightPanelStorePhases"; +import { BetaPill } from "../beta/BetaCard"; + +interface IProps extends IContextMenuProps { + space: Room; +} + +const SpaceContextMenu = ({ space, onFinished, ...props }: IProps) => { + const cli = useContext(MatrixClientContext); + const userId = cli.getUserId(); + + let inviteOption; + if (space.getJoinRule() === "public" || space.canInvite(userId)) { + const onInviteClick = (ev: ButtonEvent) => { + ev.preventDefault(); + ev.stopPropagation(); + + showSpaceInvite(space); + onFinished(); + }; + + inviteOption = ( + + ); + } + + let settingsOption; + let leaveSection; + if (shouldShowSpaceSettings(space)) { + const onSettingsClick = (ev: ButtonEvent) => { + ev.preventDefault(); + ev.stopPropagation(); + + showSpaceSettings(space); + onFinished(); + }; + + settingsOption = ( + + ); + } else { + const onLeaveClick = (ev: ButtonEvent) => { + ev.preventDefault(); + ev.stopPropagation(); + + leaveSpace(space); + onFinished(); + }; + + leaveSection = + + ; + } + + const canAddRooms = space.currentState.maySendStateEvent(EventType.SpaceChild, userId); + + let newRoomSection; + if (space.currentState.maySendStateEvent(EventType.SpaceChild, userId)) { + const onNewRoomClick = (ev: ButtonEvent) => { + ev.preventDefault(); + ev.stopPropagation(); + + showCreateNewRoom(space); + onFinished(); + }; + + const onAddExistingRoomClick = (ev: ButtonEvent) => { + ev.preventDefault(); + ev.stopPropagation(); + + showAddExistingRooms(space); + onFinished(); + }; + + const onNewSubspaceClick = (ev: ButtonEvent) => { + ev.preventDefault(); + ev.stopPropagation(); + + showCreateNewSubspace(space); + onFinished(); + }; + + newRoomSection = + + + + + + ; + } + + const onMembersClick = (ev: ButtonEvent) => { + ev.preventDefault(); + ev.stopPropagation(); + + if (!RoomViewStore.getRoomId()) { + defaultDispatcher.dispatch({ + action: "view_room", + room_id: space.roomId, + }, true); + } + + defaultDispatcher.dispatch({ + action: Action.SetRightPanelPhase, + phase: RightPanelPhases.SpaceMemberList, + refireParams: { space: space }, + }); + onFinished(); + }; + + const onExploreRoomsClick = (ev: ButtonEvent) => { + ev.preventDefault(); + ev.stopPropagation(); + + defaultDispatcher.dispatch({ + action: "view_room", + room_id: space.roomId, + }); + onFinished(); + }; + + return +
    + { space.name } +
    + + { inviteOption } + + { settingsOption } + + + { newRoomSection } + { leaveSection } +
    ; +}; + +export default SpaceContextMenu; + diff --git a/src/components/views/context_menus/TagTileContextMenu.js b/src/components/views/context_menus/TagTileContextMenu.js index c40ff4207b..0c3c48a07f 100644 --- a/src/components/views/context_menus/TagTileContextMenu.js +++ b/src/components/views/context_menus/TagTileContextMenu.js @@ -24,6 +24,8 @@ import { MenuItem } from "../../structures/ContextMenu"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import { replaceableComponent } from "../../../utils/replaceableComponent"; import GroupFilterOrderStore from "../../../stores/GroupFilterOrderStore"; +import { createSpaceFromCommunity } from "../../../utils/space"; +import GroupStore from "../../../stores/GroupStore"; @replaceableComponent("views.context_menus.TagTileContextMenu") export default class TagTileContextMenu extends React.Component { @@ -49,6 +51,11 @@ export default class TagTileContextMenu extends React.Component { this.props.onFinished(); }; + _onCreateSpaceClick = () => { + createSpaceFromCommunity(this.context, this.props.tag); + this.props.onFinished(); + }; + _onMoveUp = () => { dis.dispatch(TagOrderActions.moveTag(this.context, this.props.tag, this.props.index - 1)); this.props.onFinished(); @@ -77,6 +84,16 @@ export default class TagTileContextMenu extends React.Component { ); } + let createSpaceOption; + if (GroupStore.isUserPrivileged(this.props.tag)) { + createSpaceOption = <> +
    + + { _t("Create Space") } + + ; + } + return
    { _t('View Community') } @@ -88,6 +105,7 @@ export default class TagTileContextMenu extends React.Component { { _t("Unpin") } + { createSpaceOption }
    ; } } diff --git a/src/components/views/dialogs/CreateRoomDialog.tsx b/src/components/views/dialogs/CreateRoomDialog.tsx index 597fe33777..d573882f40 100644 --- a/src/components/views/dialogs/CreateRoomDialog.tsx +++ b/src/components/views/dialogs/CreateRoomDialog.tsx @@ -116,8 +116,8 @@ export default class CreateRoomDialog extends React.Component { opts.associatedWithCommunity = CommunityPrototypeStore.instance.getSelectedCommunityId(); } + opts.parentSpace = this.props.parentSpace; if (this.props.parentSpace && this.state.joinRule === JoinRule.Restricted) { - opts.parentSpace = this.props.parentSpace; opts.joinRule = JoinRule.Restricted; } diff --git a/src/components/views/dialogs/CreateSpaceFromCommunityDialog.tsx b/src/components/views/dialogs/CreateSpaceFromCommunityDialog.tsx new file mode 100644 index 0000000000..4fb0994e23 --- /dev/null +++ b/src/components/views/dialogs/CreateSpaceFromCommunityDialog.tsx @@ -0,0 +1,340 @@ +/* +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, { useEffect, useRef, useState } from "react"; +import { JoinRule } from "matrix-js-sdk/src/@types/partials"; +import { EventType } from "matrix-js-sdk/src/@types/event"; +import { MatrixClient } from "matrix-js-sdk/src/matrix"; + +import { _t } from '../../../languageHandler'; +import BaseDialog from "./BaseDialog"; +import AccessibleButton from "../elements/AccessibleButton"; +import { createSpace, SpaceCreateForm } from "../spaces/SpaceCreateMenu"; +import JoinRuleDropdown from "../elements/JoinRuleDropdown"; +import Field from "../elements/Field"; +import RoomAliasField from "../elements/RoomAliasField"; +import { GroupMember } from "../right_panel/UserInfo"; +import { parseMembersResponse, parseRoomsResponse } from "../../../stores/GroupStore"; +import { calculateRoomVia, makeRoomPermalink } from "../../../utils/permalinks/Permalinks"; +import { useAsyncMemo } from "../../../hooks/useAsyncMemo"; +import Spinner from "../elements/Spinner"; +import { mediaFromMxc } from "../../../customisations/Media"; +import SpaceStore from "../../../stores/SpaceStore"; +import Modal from "../../../Modal"; +import InfoDialog from "./InfoDialog"; +import dis from "../../../dispatcher/dispatcher"; +import { Action } from "../../../dispatcher/actions"; +import { UserTab } from "./UserSettingsDialog"; +import TagOrderActions from "../../../actions/TagOrderActions"; + +interface IProps { + matrixClient: MatrixClient; + groupId: string; + onFinished(spaceId?: string): void; +} + +export const CreateEventField = "io.element.migrated_from_community"; + +interface IGroupRoom { + displayname: string; + name?: string; + roomId: string; + canonicalAlias?: string; + avatarUrl?: string; + topic?: string; + numJoinedMembers?: number; + worldReadable?: boolean; + guestCanJoin?: boolean; + isPublic?: boolean; +} + +/* eslint-disable camelcase */ +export interface IGroupSummary { + profile: { + avatar_url?: string; + is_openly_joinable?: boolean; + is_public?: boolean; + long_description: string; + name: string; + short_description: string; + }; + rooms_section: { + rooms: unknown[]; + categories: Record; + total_room_count_estimate: number; + }; + user: { + is_privileged: boolean; + is_public: boolean; + is_publicised: boolean; + membership: string; + }; + users_section: { + users: unknown[]; + roles: Record; + total_user_count_estimate: number; + }; +} +/* eslint-enable camelcase */ + +const CreateSpaceFromCommunityDialog: React.FC = ({ matrixClient: cli, groupId, onFinished }) => { + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + const [avatar, setAvatar] = useState(null); // undefined means to remove avatar + const [name, setName] = useState(""); + const spaceNameField = useRef(); + const [alias, setAlias] = useState("#" + groupId.substring(1, groupId.indexOf(":")) + ":" + cli.getDomain()); + const spaceAliasField = useRef(); + const [topic, setTopic] = useState(""); + const [joinRule, setJoinRule] = useState(JoinRule.Public); + + const groupSummary = useAsyncMemo(() => cli.getGroupSummary(groupId), [groupId]); + useEffect(() => { + if (groupSummary) { + setName(groupSummary.profile.name || ""); + setTopic(groupSummary.profile.short_description || ""); + setJoinRule(groupSummary.profile.is_openly_joinable ? JoinRule.Public : JoinRule.Invite); + setLoading(false); + } + }, [groupSummary]); + + if (loading) { + return ; + } + + const onCreateSpaceClick = async (e) => { + e.preventDefault(); + if (busy) return; + + setError(null); + setBusy(true); + + // require & validate the space name field + if (!await spaceNameField.current.validate({ allowEmpty: false })) { + setBusy(false); + spaceNameField.current.focus(); + spaceNameField.current.validate({ allowEmpty: false, focused: true }); + return; + } + // validate the space name alias field but do not require it + if (joinRule === JoinRule.Public && !await spaceAliasField.current.validate({ allowEmpty: true })) { + setBusy(false); + spaceAliasField.current.focus(); + spaceAliasField.current.validate({ allowEmpty: true, focused: true }); + return; + } + + try { + const [rooms, members, invitedMembers] = await Promise.all([ + cli.getGroupRooms(groupId).then(parseRoomsResponse) as Promise, + cli.getGroupUsers(groupId).then(parseMembersResponse) as Promise, + cli.getGroupInvitedUsers(groupId).then(parseMembersResponse) as Promise, + ]); + + const viaMap = new Map(); + for (const { roomId, canonicalAlias } of rooms) { + const room = cli.getRoom(roomId); + if (room) { + viaMap.set(roomId, calculateRoomVia(room)); + } else if (canonicalAlias) { + try { + const { servers } = await cli.getRoomIdForAlias(canonicalAlias); + viaMap.set(roomId, servers); + } catch (e) { + console.warn("Failed to resolve alias during community migration", e); + } + } + + if (!viaMap.get(roomId)?.length) { + // XXX: lets guess the via, this might end up being incorrect. + const str = canonicalAlias || roomId; + viaMap.set(roomId, [str.substring(1, str.indexOf(":"))]); + } + } + + const spaceAvatar = avatar !== undefined ? avatar : groupSummary.profile.avatar_url; + const roomId = await createSpace(name, joinRule === JoinRule.Public, alias, topic, spaceAvatar, { + creation_content: { + [CreateEventField]: groupId, + }, + initial_state: rooms.map(({ roomId }) => ({ + type: EventType.SpaceChild, + state_key: roomId, + content: { + via: viaMap.get(roomId) || [], + }, + })), + invite: [...members, ...invitedMembers].map(m => m.userId).filter(m => m !== cli.getUserId()), + }, { + andView: false, + }); + + // eagerly remove it from the community panel + dis.dispatch(TagOrderActions.removeTag(cli, groupId)); + + // don't bother awaiting this, as we don't hugely care if it fails + cli.setGroupProfile(groupId, { + ...groupSummary.profile, + long_description: `

    ` + + _t("This community has been upgraded into a Space") + `


    ` + + groupSummary.profile.long_description, + } as IGroupSummary["profile"]).catch(e => { + console.warn("Failed to update community profile during migration", e); + }); + + onFinished(roomId); + + const onSpaceClick = () => { + dis.dispatch({ + action: "view_room", + room_id: roomId, + }); + }; + + const onPreferencesClick = () => { + dis.dispatch({ + action: Action.ViewUserSettings, + initialTabId: UserTab.Preferences, + }); + }; + + let spacesDisabledCopy; + if (!SpaceStore.spacesEnabled) { + spacesDisabledCopy = _t("To view Spaces, hide communities in Preferences", {}, { + a: sub => { sub }, + }); + } + + Modal.createDialog(InfoDialog, { + title: _t("Space created"), + description: <> +
    +

    + { _t(" has been made and everyone who was a part of the community has " + + "been invited to it.", {}, { + SpaceName: () => + { name } + , + }) } +   + { spacesDisabledCopy } +

    +

    + { _t("To create a Space from another community, just pick the community in Preferences.") } +

    + , + button: _t("Preferences"), + onFinished: (openPreferences: boolean) => { + if (openPreferences) { + onPreferencesClick(); + } + }, + }, "mx_CreateSpaceFromCommunityDialog_SuccessInfoDialog"); + } catch (e) { + console.error(e); + setError(e); + } + + setBusy(false); + }; + + let footer; + if (error) { + footer = <> + + + +
    { _t("Failed to migrate community") }
    +
    { _t("Try again") }
    +
    + + + { _t("Retry") } + + ; + } else { + footer = <> + onFinished()}> + { _t("Cancel") } + + + { busy ? _t("Creating...") : _t("Create Space") } + + ; + } + + return +
    +

    + { _t("A link to the Space will be put in your community description.") } +   + { _t("All rooms will be added and all community members will be invited.") } +

    +

    + { _t("Flair won't be available in Spaces for the foreseeable future.") } +

    + + +

    { _t("This description will be shown to people when they view your space") }

    + +

    { joinRule === JoinRule.Public + ? _t("Open space for anyone, best for communities") + : _t("Invite only, best for yourself or teams") + }

    + { joinRule !== JoinRule.Public && +
    + } + +
    + +
    + { footer } +
    + ; +}; + +export default CreateSpaceFromCommunityDialog; + diff --git a/src/components/views/dialogs/CreateSubspaceDialog.tsx b/src/components/views/dialogs/CreateSubspaceDialog.tsx index 0d71eb2de3..03927c7d62 100644 --- a/src/components/views/dialogs/CreateSubspaceDialog.tsx +++ b/src/components/views/dialogs/CreateSubspaceDialog.tsx @@ -16,8 +16,7 @@ limitations under the License. import React, { useRef, useState } from "react"; import { Room } from "matrix-js-sdk/src/models/room"; -import { JoinRule, Preset } from "matrix-js-sdk/src/@types/partials"; -import { RoomType } from "matrix-js-sdk/src/@types/event"; +import { JoinRule } from "matrix-js-sdk/src/@types/partials"; import { _t } from '../../../languageHandler'; import BaseDialog from "./BaseDialog"; @@ -27,8 +26,7 @@ import { BetaPill } from "../beta/BetaCard"; import Field from "../elements/Field"; import RoomAliasField from "../elements/RoomAliasField"; import SpaceStore from "../../../stores/SpaceStore"; -import { SpaceCreateForm } from "../spaces/SpaceCreateMenu"; -import createRoom from "../../../createRoom"; +import { createSpace, SpaceCreateForm } from "../spaces/SpaceCreateMenu"; import { SubspaceSelector } from "./AddExistingToSpaceDialog"; import JoinRuleDropdown from "../elements/JoinRuleDropdown"; @@ -81,28 +79,7 @@ const CreateSubspaceDialog: React.FC = ({ space, onAddExistingSpaceClick } try { - await createRoom({ - createOpts: { - preset: joinRule === JoinRule.Public ? Preset.PublicChat : Preset.PrivateChat, - name, - power_level_content_override: { - // Only allow Admins to write to the timeline to prevent hidden sync spam - events_default: 100, - ...joinRule === JoinRule.Public ? { invite: 0 } : {}, - }, - room_alias_name: joinRule === JoinRule.Public && alias - ? alias.substr(1, alias.indexOf(":") - 1) - : undefined, - topic, - }, - avatar, - roomType: RoomType.Space, - parentSpace, - spinner: false, - encryption: false, - andView: true, - inlineErrors: true, - }); + await createSpace(name, joinRule === JoinRule.Public, alias, topic, avatar, {}, { parentSpace }); onFinished(true); } catch (e) { diff --git a/src/components/views/dialogs/DevtoolsDialog.tsx b/src/components/views/dialogs/DevtoolsDialog.tsx index 8ae9d0654f..30e6c70f0e 100644 --- a/src/components/views/dialogs/DevtoolsDialog.tsx +++ b/src/components/views/dialogs/DevtoolsDialog.tsx @@ -490,9 +490,9 @@ class RoomStateExplorer extends React.PureComponent; } diff --git a/src/components/views/dialogs/KeySignatureUploadFailedDialog.js b/src/components/views/dialogs/KeySignatureUploadFailedDialog.js index 9fa9fc1373..6b36c19977 100644 --- a/src/components/views/dialogs/KeySignatureUploadFailedDialog.js +++ b/src/components/views/dialogs/KeySignatureUploadFailedDialog.js @@ -20,10 +20,10 @@ import { _t } from '../../../languageHandler'; import SdkConfig from '../../../SdkConfig'; export default function KeySignatureUploadFailedDialog({ - failures, - source, - continuation, - onFinished, + failures, + source, + continuation, + onFinished, }) { const RETRIES = 2; const BaseDialog = sdk.getComponent('dialogs.BaseDialog'); diff --git a/src/components/views/dialogs/LogoutDialog.js b/src/components/views/dialogs/LogoutDialog.tsx similarity index 82% rename from src/components/views/dialogs/LogoutDialog.js rename to src/components/views/dialogs/LogoutDialog.tsx index 469cd48093..8c035dcbba 100644 --- a/src/components/views/dialogs/LogoutDialog.js +++ b/src/components/views/dialogs/LogoutDialog.tsx @@ -16,6 +16,7 @@ limitations under the License. */ import React from 'react'; +import { IKeyBackupInfo } from "matrix-js-sdk/src/crypto/keybackup"; import Modal from '../../../Modal'; import * as sdk from '../../../index'; import dis from '../../../dispatcher/dispatcher'; @@ -24,19 +25,25 @@ import { MatrixClientPeg } from '../../../MatrixClientPeg'; import RestoreKeyBackupDialog from './security/RestoreKeyBackupDialog'; import { replaceableComponent } from "../../../utils/replaceableComponent"; +interface IProps { + onFinished: (success: boolean) => void; +} + +interface IState { + shouldLoadBackupStatus: boolean; + loading: boolean; + backupInfo: IKeyBackupInfo; + error?: string; +} + @replaceableComponent("views.dialogs.LogoutDialog") -export default class LogoutDialog extends React.Component { - defaultProps = { +export default class LogoutDialog extends React.Component { + static defaultProps = { onFinished: function() {}, }; - constructor() { - super(); - this._onSettingsLinkClick = this._onSettingsLinkClick.bind(this); - this._onExportE2eKeysClicked = this._onExportE2eKeysClicked.bind(this); - this._onFinished = this._onFinished.bind(this); - this._onSetRecoveryMethodClick = this._onSetRecoveryMethodClick.bind(this); - this._onLogoutConfirm = this._onLogoutConfirm.bind(this); + constructor(props) { + super(props); const cli = MatrixClientPeg.get(); const shouldLoadBackupStatus = cli.isCryptoEnabled() && !cli.getKeyBackupEnabled(); @@ -49,11 +56,11 @@ export default class LogoutDialog extends React.Component { }; if (shouldLoadBackupStatus) { - this._loadBackupStatus(); + this.loadBackupStatus(); } } - async _loadBackupStatus() { + private async loadBackupStatus() { try { const backupInfo = await MatrixClientPeg.get().getKeyBackupVersion(); this.setState({ @@ -69,29 +76,29 @@ export default class LogoutDialog extends React.Component { } } - _onSettingsLinkClick() { + private onSettingsLinkClick = (): void => { // close dialog - this.props.onFinished(); - } + this.props.onFinished(true); + }; - _onExportE2eKeysClicked() { + private onExportE2eKeysClicked = (): void => { Modal.createTrackedDialogAsync('Export E2E Keys', '', import('../../../async-components/views/dialogs/security/ExportE2eKeysDialog'), { matrixClient: MatrixClientPeg.get(), }, ); - } + }; - _onFinished(confirmed) { + private onFinished = (confirmed: boolean): void => { if (confirmed) { dis.dispatch({ action: 'logout' }); } // close dialog - this.props.onFinished(); - } + this.props.onFinished(confirmed); + }; - _onSetRecoveryMethodClick() { + private onSetRecoveryMethodClick = (): void => { if (this.state.backupInfo) { // A key backup exists for this account, but the creating device is not // verified, so restore the backup which will give us the keys from it and @@ -108,15 +115,15 @@ export default class LogoutDialog extends React.Component { } // close dialog - this.props.onFinished(); - } + this.props.onFinished(true); + }; - _onLogoutConfirm() { + private onLogoutConfirm = (): void => { dis.dispatch({ action: 'logout' }); // close dialog - this.props.onFinished(); - } + this.props.onFinished(true); + }; render() { if (this.state.shouldLoadBackupStatus) { @@ -152,16 +159,16 @@ export default class LogoutDialog extends React.Component {
    -
    { _t("Advanced") } -

    @@ -174,7 +181,7 @@ export default class LogoutDialog extends React.Component { title={_t("You'll lose access to your encrypted messages")} contentId='mx_Dialog_content' hasCancel={true} - onFinished={this._onFinished} + onFinished={this.onFinished} > { dialogContent }
    ); @@ -187,7 +194,7 @@ export default class LogoutDialog extends React.Component { "Are you sure you want to sign out?", )} button={_t("Sign out")} - onFinished={this._onFinished} + onFinished={this.onFinished} />); } } diff --git a/src/components/views/dialogs/UserSettingsDialog.tsx b/src/components/views/dialogs/UserSettingsDialog.tsx index 7608d7cb55..9613b27d17 100644 --- a/src/components/views/dialogs/UserSettingsDialog.tsx +++ b/src/components/views/dialogs/UserSettingsDialog.tsx @@ -114,7 +114,7 @@ export default class UserSettingsDialog extends React.Component UserTab.Preferences, _td("Preferences"), "mx_UserSettingsDialog_preferencesIcon", - , + , )); if (SettingsStore.getValue(UIFeature.Voip)) { diff --git a/src/components/views/elements/AccessibleButton.tsx b/src/components/views/elements/AccessibleButton.tsx index 8bb6341c3d..0ce9a3a030 100644 --- a/src/components/views/elements/AccessibleButton.tsx +++ b/src/components/views/elements/AccessibleButton.tsx @@ -67,7 +67,9 @@ export default function AccessibleButton({ ...restProps }: IProps) { const newProps: IAccessibleButtonProps = restProps; - if (!disabled) { + if (disabled) { + newProps["aria-disabled"] = true; + } else { newProps.onClick = onClick; // We need to consume enter onKeyDown and space onKeyUp // otherwise we are risking also activating other keyboard focusable elements @@ -118,7 +120,7 @@ export default function AccessibleButton({ ); // React.createElement expects InputHTMLAttributes - return React.createElement(element, restProps, children); + return React.createElement(element, newProps, children); } AccessibleButton.defaultProps = { diff --git a/src/components/views/elements/AppPermission.tsx b/src/components/views/elements/AppPermission.tsx index 8dc874381a..c0543eb363 100644 --- a/src/components/views/elements/AppPermission.tsx +++ b/src/components/views/elements/AppPermission.tsx @@ -62,10 +62,10 @@ export default class AppPermission extends React.Component { // Set all this into the initial state this.state = { - ...urlInfo, - roomMember, - isWrapped: null, widgetDomain: null, + isWrapped: null, + roomMember, + ...urlInfo, }; } diff --git a/src/components/views/elements/Dropdown.tsx b/src/components/views/elements/Dropdown.tsx index dddcceb97c..b4f382c9c3 100644 --- a/src/components/views/elements/Dropdown.tsx +++ b/src/components/views/elements/Dropdown.tsx @@ -18,7 +18,7 @@ limitations under the License. import React, { ChangeEvent, createRef, CSSProperties, ReactElement, ReactNode, Ref } from 'react'; import classnames from 'classnames'; -import AccessibleButton from './AccessibleButton'; +import AccessibleButton, { ButtonEvent } from './AccessibleButton'; import { _t } from '../../../languageHandler'; import { Key } from "../../../Keyboard"; import { replaceableComponent } from "../../../utils/replaceableComponent"; @@ -178,7 +178,7 @@ export default class Dropdown extends React.Component { this.ignoreEvent = ev; }; - private onInputClick = (ev: React.MouseEvent) => { + private onAccessibleButtonClick = (ev: ButtonEvent) => { if (this.props.disabled) return; if (!this.state.expanded) { @@ -186,6 +186,10 @@ export default class Dropdown extends React.Component { expanded: true, }); ev.preventDefault(); + } else if ((ev as React.KeyboardEvent).key === Key.ENTER) { + // the accessible button consumes enter onKeyDown for firing onClick, so handle it here + this.props.onOptionChange(this.state.highlightedOption); + this.close(); } }; @@ -204,7 +208,7 @@ export default class Dropdown extends React.Component { this.props.onOptionChange(dropdownKey); }; - private onInputKeyDown = (e: React.KeyboardEvent) => { + private onKeyDown = (e: React.KeyboardEvent) => { let handled = true; // These keys don't generate keypress events and so needs to be on keyup @@ -269,7 +273,7 @@ export default class Dropdown extends React.Component { private prevOption(optionKey: string): string { const keys = Object.keys(this.childrenByKey); const index = keys.indexOf(optionKey); - return keys[(index - 1) % keys.length]; + return keys[index <= 0 ? keys.length - 1 : (index - 1) % keys.length]; } private scrollIntoView(node: Element) { @@ -320,7 +324,6 @@ export default class Dropdown extends React.Component { type="text" autoFocus={true} className="mx_Dropdown_option" - onKeyDown={this.onInputKeyDown} onChange={this.onInputChange} value={this.state.searchQuery} role="combobox" @@ -329,6 +332,7 @@ export default class Dropdown extends React.Component { aria-owns={`${this.props.id}_listbox`} aria-disabled={this.props.disabled} aria-label={this.props.label} + onKeyDown={this.onKeyDown} /> ); } @@ -361,13 +365,14 @@ export default class Dropdown extends React.Component { return
    { currentValue } diff --git a/src/components/views/elements/EventListSummary.tsx b/src/components/views/elements/EventListSummary.tsx index d66319ca73..cbb0e17b42 100644 --- a/src/components/views/elements/EventListSummary.tsx +++ b/src/components/views/elements/EventListSummary.tsx @@ -34,7 +34,7 @@ interface IProps { // The list of room members for which to show avatars next to the summary summaryMembers?: RoomMember[]; // The text to show as the summary of this event list - summaryText?: string; + summaryText?: string | JSX.Element; // An array of EventTiles to render when expanded children: ReactNode[]; // Called when the event list expansion is toggled diff --git a/src/components/views/elements/MemberEventListSummary.tsx b/src/components/views/elements/MemberEventListSummary.tsx index 604e6c63b0..0722cb872a 100644 --- a/src/components/views/elements/MemberEventListSummary.tsx +++ b/src/components/views/elements/MemberEventListSummary.tsx @@ -25,8 +25,24 @@ import { formatCommaSeparatedList } from '../../../utils/FormattingUtils'; import { isValid3pidInvite } from "../../../RoomInvite"; import EventListSummary from "./EventListSummary"; import { replaceableComponent } from "../../../utils/replaceableComponent"; +import defaultDispatcher from '../../../dispatcher/dispatcher'; +import { RightPanelPhases } from '../../../stores/RightPanelStorePhases'; +import { Action } from '../../../dispatcher/actions'; +import { SetRightPanelPhasePayload } from '../../../dispatcher/payloads/SetRightPanelPhasePayload'; +import { jsxJoin } from '../../../utils/ReactUtils'; +import { EventType } from 'matrix-js-sdk/src/@types/event'; import { Layout } from '../../../settings/Layout'; +const onPinnedMessagesClick = (): void => { + defaultDispatcher.dispatch({ + action: Action.SetRightPanelPhase, + phase: RightPanelPhases.PinnedMessages, + allowClose: false, + }); +}; + +const SENDER_AS_DISPLAY_NAME_EVENTS = [EventType.RoomServerAcl, EventType.RoomPinnedEvents]; + interface IProps extends Omit, "summaryText" | "summaryMembers"> { // The maximum number of names to show in either each summary e.g. 2 would result "A, B and 234 others left" summaryLength?: number; @@ -60,6 +76,7 @@ enum TransitionType { ChangedAvatar = "changed_avatar", NoChange = "no_change", ServerAcl = "server_acl", + ChangedPins = "pinned_messages" } const SEP = ","; @@ -93,7 +110,10 @@ export default class MemberEventListSummary extends React.Component { * `Object.keys(eventAggregates)`. * @returns {string} the textual summary of the aggregated events that occurred. */ - private generateSummary(eventAggregates: Record, orderedTransitionSequences: string[]) { + private generateSummary( + eventAggregates: Record, + orderedTransitionSequences: string[], + ): string | JSX.Element { const summaries = orderedTransitionSequences.map((transitions) => { const userNames = eventAggregates[transitions]; const nameList = this.renderNameList(userNames); @@ -122,7 +142,7 @@ export default class MemberEventListSummary extends React.Component { return null; } - return summaries.join(", "); + return jsxJoin(summaries, ", "); } /** @@ -216,7 +236,11 @@ export default class MemberEventListSummary extends React.Component { * @param {number} repeats the number of times the transition was repeated in a row. * @returns {string} the written Human Readable equivalent of the transition. */ - private static getDescriptionForTransition(t: TransitionType, userCount: number, repeats: number) { + private static getDescriptionForTransition( + t: TransitionType, + userCount: number, + repeats: number, + ): string | JSX.Element { // The empty interpolations 'severalUsers' and 'oneUser' // are there only to show translators to non-English languages // that the verb is conjugated to plural or singular Subject. @@ -299,6 +323,15 @@ export default class MemberEventListSummary extends React.Component { { severalUsers: "", count: repeats }) : _t("%(oneUser)schanged the server ACLs %(count)s times", { oneUser: "", count: repeats }); break; + case "pinned_messages": + res = (userCount > 1) + ? _t("%(severalUsers)schanged the pinned messages for the room %(count)s times.", + { severalUsers: "", count: repeats }, + { "a": (sub) => { sub } }) + : _t("%(oneUser)schanged the pinned messages for the room %(count)s times.", + { oneUser: "", count: repeats }, + { "a": (sub) => { sub } }); + break; } return res; @@ -317,16 +350,18 @@ export default class MemberEventListSummary extends React.Component { * if a transition is not recognised. */ private static getTransition(e: IUserEvents): TransitionType { - if (e.mxEvent.getType() === 'm.room.third_party_invite') { + const type = e.mxEvent.getType(); + + if (type === EventType.RoomThirdPartyInvite) { // Handle 3pid invites the same as invites so they get bundled together if (!isValid3pidInvite(e.mxEvent)) { return TransitionType.InviteWithdrawal; } return TransitionType.Invited; - } - - if (e.mxEvent.getType() === 'm.room.server_acl') { + } else if (type === EventType.RoomServerAcl) { return TransitionType.ServerAcl; + } else if (type === EventType.RoomPinnedEvents) { + return TransitionType.ChangedPins; } switch (e.mxEvent.getContent().membership) { @@ -415,22 +450,23 @@ export default class MemberEventListSummary extends React.Component { // Object mapping user IDs to an array of IUserEvents const userEvents: Record = {}; eventsToRender.forEach((e, index) => { - const userId = e.getType() === 'm.room.server_acl' ? e.getSender() : e.getStateKey(); + const type = e.getType(); + const userId = type === EventType.RoomServerAcl ? e.getSender() : e.getStateKey(); // Initialise a user's events if (!userEvents[userId]) { userEvents[userId] = []; } - if (e.getType() === 'm.room.server_acl') { + if (SENDER_AS_DISPLAY_NAME_EVENTS.includes(type as EventType)) { latestUserAvatarMember.set(userId, e.sender); } else if (e.target) { latestUserAvatarMember.set(userId, e.target); } let displayName = userId; - if (e.getType() === 'm.room.third_party_invite') { + if (type === EventType.RoomThirdPartyInvite) { displayName = e.getContent().display_name; - } else if (e.getType() === 'm.room.server_acl') { + } else if (SENDER_AS_DISPLAY_NAME_EVENTS.includes(type as EventType)) { displayName = e.sender.name; } else if (e.target) { displayName = e.target.name; diff --git a/src/components/views/messages/CallEvent.tsx b/src/components/views/messages/CallEvent.tsx index 2de66f897a..bc868c35b3 100644 --- a/src/components/views/messages/CallEvent.tsx +++ b/src/components/views/messages/CallEvent.tsx @@ -25,7 +25,7 @@ import { CallErrorCode, CallState } from 'matrix-js-sdk/src/webrtc/call'; import InfoTooltip, { InfoTooltipKind } from '../elements/InfoTooltip'; import classNames from 'classnames'; import AccessibleTooltipButton from '../elements/AccessibleTooltipButton'; -import { MatrixClientPeg } from '../../../MatrixClientPeg'; +import { formatCallTime } from "../../../DateUtils"; interface IProps { mxEvent: MatrixEvent; @@ -117,14 +117,12 @@ export default class CallEvent extends React.Component { if (state === CallState.Ended) { const hangupReason = this.props.callEventGrouper.hangupReason; const gotRejected = this.props.callEventGrouper.gotRejected; - const rejectParty = this.props.callEventGrouper.rejectParty; if (gotRejected) { - const weDeclinedCall = MatrixClientPeg.get().getUserId() === rejectParty; return (
    - { weDeclinedCall ? _t("You declined this call") : _t("They declined this call") } - { this.renderCallBackButton(weDeclinedCall ? _t("Call back") : _t("Call again")) } + { _t("Call declined") } + { this.renderCallBackButton(_t("Call back")) }
    ); } else if (([CallErrorCode.UserHangup, "user hangup"].includes(hangupReason) || !hangupReason)) { @@ -134,16 +132,21 @@ export default class CallEvent extends React.Component { // https://github.com/vector-im/riot-android/issues/2623 // Also the correct hangup code as of VoIP v1 (with underscore) // Also, if we don't have a reason + const duration = this.props.callEventGrouper.duration; + let text = _t("Call ended"); + if (duration) { + text += " • " + formatCallTime(duration); + } return (
    - { _t("This call has ended") } + { text }
    ); } else if (hangupReason === CallErrorCode.InviteTimeout) { return (
    - { _t("They didn't pick up") } - { this.renderCallBackButton(_t("Call again")) } + { _t("Missed call") } + { this.renderCallBackButton(_t("Call back")) }
    ); } @@ -176,7 +179,8 @@ export default class CallEvent extends React.Component { className="mx_CallEvent_content_tooltip" kind={InfoTooltipKind.Warning} /> - { _t("This call has failed") } + { _t("Connection failed") } + { this.renderCallBackButton(_t("Retry")) }
    ); } @@ -190,7 +194,7 @@ export default class CallEvent extends React.Component { if (state === CustomCallState.Missed) { return (
    - { _t("You missed this call") } + { _t("Missed call") } { this.renderCallBackButton(_t("Call back")) }
    ); diff --git a/src/components/views/messages/DownloadActionButton.tsx b/src/components/views/messages/DownloadActionButton.tsx index 2bdae04eda..6dc48b0936 100644 --- a/src/components/views/messages/DownloadActionButton.tsx +++ b/src/components/views/messages/DownloadActionButton.tsx @@ -16,12 +16,13 @@ limitations under the License. import { MatrixEvent } from "matrix-js-sdk/src"; import { MediaEventHelper } from "../../../utils/MediaEventHelper"; -import React, { createRef } from "react"; +import React from "react"; import { RovingAccessibleTooltipButton } from "../../../accessibility/RovingTabIndex"; import Spinner from "../elements/Spinner"; import classNames from "classnames"; import { _t } from "../../../languageHandler"; import { replaceableComponent } from "../../../utils/replaceableComponent"; +import { FileDownloader } from "../../../utils/FileDownloader"; interface IProps { mxEvent: MatrixEvent; @@ -39,7 +40,7 @@ interface IState { @replaceableComponent("views.messages.DownloadActionButton") export default class DownloadActionButton extends React.PureComponent { - private iframe: React.RefObject = createRef(); + private downloader = new FileDownloader(); public constructor(props: IProps) { super(props); @@ -56,27 +57,21 @@ export default class DownloadActionButton extends React.PureComponent { - this.setState({ loading: false }); - - // we aren't showing the iframe, so we can send over the bare minimum styles and such. - this.iframe.current.contentWindow.postMessage({ - imgSrc: "", // no image - imgStyle: null, - style: "", + private async doDownload() { + await this.downloader.download({ blob: this.state.blob, - download: this.props.mediaEventHelperGet().fileName, - textContent: "", - auto: true, // autodownload - }, '*'); - }; + name: this.props.mediaEventHelperGet().fileName, + }); + this.setState({ loading: false }); + } public render() { let spinner: JSX.Element; @@ -92,18 +87,11 @@ export default class DownloadActionButton extends React.PureComponent { spinner } - { this.state.blob &&