diff --git a/.eslintrc.js b/.eslintrc.js index 67e6ab1e642..f3e38f94c3c 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -86,6 +86,8 @@ module.exports = { "jsx-a11y/no-static-element-interactions": "off", "jsx-a11y/role-supports-aria-props": "off", "jsx-a11y/tabindex-no-positive": "off", + + "matrix-org/require-copyright-header": "error", }, overrides: [ { diff --git a/.github/workflows/element-build-and-test.yaml b/.github/workflows/element-build-and-test.yaml index 9b3d0f373a0..a1047094158 100644 --- a/.github/workflows/element-build-and-test.yaml +++ b/.github/workflows/element-build-and-test.yaml @@ -67,14 +67,20 @@ jobs: - name: Run Cypress tests uses: cypress-io/github-action@v2 with: - # The built in Electron runner seems to grind to a halt trying + # The built-in Electron runner seems to grind to a halt trying # to run the tests, so use chrome. browser: chrome start: npx serve -p 8080 webapp record: true + command-prefix: 'yarn percy exec --' env: # pass the Dashboard record key as an environment variable CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }} + # pass the Percy token as an environment variable + PERCY_TOKEN: ${{ secrets.PERCY_TOKEN }} + # Use existing chromium rather than downloading another + PUPPETEER_SKIP_CHROMIUM_DOWNLOAD: true + PERCY_BROWSER_EXECUTABLE: /usr/bin/chromium-browser # pass GitHub token to allow accurately detecting a build vs a re-run build GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -88,6 +94,20 @@ jobs: cypress/videos cypress/synapselogs + - name: Store benchmark result + if: github.ref == 'refs/heads/develop' + uses: matrix-org/github-action-benchmark@jsperfentry-1 + with: + name: Cypress measurements + tool: 'jsperformanceentry' + output-file-path: cypress/performance/measurements.json + # The dashboard is available at https://matrix-org.github.io/matrix-react-sdk/cypress/bench/ + benchmark-data-dir-path: cypress/bench + fail-on-alert: false + comment-on-alert: false + github-token: ${{ secrets.DEPLOY_GH_PAGES }} + auto-push: ${{ github.ref == 'refs/heads/develop' }} + app-tests: name: Element Web Integration Tests runs-on: ubuntu-latest diff --git a/.github/workflows/pull_request.yaml b/.github/workflows/pull_request.yaml index 22a92bf0b56..8d115062ea6 100644 --- a/.github/workflows/pull_request.yaml +++ b/.github/workflows/pull_request.yaml @@ -1,10 +1,11 @@ name: Pull Request on: pull_request_target: - types: [ opened, edited, labeled, unlabeled ] + types: [ opened, edited, labeled, unlabeled, synchronize ] jobs: changelog: name: Preview Changelog + if: github.event.action != 'synchronize' runs-on: ubuntu-latest steps: - uses: matrix-org/allchange@main diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml index 7029be97f3b..95b06bab6b5 100644 --- a/.github/workflows/sonarqube.yml +++ b/.github/workflows/sonarqube.yml @@ -4,44 +4,34 @@ on: workflows: [ "Tests" ] types: - completed +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: - sonarqube: - name: SonarQube - runs-on: ubuntu-latest - if: github.event.workflow_run.conclusion == 'success' - steps: - - uses: actions/checkout@v2 - with: - fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis - - # 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 Coverage Report - uses: actions/github-script@v3.1.0 - with: - script: | - const artifacts = await github.actions.listWorkflowRunArtifacts({ - owner: context.repo.owner, - repo: context.repo.repo, - run_id: ${{ github.event.workflow_run.id }}, - }); - const matchArtifact = artifacts.data.artifacts.filter((artifact) => { - return artifact.name == "coverage" - })[0]; - const download = await github.actions.downloadArtifact({ - owner: context.repo.owner, - repo: context.repo.repo, - artifact_id: matchArtifact.id, - archive_format: 'zip', - }); - const fs = require('fs'); - fs.writeFileSync('${{github.workspace}}/coverage.zip', Buffer.from(download.data)); + prdetails: + name: ℹ️ PR Details + if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'pull_request' + uses: matrix-org/matrix-js-sdk/.github/workflows/pr_details.yml@develop + with: + owner: ${{ github.event.workflow_run.head_repository.owner.login }} + branch: ${{ github.event.workflow_run.head_branch }} - - name: Extract Coverage Report - run: unzip -d coverage coverage.zip && rm coverage.zip - - - name: SonarCloud Scan - uses: SonarSource/sonarcloud-github-action@master - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + sonarqube: + name: 🩻 SonarQube + needs: prdetails + # Only wait for prdetails if it isn't skipped + if: | + always() && + (needs.prdetails.result == 'success' || needs.prdetails.result == 'skipped') && + github.event.workflow_run.conclusion == 'success' + uses: matrix-org/matrix-js-sdk/.github/workflows/sonarcloud.yml@develop + with: + repo: ${{ github.event.workflow_run.head_repository.full_name }} + pr_id: ${{ needs.prdetails.outputs.pr_id }} + head_branch: ${{ needs.prdetails.outputs.head_branch || github.event.workflow_run.head_branch }} + base_branch: ${{ needs.prdetails.outputs.base_branch }} + revision: ${{ github.event.workflow_run.head_sha }} + coverage_workflow_name: tests.yml + coverage_run_id: ${{ github.event.workflow_run.id }} + secrets: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} diff --git a/.github/workflows/static_analysis.yaml b/.github/workflows/static_analysis.yaml index 5e2f27f68b8..266f7c728ad 100644 --- a/.github/workflows/static_analysis.yaml +++ b/.github/workflows/static_analysis.yaml @@ -47,7 +47,7 @@ jobs: - name: "Get modified files" id: changed_files - if: github.event_name == 'pull_request' + if: github.event_name == 'pull_request' && github.actor != 'RiotTranslateBot' uses: tj-actions/changed-files@v19 with: files: | @@ -56,7 +56,10 @@ jobs: src/i18n/strings/en_EN.json - name: "Assert only en_EN was modified" - if: github.event_name == 'pull_request' && steps.changed_files.outputs.any_modified == 'true' + if: | + github.event_name == 'pull_request' && + github.actor != 'RiotTranslateBot' && + steps.changed_files.outputs.any_modified == 'true' run: | echo "You can only modify en_EN.json, do not touch any of the other i18n files as Weblate will be confused" exit 1 diff --git a/.github/workflows/upgrade_dependencies.yml b/.github/workflows/upgrade_dependencies.yml new file mode 100644 index 00000000000..a4a0fedc0d9 --- /dev/null +++ b/.github/workflows/upgrade_dependencies.yml @@ -0,0 +1,8 @@ +name: Upgrade Dependencies +on: + workflow_dispatch: { } +jobs: + upgrade: + uses: matrix-org/matrix-js-sdk/.github/workflows/upgrade_dependencies.yml@develop + secrets: + ELEMENT_BOT_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }} diff --git a/.gitignore b/.gitignore index 8dcb983ec84..1c4eb114e7d 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,4 @@ package-lock.json # These could have files in them but don't currently # Cypress will still auto-create them though... /cypress/fixtures +/cypress/performance diff --git a/.percy.yml b/.percy.yml new file mode 100644 index 00000000000..e50f0b0dbba --- /dev/null +++ b/.percy.yml @@ -0,0 +1,5 @@ +version: 2 +snapshot: + widths: + - 1024 + - 1920 diff --git a/CHANGELOG.md b/CHANGELOG.md index da198272a24..d6ad5a02863 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,93 @@ +Changes in [3.45.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.45.0) (2022-05-24) +===================================================================================================== + +## ✨ Features + * Go to space landing page when clicking on a selected space ([\#6442](https://github.com/matrix-org/matrix-react-sdk/pull/6442)). Fixes vector-im/element-web#20296. + * Fall back to untranslated string rather than showing missing translation error ([\#8609](https://github.com/matrix-org/matrix-react-sdk/pull/8609)). + * Show file name and size on images on hover ([\#6511](https://github.com/matrix-org/matrix-react-sdk/pull/6511)). Fixes vector-im/element-web#18197. + * Iterate on search results for message bubbles ([\#7047](https://github.com/matrix-org/matrix-react-sdk/pull/7047)). Fixes vector-im/element-web#20315. + * registration: redesign email verification page ([\#8554](https://github.com/matrix-org/matrix-react-sdk/pull/8554)). Fixes vector-im/element-web#21984. + * Show full thread message in hover title on thread summary ([\#8568](https://github.com/matrix-org/matrix-react-sdk/pull/8568)). Fixes vector-im/element-web#22037. + * Tweak video rooms copy ([\#8582](https://github.com/matrix-org/matrix-react-sdk/pull/8582)). Fixes vector-im/element-web#22176. + * Live location share - beacon tooltip in maximised view ([\#8572](https://github.com/matrix-org/matrix-react-sdk/pull/8572)). + * Add dialog to navigate long room topics ([\#8517](https://github.com/matrix-org/matrix-react-sdk/pull/8517)). Fixes vector-im/element-web#9623. + * Change spaceroomfacepile tooltip if memberlist is shown ([\#8571](https://github.com/matrix-org/matrix-react-sdk/pull/8571)). Fixes vector-im/element-web#17406. + * Improve message editing UI ([\#8483](https://github.com/matrix-org/matrix-react-sdk/pull/8483)). Fixes vector-im/element-web#9752 and vector-im/element-web#22108. + * Make date changes more obvious ([\#6410](https://github.com/matrix-org/matrix-react-sdk/pull/6410)). Fixes vector-im/element-web#16221. + * Enable forwarding static locations ([\#8553](https://github.com/matrix-org/matrix-react-sdk/pull/8553)). + * Log `TimelinePanel` debugging info when opening the bug report modal ([\#8502](https://github.com/matrix-org/matrix-react-sdk/pull/8502)). + * Improve welcome screen, add opt-out analytics ([\#8474](https://github.com/matrix-org/matrix-react-sdk/pull/8474)). Fixes vector-im/element-web#21946. + * Converting selected text to MD link when pasting a URL ([\#8242](https://github.com/matrix-org/matrix-react-sdk/pull/8242)). Fixes vector-im/element-web#21634. Contributed by @Sinharitik589. + * Support Inter on custom themes ([\#8399](https://github.com/matrix-org/matrix-react-sdk/pull/8399)). Fixes vector-im/element-web#16293. + * Add a `Copy link` button to the right-click message context-menu labs feature ([\#8527](https://github.com/matrix-org/matrix-react-sdk/pull/8527)). + * Move widget screenshots labs flag to devtools ([\#8522](https://github.com/matrix-org/matrix-react-sdk/pull/8522)). + * Remove some labs features which don't get used or create maintenance burden: custom status, multiple integration managers, and do not disturb ([\#8521](https://github.com/matrix-org/matrix-react-sdk/pull/8521)). + * Add a way to toggle `ScrollPanel` and `TimelinePanel` debug logs ([\#8513](https://github.com/matrix-org/matrix-react-sdk/pull/8513)). + * Spaces: remove blue beta dot ([\#8511](https://github.com/matrix-org/matrix-react-sdk/pull/8511)). Fixes vector-im/element-web#22061. + * Order new search dialog results by recency ([\#8444](https://github.com/matrix-org/matrix-react-sdk/pull/8444)). + * Improve pills ([\#6398](https://github.com/matrix-org/matrix-react-sdk/pull/6398)). Fixes vector-im/element-web#16948 and vector-im/element-web#21281. + * Add a way to maximize/pin widget from the PiP view ([\#7672](https://github.com/matrix-org/matrix-react-sdk/pull/7672)). Fixes vector-im/element-web#20723. + * Iterate video room designs in labs ([\#8499](https://github.com/matrix-org/matrix-react-sdk/pull/8499)). + * Improve UI/UX in calls ([\#7791](https://github.com/matrix-org/matrix-react-sdk/pull/7791)). Fixes vector-im/element-web#19937. + * Add ability to change audio and video devices during a call ([\#7173](https://github.com/matrix-org/matrix-react-sdk/pull/7173)). Fixes vector-im/element-web#15595. + +## 🐛 Bug Fixes + * Fix click behavior of notification badges on spaces ([\#8627](https://github.com/matrix-org/matrix-react-sdk/pull/8627)). Fixes vector-im/element-web#22241. + * Add missing return values in Read Receipt animation code ([\#8625](https://github.com/matrix-org/matrix-react-sdk/pull/8625)). Fixes vector-im/element-web#22175. + * Fix 'continue' button not working after accepting identity server terms of service ([\#8619](https://github.com/matrix-org/matrix-react-sdk/pull/8619)). Fixes vector-im/element-web#20003. + * Proactively fix stuck devices in video rooms ([\#8587](https://github.com/matrix-org/matrix-react-sdk/pull/8587)). Fixes vector-im/element-web#22131. + * Fix position of the message action bar on left side bubbles ([\#8398](https://github.com/matrix-org/matrix-react-sdk/pull/8398)). Fixes vector-im/element-web#21879. Contributed by @luixxiul. + * Fix edge case thread summaries around events without a msgtype ([\#8576](https://github.com/matrix-org/matrix-react-sdk/pull/8576)). + * Fix favourites metaspace not updating ([\#8594](https://github.com/matrix-org/matrix-react-sdk/pull/8594)). Fixes vector-im/element-web#22156. + * Stop spaces from displaying as rooms in new breadcrumbs ([\#8595](https://github.com/matrix-org/matrix-react-sdk/pull/8595)). Fixes vector-im/element-web#22165. + * Fix avatar position of hidden event on ThreadView ([\#8592](https://github.com/matrix-org/matrix-react-sdk/pull/8592)). Fixes vector-im/element-web#22199. Contributed by @luixxiul. + * Fix MessageTimestamp position next to redacted messages on IRC/modern layout ([\#8591](https://github.com/matrix-org/matrix-react-sdk/pull/8591)). Fixes vector-im/element-web#22181. Contributed by @luixxiul. + * Fix padding of messages in threads ([\#8574](https://github.com/matrix-org/matrix-react-sdk/pull/8574)). Contributed by @luixxiul. + * Enable overflow of hidden events content ([\#8585](https://github.com/matrix-org/matrix-react-sdk/pull/8585)). Fixes vector-im/element-web#22187. Contributed by @luixxiul. + * Increase composer line height to avoid cutting off emoji ([\#8583](https://github.com/matrix-org/matrix-react-sdk/pull/8583)). Fixes vector-im/element-web#22170. + * Don't consider threads for breaking continuation until actually created ([\#8581](https://github.com/matrix-org/matrix-react-sdk/pull/8581)). Fixes vector-im/element-web#22164. + * Fix displaying hidden events on threads ([\#8555](https://github.com/matrix-org/matrix-react-sdk/pull/8555)). Fixes vector-im/element-web#22058. Contributed by @luixxiul. + * Fix button width and align 絵文字 (emoji) on the user panel ([\#8562](https://github.com/matrix-org/matrix-react-sdk/pull/8562)). Fixes vector-im/element-web#22142. Contributed by @luixxiul. + * Standardise the margin for settings tabs ([\#7963](https://github.com/matrix-org/matrix-react-sdk/pull/7963)). Fixes vector-im/element-web#20767. Contributed by @yuktea. + * Fix room history not being visible even if we have historical keys ([\#8563](https://github.com/matrix-org/matrix-react-sdk/pull/8563)). Fixes vector-im/element-web#16983. + * Fix oblong avatars in video room lobbies ([\#8565](https://github.com/matrix-org/matrix-react-sdk/pull/8565)). + * Update thread summary when latest event gets decrypted ([\#8564](https://github.com/matrix-org/matrix-react-sdk/pull/8564)). Fixes vector-im/element-web#22151. + * Fix codepath which can wrongly cause automatic space switch from all rooms ([\#8560](https://github.com/matrix-org/matrix-react-sdk/pull/8560)). Fixes vector-im/element-web#21373. + * Fix effect of URL preview toggle not updating live ([\#8561](https://github.com/matrix-org/matrix-react-sdk/pull/8561)). Fixes vector-im/element-web#22148. + * Fix visual bugs on AccessSecretStorageDialog ([\#8160](https://github.com/matrix-org/matrix-react-sdk/pull/8160)). Fixes vector-im/element-web#19426. Contributed by @luixxiul. + * Fix the width bounce of the clock on the AudioPlayer ([\#8320](https://github.com/matrix-org/matrix-react-sdk/pull/8320)). Fixes vector-im/element-web#21788. Contributed by @luixxiul. + * Hide the verification left stroke only on the thread list ([\#8525](https://github.com/matrix-org/matrix-react-sdk/pull/8525)). Fixes vector-im/element-web#22132. Contributed by @luixxiul. + * Hide recently_viewed dropdown when other modal opens ([\#8538](https://github.com/matrix-org/matrix-react-sdk/pull/8538)). Contributed by @yaya-usman. + * Only jump to date after pressing the 'go' button ([\#8548](https://github.com/matrix-org/matrix-react-sdk/pull/8548)). Fixes vector-im/element-web#20799. + * Fix download button not working on events that were decrypted too late ([\#8556](https://github.com/matrix-org/matrix-react-sdk/pull/8556)). Fixes vector-im/element-web#19427. + * Align thread summary button with bubble messages on the left side ([\#8388](https://github.com/matrix-org/matrix-react-sdk/pull/8388)). Fixes vector-im/element-web#21873. Contributed by @luixxiul. + * Fix unresponsive notification toggles ([\#8549](https://github.com/matrix-org/matrix-react-sdk/pull/8549)). Fixes vector-im/element-web#22109. + * Set color-scheme property in themes ([\#8547](https://github.com/matrix-org/matrix-react-sdk/pull/8547)). Fixes vector-im/element-web#22124. + * Improve the styling of error messages during search initialization. ([\#6899](https://github.com/matrix-org/matrix-react-sdk/pull/6899)). Fixes vector-im/element-web#19245 and vector-im/element-web#18164. Contributed by @KalleStruik. + * Don't leave button tooltips open when closing modals ([\#8546](https://github.com/matrix-org/matrix-react-sdk/pull/8546)). Fixes vector-im/element-web#22121. + * update matrix-analytics-events ([\#8543](https://github.com/matrix-org/matrix-react-sdk/pull/8543)). + * Handle Jitsi Meet crashes more gracefully ([\#8541](https://github.com/matrix-org/matrix-react-sdk/pull/8541)). + * Fix regression around pasting links ([\#8537](https://github.com/matrix-org/matrix-react-sdk/pull/8537)). Fixes vector-im/element-web#22117. + * Fixes suggested room not ellipsized on shrinking ([\#8536](https://github.com/matrix-org/matrix-react-sdk/pull/8536)). Contributed by @yaya-usman. + * Add global spacing between display name and location body ([\#8523](https://github.com/matrix-org/matrix-react-sdk/pull/8523)). Fixes vector-im/element-web#22111. Contributed by @luixxiul. + * Add box-shadow to the reply preview on the main (left) panel only ([\#8397](https://github.com/matrix-org/matrix-react-sdk/pull/8397)). Fixes vector-im/element-web#21894. Contributed by @luixxiul. + * Set line-height: 1 to RedactedBody inside GenericEventListSummary for IRC/modern layout ([\#8529](https://github.com/matrix-org/matrix-react-sdk/pull/8529)). Fixes vector-im/element-web#22112. Contributed by @luixxiul. + * Fix position of timestamp on the chat panel in IRC layout and message edits history modal window ([\#8464](https://github.com/matrix-org/matrix-react-sdk/pull/8464)). Fixes vector-im/element-web#22011 and vector-im/element-web#22014. Contributed by @luixxiul. + * Fix unexpected and inconsistent inheritance of line-height property for mx_TextualEvent ([\#8485](https://github.com/matrix-org/matrix-react-sdk/pull/8485)). Fixes vector-im/element-web#22041. Contributed by @luixxiul. + * Set the same margin to the right side of NewRoomIntro on TimelineCard ([\#8453](https://github.com/matrix-org/matrix-react-sdk/pull/8453)). Contributed by @luixxiul. + * Remove duplicate tooltip from user pills ([\#8512](https://github.com/matrix-org/matrix-react-sdk/pull/8512)). + * Set max-width for MLocationBody and MLocationBody_map by default ([\#8519](https://github.com/matrix-org/matrix-react-sdk/pull/8519)). Fixes vector-im/element-web#21983. Contributed by @luixxiul. + * Simplify ReplyPreview UI implementation ([\#8516](https://github.com/matrix-org/matrix-react-sdk/pull/8516)). Fixes vector-im/element-web#22091. Contributed by @luixxiul. + * Fix thread summary overflow on narrow message panel on bubble message layout ([\#8520](https://github.com/matrix-org/matrix-react-sdk/pull/8520)). Fixes vector-im/element-web#22097. Contributed by @luixxiul. + * Live location sharing - refresh beacon timers on tab becoming active ([\#8515](https://github.com/matrix-org/matrix-react-sdk/pull/8515)). + * Enlarge emoji again ([\#8509](https://github.com/matrix-org/matrix-react-sdk/pull/8509)). Fixes vector-im/element-web#22086. + * Order receipts with the most recent on the right ([\#8506](https://github.com/matrix-org/matrix-react-sdk/pull/8506)). Fixes vector-im/element-web#22044. + * Disconnect from video rooms when leaving ([\#8500](https://github.com/matrix-org/matrix-react-sdk/pull/8500)). + * Fix soft crash around threads when room isn't yet in store ([\#8496](https://github.com/matrix-org/matrix-react-sdk/pull/8496)). Fixes vector-im/element-web#22047. + * Fix reading of cached room device setting values ([\#8491](https://github.com/matrix-org/matrix-react-sdk/pull/8491)). + * Add loading spinners to threads panels ([\#8490](https://github.com/matrix-org/matrix-react-sdk/pull/8490)). Fixes vector-im/element-web#21335. + * Fix forwarding UI papercuts ([\#8482](https://github.com/matrix-org/matrix-react-sdk/pull/8482)). Fixes vector-im/element-web#17616. + Changes in [3.44.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.44.0) (2022-05-10) ===================================================================================================== diff --git a/__mocks__/browser-request.js b/__mocks__/browser-request.js index aa9c7102998..7029f1c1909 100644 --- a/__mocks__/browser-request.js +++ b/__mocks__/browser-request.js @@ -1,3 +1,19 @@ +/* +Copyright 2022 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. +*/ + const en = require("../src/i18n/strings/en_EN"); const de = require("../src/i18n/strings/de_DE"); const lv = { @@ -5,6 +21,32 @@ const lv = { "Uploading %(filename)s and %(count)s others|one": "Качване на %(filename)s и %(count)s друг", }; +function weblateToCounterpart(inTrs) { + const outTrs = {}; + + for (const key of Object.keys(inTrs)) { + const keyParts = key.split('|', 2); + if (keyParts.length === 2) { + let obj = outTrs[keyParts[0]]; + if (obj === undefined) { + obj = outTrs[keyParts[0]] = {}; + } else if (typeof obj === "string") { + // This is a transitional edge case if a string went from singular to pluralised and both still remain + // in the translation json file. Use the singular translation as `other` and merge pluralisation atop. + obj = outTrs[keyParts[0]] = { + "other": inTrs[key], + }; + console.warn("Found entry in i18n file in both singular and pluralised form", keyParts[0]); + } + obj[keyParts[1]] = inTrs[key]; + } else { + outTrs[key] = inTrs[key]; + } + } + + return outTrs; +} + // Mock the browser-request for the languageHandler tests to return // Fake languages.json containing references to en_EN, de_DE and lv // en_EN.json @@ -13,7 +55,7 @@ const lv = { module.exports = jest.fn((opts, cb) => { const url = opts.url || opts.uri; if (url && url.endsWith("languages.json")) { - cb(undefined, {status: 200}, JSON.stringify({ + cb(undefined, { status: 200 }, JSON.stringify({ "en": { "fileName": "en_EN.json", "label": "English", @@ -24,16 +66,16 @@ module.exports = jest.fn((opts, cb) => { }, "lv": { "fileName": "lv.json", - "label": "Latvian" - } + "label": "Latvian", + }, })); } else if (url && url.endsWith("en_EN.json")) { - cb(undefined, {status: 200}, JSON.stringify(en)); + cb(undefined, { status: 200 }, JSON.stringify(weblateToCounterpart(en))); } else if (url && url.endsWith("de_DE.json")) { - cb(undefined, {status: 200}, JSON.stringify(de)); + cb(undefined, { status: 200 }, JSON.stringify(weblateToCounterpart(de))); } else if (url && url.endsWith("lv.json")) { - cb(undefined, {status: 200}, JSON.stringify(lv)); + cb(undefined, { status: 200 }, JSON.stringify(weblateToCounterpart(lv))); } else { - cb(true, {status: 404}, ""); + cb(true, { status: 404 }, ""); } }); diff --git a/cypress.json b/cypress.json index 2c39bb411fe..d41cc70dd00 100644 --- a/cypress.json +++ b/cypress.json @@ -1,5 +1,7 @@ { "baseUrl": "http://localhost:8080", "videoUploadOnPasses": false, - "projectId": "ppvnzg" + "projectId": "ppvnzg", + "experimentalSessionAndOrigin": true, + "experimentalInteractiveRunEvents": true } diff --git a/cypress/global.d.ts b/cypress/global.d.ts new file mode 100644 index 00000000000..efbb255b081 --- /dev/null +++ b/cypress/global.d.ts @@ -0,0 +1,51 @@ +/* +Copyright 2022 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 "matrix-js-sdk/src/@types/global"; +import type { MatrixClient, ClientEvent } from "matrix-js-sdk/src/client"; +import type { RoomMemberEvent } from "matrix-js-sdk/src/models/room-member"; +import type { MatrixDispatcher } from "../src/dispatcher/dispatcher"; +import type PerformanceMonitor from "../src/performance"; + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Cypress { + interface ApplicationWindow { + mxMatrixClientPeg: { + matrixClient?: MatrixClient; + }; + mxDispatcher: MatrixDispatcher; + mxPerformanceMonitor: PerformanceMonitor; + beforeReload?: boolean; // for detecting reloads + // Partial type for the matrix-js-sdk module, exported by browser-matrix + matrixcs: { + MatrixClient: typeof MatrixClient; + ClientEvent: typeof ClientEvent; + RoomMemberEvent: typeof RoomMemberEvent; + }; + } + } + + interface Window { + // to appease the MatrixDispatcher import + mxDispatcher: MatrixDispatcher; + // to appease the PerformanceMonitor import + mxPerformanceMonitor: PerformanceMonitor; + mxPerformanceEntryNames: any; + } +} + +export { MatrixClient }; diff --git a/cypress/integration/1-register/register.spec.ts b/cypress/integration/1-register/register.spec.ts index f719da55477..3dba78c4904 100644 --- a/cypress/integration/1-register/register.spec.ts +++ b/cypress/integration/1-register/register.spec.ts @@ -16,37 +16,56 @@ limitations under the License. /// -import { SynapseInstance } from "../../plugins/synapsedocker/index"; +import { SynapseInstance } from "../../plugins/synapsedocker"; describe("Registration", () => { - let synapseId; - let synapsePort; + let synapse: SynapseInstance; beforeEach(() => { - cy.task("synapseStart", "consent").then(result => { - synapseId = result.synapseId; - synapsePort = result.port; - }); cy.visit("/#/register"); + cy.startSynapse("consent").then(data => { + synapse = data; + }); }); afterEach(() => { - cy.task("synapseStop", synapseId); + cy.stopSynapse(synapse); }); it("registers an account and lands on the home screen", () => { cy.get(".mx_ServerPicker_change", { timeout: 15000 }).click(); - cy.get(".mx_ServerPickerDialog_otherHomeserver").type(`http://localhost:${synapsePort}`); + cy.get(".mx_ServerPickerDialog_continue").should("be.visible"); + cy.percySnapshot("Server Picker"); + + cy.get(".mx_ServerPickerDialog_otherHomeserver").type(synapse.baseUrl); cy.get(".mx_ServerPickerDialog_continue").click(); // wait for the dialog to go away cy.get('.mx_ServerPickerDialog').should('not.exist'); + + cy.get("#mx_RegistrationForm_username").should("be.visible"); + // Hide the server text as it contains the randomly allocated Synapse port + const percyCSS = ".mx_ServerPicker_server { visibility: hidden !important; }"; + cy.percySnapshot("Registration", { percyCSS }); + cy.get("#mx_RegistrationForm_username").type("alice"); cy.get("#mx_RegistrationForm_password").type("totally a great password"); cy.get("#mx_RegistrationForm_passwordConfirm").type("totally a great password"); + cy.startMeasuring("create-account"); cy.get(".mx_Login_submit").click(); + + cy.get(".mx_RegistrationEmailPromptDialog").should("be.visible"); + cy.percySnapshot("Registration email prompt", { percyCSS }); cy.get(".mx_RegistrationEmailPromptDialog button.mx_Dialog_primary").click(); + + cy.stopMeasuring("create-account"); + cy.get(".mx_InteractiveAuthEntryComponents_termsPolicy").should("be.visible"); + cy.percySnapshot("Registration terms prompt", { percyCSS }); + cy.get(".mx_InteractiveAuthEntryComponents_termsPolicy input").click(); + cy.startMeasuring("from-submit-to-home"); cy.get(".mx_InteractiveAuthEntryComponents_termsSubmit").click(); + cy.url().should('contain', '/#/home'); + cy.stopMeasuring("from-submit-to-home"); }); }); diff --git a/cypress/integration/2-login/consent.spec.ts b/cypress/integration/2-login/consent.spec.ts new file mode 100644 index 00000000000..a4cd31bd26c --- /dev/null +++ b/cypress/integration/2-login/consent.spec.ts @@ -0,0 +1,73 @@ +/* +Copyright 2022 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 { SinonStub } from "cypress/types/sinon"; + +import { SynapseInstance } from "../../plugins/synapsedocker"; + +describe("Consent", () => { + let synapse: SynapseInstance; + + beforeEach(() => { + cy.startSynapse("consent").then(data => { + synapse = data; + + cy.initTestUser(synapse, "Bob"); + }); + }); + + afterEach(() => { + cy.stopSynapse(synapse); + }); + + it("should prompt the user to consent to terms when server deems it necessary", () => { + // Attempt to create a room using the js-sdk which should return an error with `M_CONSENT_NOT_GIVEN` + cy.window().then(win => { + win.mxMatrixClientPeg.matrixClient.createRoom({}).catch(() => {}); + + // Stub `window.open` - clicking the primary button below will call it + cy.stub(win, "open").as("windowOpen").returns({}); + }); + + // Accept terms & conditions + cy.get(".mx_QuestionDialog").within(() => { + cy.get("#mx_BaseDialog_title").contains("Terms and Conditions"); + cy.get(".mx_Dialog_primary").click(); + }); + + cy.get("@windowOpen").then(stub => { + const url = stub.getCall(0).args[0]; + + // Go to Synapse's consent page and accept it + cy.origin(synapse.baseUrl, { args: { url } }, ({ url }) => { + cy.visit(url); + + cy.get('[type="submit"]').click(); + cy.get("p").contains("Danke schon"); + }); + }); + + // go back to the app + cy.visit("/"); + // wait for the app to re-load + cy.get(".mx_MatrixChat", { timeout: 15000 }); + + // attempt to perform the same action again and expect it to not fail + cy.createRoom({}); + }); +}); diff --git a/cypress/integration/2-login/login.spec.ts b/cypress/integration/2-login/login.spec.ts new file mode 100644 index 00000000000..521eb66f25a --- /dev/null +++ b/cypress/integration/2-login/login.spec.ts @@ -0,0 +1,62 @@ +/* +Copyright 2022 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 { SynapseInstance } from "../../plugins/synapsedocker"; + +describe("Login", () => { + let synapse: SynapseInstance; + + beforeEach(() => { + cy.visit("/#/login"); + cy.startSynapse("consent").then(data => { + synapse = data; + }); + }); + + afterEach(() => { + cy.stopSynapse(synapse); + }); + + describe("m.login.password", () => { + const username = "user1234"; + const password = "p4s5W0rD"; + + beforeEach(() => { + cy.registerUser(synapse, username, password); + }); + + it("logs in with an existing account and lands on the home screen", () => { + cy.get("#mx_LoginForm_username", { timeout: 15000 }).should("be.visible"); + cy.percySnapshot("Login"); + + cy.get(".mx_ServerPicker_change").click(); + cy.get(".mx_ServerPickerDialog_otherHomeserver").type(synapse.baseUrl); + cy.get(".mx_ServerPickerDialog_continue").click(); + // wait for the dialog to go away + cy.get('.mx_ServerPickerDialog').should('not.exist'); + + cy.get("#mx_LoginForm_username").type(username); + cy.get("#mx_LoginForm_password").type(password); + cy.startMeasuring("from-submit-to-home"); + cy.get(".mx_Login_submit").click(); + + cy.url().should('contain', '/#/home'); + cy.stopMeasuring("from-submit-to-home"); + }); + }); +}); diff --git a/cypress/integration/3-user-menu/user-menu.spec.ts b/cypress/integration/3-user-menu/user-menu.spec.ts new file mode 100644 index 00000000000..671fd4eacf3 --- /dev/null +++ b/cypress/integration/3-user-menu/user-menu.spec.ts @@ -0,0 +1,47 @@ +/* +Copyright 2022 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 { SynapseInstance } from "../../plugins/synapsedocker"; +import type { UserCredentials } from "../../support/login"; + +describe("User Menu", () => { + let synapse: SynapseInstance; + let user: UserCredentials; + + beforeEach(() => { + cy.startSynapse("default").then(data => { + synapse = data; + + cy.initTestUser(synapse, "Jeff").then(credentials => { + user = credentials; + }); + }); + }); + + afterEach(() => { + cy.stopSynapse(synapse); + }); + + it("should contain our name & userId", () => { + cy.get('[aria-label="User menu"]').click(); + cy.get(".mx_ContextualMenu").within(() => { + cy.get(".mx_UserMenu_contextMenu_displayName").should("contain", "Jeff"); + cy.get(".mx_UserMenu_contextMenu_userId").should("contain", user.userId); + }); + }); +}); diff --git a/cypress/integration/4-create-room/create-room.spec.ts b/cypress/integration/4-create-room/create-room.spec.ts new file mode 100644 index 00000000000..9bf38194d92 --- /dev/null +++ b/cypress/integration/4-create-room/create-room.spec.ts @@ -0,0 +1,66 @@ +/* +Copyright 2022 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 { SynapseInstance } from "../../plugins/synapsedocker"; +import Chainable = Cypress.Chainable; + +function openCreateRoomDialog(): Chainable> { + cy.get('[aria-label="Add room"]').click(); + cy.get('.mx_ContextualMenu [aria-label="New room"]').click(); + return cy.get(".mx_CreateRoomDialog"); +} + +describe("Create Room", () => { + let synapse: SynapseInstance; + + beforeEach(() => { + cy.startSynapse("default").then(data => { + synapse = data; + + cy.initTestUser(synapse, "Jim"); + }); + }); + + afterEach(() => { + cy.stopSynapse(synapse); + }); + + it("should allow us to create a public room with name, topic & address set", () => { + const name = "Test room 1"; + const topic = "This room is dedicated to this test and this test only!"; + + openCreateRoomDialog().within(() => { + // Fill name & topic + cy.get('[label="Name"]').type(name); + cy.get('[label="Topic (optional)"]').type(topic); + // Change room to public + cy.get('[aria-label="Room visibility"]').click(); + cy.get("#mx_JoinRuleDropdown__public").click(); + // Fill room address + cy.get('[label="Room address"]').type("test-room-1"); + // Submit + cy.startMeasuring("from-submit-to-room"); + cy.get(".mx_Dialog_primary").click(); + }); + + cy.url().should("contain", "/#/room/#test-room-1:localhost"); + cy.stopMeasuring("from-submit-to-room"); + cy.get(".mx_RoomHeader_nametext").contains(name); + cy.get(".mx_RoomHeader_topic").contains(topic); + }); +}); diff --git a/cypress/integration/5-threads/threads.spec.ts b/cypress/integration/5-threads/threads.spec.ts new file mode 100644 index 00000000000..43b0058bb11 --- /dev/null +++ b/cypress/integration/5-threads/threads.spec.ts @@ -0,0 +1,214 @@ +/* +Copyright 2022 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 { SynapseInstance } from "../../plugins/synapsedocker"; +import { MatrixClient } from "../../global"; + +function markWindowBeforeReload(): void { + // mark our window object to "know" when it gets reloaded + cy.window().then(w => w.beforeReload = true); +} + +describe("Threads", () => { + let synapse: SynapseInstance; + + beforeEach(() => { + cy.window().then(win => { + win.localStorage.setItem("mx_lhs_size", "0"); // Collapse left panel for these tests + win.localStorage.setItem("mx_labs_feature_feature_thread", "true"); // Default threads to ON for this spec + }); + cy.startSynapse("default").then(data => { + synapse = data; + + cy.initTestUser(synapse, "Tom"); + }); + }); + + afterEach(() => { + cy.stopSynapse(synapse); + }); + + it("should reload when enabling threads beta", () => { + markWindowBeforeReload(); + + // Turn off + cy.openUserSettings("Labs").within(() => { + // initially the new property is there + cy.window().should("have.prop", "beforeReload", true); + + cy.leaveBeta("Threads"); + // after reload the property should be gone + cy.window().should("not.have.prop", "beforeReload"); + }); + + cy.get(".mx_MatrixChat", { timeout: 15000 }); // wait for the app + markWindowBeforeReload(); + + // Turn on + cy.openUserSettings("Labs").within(() => { + // initially the new property is there + cy.window().should("have.prop", "beforeReload", true); + + cy.joinBeta("Threads"); + // after reload the property should be gone + cy.window().should("not.have.prop", "beforeReload"); + }); + }); + + it("should be usable for a conversation", () => { + let bot: MatrixClient; + cy.getBot(synapse, "BotBob").then(_bot => { + bot = _bot; + }); + + let roomId: string; + cy.createRoom({}).then(_roomId => { + roomId = _roomId; + cy.inviteUser(roomId, bot.getUserId()); + cy.visit("/#/room/" + roomId); + }); + + // User sends message + cy.get(".mx_RoomView_body .mx_BasicMessageComposer_input").type("Hello Mr. Bot{enter}"); + + // Wait for message to send, get its ID and save as @threadId + cy.get(".mx_RoomView_body .mx_EventTile").contains("Hello Mr. Bot") + .closest(".mx_EventTile[data-scroll-tokens]").invoke("attr", "data-scroll-tokens").as("threadId"); + + // Bot starts thread + cy.get("@threadId").then(threadId => { + bot.sendMessage(roomId, threadId, { + body: "Hello there", + msgtype: "m.text", + }); + }); + + // User asserts timeline thread summary visible & clicks it + cy.get(".mx_RoomView_body .mx_ThreadSummary .mx_ThreadSummary_sender").should("contain", "BotBob"); + cy.get(".mx_RoomView_body .mx_ThreadSummary .mx_ThreadSummary_content").should("contain", "Hello there"); + cy.get(".mx_RoomView_body .mx_ThreadSummary").click(); + + // User responds in thread + cy.get(".mx_ThreadView .mx_BasicMessageComposer_input").type("Test{enter}"); + + // User asserts summary was updated correctly + cy.get(".mx_RoomView_body .mx_ThreadSummary .mx_ThreadSummary_sender").should("contain", "Tom"); + cy.get(".mx_RoomView_body .mx_ThreadSummary .mx_ThreadSummary_content").should("contain", "Test"); + + // User reacts to message instead + cy.get(".mx_ThreadView .mx_EventTile").contains("Hello there").closest(".mx_EventTile_line") + .find('[aria-label="React"]').click({ force: true }); // Cypress has no ability to hover + cy.get(".mx_EmojiPicker").within(() => { + cy.get('input[type="text"]').type("wave"); + cy.get('[role="menuitem"]').contains("👋").click(); + }); + + // User redacts their prior response + cy.get(".mx_ThreadView .mx_EventTile").contains("Test").closest(".mx_EventTile_line") + .find('[aria-label="Options"]').click({ force: true }); // Cypress has no ability to hover + cy.get(".mx_IconizedContextMenu").within(() => { + cy.get('[role="menuitem"]').contains("Remove").click(); + }); + cy.get(".mx_TextInputDialog").within(() => { + cy.get(".mx_Dialog_primary").contains("Remove").click(); + }); + + // User asserts summary was updated correctly + cy.get(".mx_RoomView_body .mx_ThreadSummary .mx_ThreadSummary_sender").should("contain", "BotBob"); + cy.get(".mx_RoomView_body .mx_ThreadSummary .mx_ThreadSummary_content").should("contain", "Hello there"); + + // User closes right panel after clicking back to thread list + cy.get(".mx_ThreadView .mx_BaseCard_back").click(); + cy.get(".mx_ThreadPanel .mx_BaseCard_close").click(); + + // Bot responds to thread + cy.get("@threadId").then(threadId => { + bot.sendMessage(roomId, threadId, { + body: "How are things?", + msgtype: "m.text", + }); + }); + + cy.get(".mx_RoomView_body .mx_ThreadSummary .mx_ThreadSummary_sender").should("contain", "BotBob"); + cy.get(".mx_RoomView_body .mx_ThreadSummary .mx_ThreadSummary_content").should("contain", "How are things?"); + // User asserts thread list unread indicator + cy.get('.mx_HeaderButtons [aria-label="Threads"]').should("have.class", "mx_RightPanel_headerButton_unread"); + + // User opens thread list + cy.get('.mx_HeaderButtons [aria-label="Threads"]').click(); + + // User asserts thread with correct root & latest events & unread dot + cy.get(".mx_ThreadPanel .mx_EventTile_last").within(() => { + cy.get(".mx_EventTile_body").should("contain", "Hello Mr. Bot"); + cy.get(".mx_ThreadSummary_content").should("contain", "How are things?"); + // User opens thread via threads list + cy.get(".mx_EventTile_line").click(); + }); + + // User responds & asserts + cy.get(".mx_ThreadView .mx_BasicMessageComposer_input").type("Great!{enter}"); + cy.get(".mx_RoomView_body .mx_ThreadSummary .mx_ThreadSummary_sender").should("contain", "Tom"); + cy.get(".mx_RoomView_body .mx_ThreadSummary .mx_ThreadSummary_content").should("contain", "Great!"); + + // User edits & asserts + cy.get(".mx_ThreadView .mx_EventTile_last").contains("Great!").closest(".mx_EventTile_line").within(() => { + cy.get('[aria-label="Edit"]').click({ force: true }); // Cypress has no ability to hover + cy.get(".mx_BasicMessageComposer_input").type(" How about yourself?{enter}"); + }); + cy.get(".mx_RoomView_body .mx_ThreadSummary .mx_ThreadSummary_sender").should("contain", "Tom"); + cy.get(".mx_RoomView_body .mx_ThreadSummary .mx_ThreadSummary_content") + .should("contain", "Great! How about yourself?"); + + // User closes right panel + cy.get(".mx_ThreadView .mx_BaseCard_close").click(); + + // Bot responds to thread and saves the id of their message to @eventId + cy.get("@threadId").then(threadId => { + cy.wrap(bot.sendMessage(roomId, threadId, { + body: "I'm very good thanks", + msgtype: "m.text", + }).then(res => res.event_id)).as("eventId"); + }); + + // User asserts + cy.get(".mx_RoomView_body .mx_ThreadSummary .mx_ThreadSummary_sender").should("contain", "BotBob"); + cy.get(".mx_RoomView_body .mx_ThreadSummary .mx_ThreadSummary_content") + .should("contain", "I'm very good thanks"); + + // Bot edits their latest event + cy.get("@eventId").then(eventId => { + bot.sendMessage(roomId, { + "body": "* I'm very good thanks :)", + "msgtype": "m.text", + "m.new_content": { + "body": "I'm very good thanks :)", + "msgtype": "m.text", + }, + "m.relates_to": { + "rel_type": "m.replace", + "event_id": eventId, + }, + }); + }); + + // User asserts + cy.get(".mx_RoomView_body .mx_ThreadSummary .mx_ThreadSummary_sender").should("contain", "BotBob"); + cy.get(".mx_RoomView_body .mx_ThreadSummary .mx_ThreadSummary_content") + .should("contain", "I'm very good thanks :)"); + }); +}); diff --git a/cypress/plugins/index.ts b/cypress/plugins/index.ts index db01ceceb4f..eab5441c203 100644 --- a/cypress/plugins/index.ts +++ b/cypress/plugins/index.ts @@ -16,8 +16,15 @@ limitations under the License. /// -import { synapseDocker } from "./synapsedocker/index"; +import PluginEvents = Cypress.PluginEvents; +import PluginConfigOptions = Cypress.PluginConfigOptions; +import { performance } from "./performance"; +import { synapseDocker } from "./synapsedocker"; -export default function(on, config) { +/** + * @type {Cypress.PluginConfig} + */ +export default function(on: PluginEvents, config: PluginConfigOptions) { + performance(on, config); synapseDocker(on, config); } diff --git a/cypress/plugins/performance.ts b/cypress/plugins/performance.ts new file mode 100644 index 00000000000..c6bd3e4ce9f --- /dev/null +++ b/cypress/plugins/performance.ts @@ -0,0 +1,47 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/// + +import * as path from "path"; +import * as fse from "fs-extra"; + +import PluginEvents = Cypress.PluginEvents; +import PluginConfigOptions = Cypress.PluginConfigOptions; + +// This holds all the performance measurements throughout the run +let bufferedMeasurements: PerformanceEntry[] = []; + +function addMeasurements(measurements: PerformanceEntry[]): void { + bufferedMeasurements = bufferedMeasurements.concat(measurements); + return null; +} + +async function writeMeasurementsFile() { + try { + const measurementsPath = path.join("cypress", "performance", "measurements.json"); + await fse.outputJSON(measurementsPath, bufferedMeasurements, { + spaces: 4, + }); + } finally { + bufferedMeasurements = []; + } +} + +export function performance(on: PluginEvents, config: PluginConfigOptions) { + on("task", { addMeasurements }); + on("after:run", writeMeasurementsFile); +} diff --git a/cypress/plugins/synapsedocker/index.ts b/cypress/plugins/synapsedocker/index.ts index 0f029e7b2ed..292c74ee670 100644 --- a/cypress/plugins/synapsedocker/index.ts +++ b/cypress/plugins/synapsedocker/index.ts @@ -21,6 +21,10 @@ import * as os from "os"; import * as crypto from "crypto"; import * as childProcess from "child_process"; import * as fse from "fs-extra"; +import * as net from "net"; + +import PluginEvents = Cypress.PluginEvents; +import PluginConfigOptions = Cypress.PluginConfigOptions; // A cypress plugins to add command to start & stop synapses in // docker with preset templates. @@ -28,11 +32,13 @@ import * as fse from "fs-extra"; interface SynapseConfig { configDir: string; registrationSecret: string; + // Synapse must be configured with its public_baseurl so we have to allocate a port & url at this stage + baseUrl: string; + port: number; } export interface SynapseInstance extends SynapseConfig { synapseId: string; - port: number; } const synapses = new Map(); @@ -41,6 +47,16 @@ function randB64Bytes(numBytes: number): string { return crypto.randomBytes(numBytes).toString("base64").replace(/=*$/, ""); } +async function getFreePort(): Promise { + return new Promise(resolve => { + const srv = net.createServer(); + srv.listen(0, () => { + const port = (srv.address()).port; + srv.close(() => resolve(port)); + }); + }); +} + async function cfgDirFromTemplate(template: string): Promise { const templateDir = path.join(__dirname, "templates", template); @@ -61,12 +77,16 @@ async function cfgDirFromTemplate(template: string): Promise { const macaroonSecret = randB64Bytes(16); const formSecret = randB64Bytes(16); - // now copy homeserver.yaml, applying sustitutions + const port = await getFreePort(); + const baseUrl = `http://localhost:${port}`; + + // now copy homeserver.yaml, applying substitutions console.log(`Gen ${path.join(templateDir, "homeserver.yaml")}`); let hsYaml = await fse.readFile(path.join(templateDir, "homeserver.yaml"), "utf8"); hsYaml = hsYaml.replace(/{{REGISTRATION_SECRET}}/g, registrationSecret); hsYaml = hsYaml.replace(/{{MACAROON_SECRET_KEY}}/g, macaroonSecret); hsYaml = hsYaml.replace(/{{FORM_SECRET}}/g, formSecret); + hsYaml = hsYaml.replace(/{{PUBLIC_BASEURL}}/g, baseUrl); await fse.writeFile(path.join(tempDir, "homeserver.yaml"), hsYaml); // now generate a signing key (we could use synapse's config generation for @@ -77,6 +97,8 @@ async function cfgDirFromTemplate(template: string): Promise { await fse.writeFile(path.join(tempDir, "localhost.signing.key"), `ed25519 x ${signingKey}`); return { + port, + baseUrl, configDir: tempDir, registrationSecret, }; @@ -98,7 +120,7 @@ async function synapseStart(template: string): Promise { "--name", containerName, "-d", "-v", `${synCfg.configDir}:/data`, - "-p", "8008/tcp", + "-p", `${synCfg.port}:8008/tcp`, "matrixdotorg/synapse:develop", "run", ], (err, stdout) => { @@ -107,30 +129,31 @@ async function synapseStart(template: string): Promise { }); }); - // Get the port that docker allocated: specifying only one - // port above leaves docker to just grab a free one, although - // in hindsight we need to put the port in public_baseurl in the - // config really, so this will probably need changing to use a fixed - // / configured port. - const port = await new Promise((resolve, reject) => { - childProcess.execFile('docker', [ - "port", synapseId, "8008", + synapses.set(synapseId, { synapseId, ...synCfg }); + + console.log(`Started synapse with id ${synapseId} on port ${synCfg.port}.`); + + // Await Synapse healthcheck + await new Promise((resolve, reject) => { + childProcess.execFile("docker", [ + "exec", synapseId, + "curl", + "--connect-timeout", "30", + "--retry", "30", + "--retry-delay", "1", + "--retry-all-errors", + "--silent", + "http://localhost:8008/health", ], { encoding: 'utf8' }, (err, stdout) => { if (err) reject(err); - resolve(Number(stdout.trim().split(":")[1])); + else resolve(); }); }); - synapses.set(synapseId, Object.assign({ - port, - synapseId, - }, synCfg)); - - console.log(`Started synapse with id ${synapseId} on port ${port}.`); return synapses.get(synapseId); } -async function synapseStop(id) { +async function synapseStop(id: string): Promise { const synCfg = synapses.get(id); if (!synCfg) throw new Error("Unknown synapse ID"); @@ -178,7 +201,7 @@ async function synapseStop(id) { synapses.delete(id); console.log(`Stopped synapse id ${id}.`); - // cypres deliberately fails if you return 'undefined', so + // cypress deliberately fails if you return 'undefined', so // return null to signal all is well and we've handled the task. return null; } @@ -186,10 +209,10 @@ async function synapseStop(id) { /** * @type {Cypress.PluginConfig} */ -// eslint-disable-next-line no-unused-vars -export function synapseDocker(on, config) { +export function synapseDocker(on: PluginEvents, config: PluginConfigOptions) { on("task", { - synapseStart, synapseStop, + synapseStart, + synapseStop, }); on("after:spec", async (spec) => { @@ -197,7 +220,7 @@ export function synapseDocker(on, config) { // This is on the theory that we should avoid re-using synapse // instances between spec runs: they should be cheap enough to // start that we can have a separate one for each spec run or even - // test. If we accidentally re-use synapses, we could inadvertantly + // test. If we accidentally re-use synapses, we could inadvertently // make our tests depend on each other. for (const synId of synapses.keys()) { console.warn(`Cleaning up synapse ID ${synId} after ${spec.name}`); diff --git a/cypress/plugins/synapsedocker/templates/consent/homeserver.yaml b/cypress/plugins/synapsedocker/templates/consent/homeserver.yaml index e26133f6d11..6decaeb5a0b 100644 --- a/cypress/plugins/synapsedocker/templates/consent/homeserver.yaml +++ b/cypress/plugins/synapsedocker/templates/consent/homeserver.yaml @@ -1,6 +1,6 @@ server_name: "localhost" pid_file: /data/homeserver.pid -public_baseurl: http://localhost:5005/ +public_baseurl: "{{PUBLIC_BASEURL}}" listeners: - port: 8008 tls: false diff --git a/cypress/plugins/synapsedocker/templates/default/README.md b/cypress/plugins/synapsedocker/templates/default/README.md new file mode 100644 index 00000000000..8f6b11f999b --- /dev/null +++ b/cypress/plugins/synapsedocker/templates/default/README.md @@ -0,0 +1 @@ +A synapse configured with user privacy consent disabled diff --git a/cypress/plugins/synapsedocker/templates/default/homeserver.yaml b/cypress/plugins/synapsedocker/templates/default/homeserver.yaml new file mode 100644 index 00000000000..7839c69c463 --- /dev/null +++ b/cypress/plugins/synapsedocker/templates/default/homeserver.yaml @@ -0,0 +1,52 @@ +server_name: "localhost" +pid_file: /data/homeserver.pid +public_baseurl: "{{PUBLIC_BASEURL}}" +listeners: + - port: 8008 + tls: false + bind_addresses: ['::'] + type: http + x_forwarded: true + + resources: + - names: [client] + compress: false + +database: + name: "sqlite3" + args: + database: ":memory:" + +log_config: "/data/log.config" + +rc_messages_per_second: 10000 +rc_message_burst_count: 10000 +rc_registration: + per_second: 10000 + burst_count: 10000 + +rc_login: + address: + per_second: 10000 + burst_count: 10000 + account: + per_second: 10000 + burst_count: 10000 + failed_attempts: + per_second: 10000 + burst_count: 10000 + +media_store_path: "/data/media_store" +uploads_path: "/data/uploads" +enable_registration: true +enable_registration_without_verification: true +disable_msisdn_registration: false +registration_shared_secret: "{{REGISTRATION_SECRET}}" +report_stats: false +macaroon_secret_key: "{{MACAROON_SECRET_KEY}}" +form_secret: "{{FORM_SECRET}}" +signing_key_path: "/data/localhost.signing.key" + +trusted_key_servers: + - server_name: "matrix.org" +suppress_key_server_warning: true diff --git a/cypress/plugins/synapsedocker/templates/default/log.config b/cypress/plugins/synapsedocker/templates/default/log.config new file mode 100644 index 00000000000..ac232762da3 --- /dev/null +++ b/cypress/plugins/synapsedocker/templates/default/log.config @@ -0,0 +1,50 @@ +# Log configuration for Synapse. +# +# This is a YAML file containing a standard Python logging configuration +# dictionary. See [1] for details on the valid settings. +# +# Synapse also supports structured logging for machine readable logs which can +# be ingested by ELK stacks. See [2] for details. +# +# [1]: https://docs.python.org/3.7/library/logging.config.html#configuration-dictionary-schema +# [2]: https://matrix-org.github.io/synapse/latest/structured_logging.html + +version: 1 + +formatters: + precise: + format: '%(asctime)s - %(name)s - %(lineno)d - %(levelname)s - %(request)s - %(message)s' + +handlers: + # A handler that writes logs to stderr. Unused by default, but can be used + # instead of "buffer" and "file" in the logger handlers. + console: + class: logging.StreamHandler + formatter: precise + +loggers: + synapse.storage.SQL: + # beware: increasing this to DEBUG will make synapse log sensitive + # information such as access tokens. + level: INFO + + twisted: + # We send the twisted logging directly to the file handler, + # to work around https://github.com/matrix-org/synapse/issues/3471 + # when using "buffer" logger. Use "console" to log to stderr instead. + handlers: [console] + propagate: false + +root: + level: INFO + + # Write logs to the `buffer` handler, which will buffer them together in memory, + # then write them to a file. + # + # Replace "buffer" with "console" to log to stderr instead. (Note that you'll + # also need to update the configuration for the `twisted` logger above, in + # this case.) + # + handlers: [console] + +disable_existing_loggers: false diff --git a/cypress/support/bot.ts b/cypress/support/bot.ts new file mode 100644 index 00000000000..a2488c0081e --- /dev/null +++ b/cypress/support/bot.ts @@ -0,0 +1,63 @@ +/* +Copyright 2022 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 request from "browser-request"; + +import type { MatrixClient } from "matrix-js-sdk/src/client"; +import { SynapseInstance } from "../plugins/synapsedocker"; +import Chainable = Cypress.Chainable; + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Cypress { + interface Chainable { + /** + * Returns a new Bot instance + * @param synapse the instance on which to register the bot user + * @param displayName the display name to give to the bot user + */ + getBot(synapse: SynapseInstance, displayName?: string): Chainable; + } + } +} + +Cypress.Commands.add("getBot", (synapse: SynapseInstance, displayName?: string): Chainable => { + const username = Cypress._.uniqueId("userId_"); + const password = Cypress._.uniqueId("password_"); + return cy.registerUser(synapse, username, password, displayName).then(credentials => { + return cy.window({ log: false }).then(win => { + const cli = new win.matrixcs.MatrixClient({ + baseUrl: synapse.baseUrl, + userId: credentials.userId, + deviceId: credentials.deviceId, + accessToken: credentials.accessToken, + request, + }); + + cli.on(win.matrixcs.RoomMemberEvent.Membership, (event, member) => { + if (member.membership === "invite" && member.userId === cli.getUserId()) { + cli.joinRoom(member.roomId); + } + }); + + cli.startClient(); + + return cli; + }); + }); +}); diff --git a/cypress/support/client.ts b/cypress/support/client.ts new file mode 100644 index 00000000000..682f3ee426c --- /dev/null +++ b/cypress/support/client.ts @@ -0,0 +1,78 @@ +/* +Copyright 2022 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 type { ICreateRoomOpts } from "matrix-js-sdk/src/@types/requests"; +import type { MatrixClient } from "matrix-js-sdk/src/client"; +import type { Room } from "matrix-js-sdk/src/models/room"; +import Chainable = Cypress.Chainable; + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Cypress { + interface Chainable { + /** + * Returns the MatrixClient from the MatrixClientPeg + */ + getClient(): Chainable; + /** + * Create a room with given options. + * @param options the options to apply when creating the room + * @return the ID of the newly created room + */ + createRoom(options: ICreateRoomOpts): Chainable; + /** + * Invites the given user to the given room. + * @param roomId the id of the room to invite to + * @param userId the id of the user to invite + */ + inviteUser(roomId: string, userId: string): Chainable<{}>; + } + } +} + +Cypress.Commands.add("getClient", (): Chainable => { + return cy.window({ log: false }).then(win => win.mxMatrixClientPeg.matrixClient); +}); + +Cypress.Commands.add("createRoom", (options: ICreateRoomOpts): Chainable => { + return cy.window({ log: false }).then(async win => { + const cli = win.mxMatrixClientPeg.matrixClient; + const resp = await cli.createRoom(options); + const roomId = resp.room_id; + + if (!cli.getRoom(roomId)) { + await new Promise(resolve => { + const onRoom = (room: Room) => { + if (room.roomId === roomId) { + cli.off(win.matrixcs.ClientEvent.Room, onRoom); + resolve(); + } + }; + cli.on(win.matrixcs.ClientEvent.Room, onRoom); + }); + } + + return roomId; + }); +}); + +Cypress.Commands.add("inviteUser", (roomId: string, userId: string): Chainable<{}> => { + return cy.getClient().then(async (cli: MatrixClient) => { + return cli.invite(roomId, userId); + }); +}); diff --git a/cypress/support/index.ts b/cypress/support/index.ts index 9901ef4cb80..dd8e5cab991 100644 --- a/cypress/support/index.ts +++ b/cypress/support/index.ts @@ -1,3 +1,26 @@ -// Empty file to prevent cypress from recreating a helpful example -// file on every run (their example file doesn't use semicolons and -// so fails our lint rules). +/* +Copyright 2022 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 "@percy/cypress"; + +import "./performance"; +import "./synapse"; +import "./login"; +import "./client"; +import "./settings"; +import "./bot"; diff --git a/cypress/support/login.ts b/cypress/support/login.ts new file mode 100644 index 00000000000..50be88ae670 --- /dev/null +++ b/cypress/support/login.ts @@ -0,0 +1,98 @@ +/* +Copyright 2022 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 Chainable = Cypress.Chainable; +import { SynapseInstance } from "../plugins/synapsedocker"; + +export interface UserCredentials { + accessToken: string; + userId: string; + deviceId: string; + password: string; + homeServer: string; +} + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Cypress { + interface Chainable { + /** + * Generates a test user and instantiates an Element session with that user. + * @param synapse the synapse returned by startSynapse + * @param displayName the displayName to give the test user + */ + initTestUser(synapse: SynapseInstance, displayName: string): Chainable; + } + } +} + +Cypress.Commands.add("initTestUser", (synapse: SynapseInstance, displayName: string): Chainable => { + // XXX: work around Cypress not clearing IDB between tests + cy.window({ log: false }).then(win => { + win.indexedDB.databases().then(databases => { + databases.forEach(database => { + win.indexedDB.deleteDatabase(database.name); + }); + }); + }); + + const username = Cypress._.uniqueId("userId_"); + const password = Cypress._.uniqueId("password_"); + return cy.registerUser(synapse, username, password, displayName).then(() => { + const url = `${synapse.baseUrl}/_matrix/client/r0/login`; + return cy.request<{ + access_token: string; + user_id: string; + device_id: string; + home_server: string; + }>({ + url, + method: "POST", + body: { + "type": "m.login.password", + "identifier": { + "type": "m.id.user", + "user": username, + }, + "password": password, + }, + }); + }).then(response => { + cy.window({ log: false }).then(win => { + // Seed the localStorage with the required credentials + win.localStorage.setItem("mx_hs_url", synapse.baseUrl); + win.localStorage.setItem("mx_user_id", response.body.user_id); + win.localStorage.setItem("mx_access_token", response.body.access_token); + win.localStorage.setItem("mx_device_id", response.body.device_id); + win.localStorage.setItem("mx_is_guest", "false"); + win.localStorage.setItem("mx_has_pickle_key", "false"); + win.localStorage.setItem("mx_has_access_token", "true"); + }); + + return cy.visit("/").then(() => { + // wait for the app to load + return cy.get(".mx_MatrixChat", { timeout: 15000 }); + }).then(() => ({ + password, + accessToken: response.body.access_token, + userId: response.body.user_id, + deviceId: response.body.device_id, + homeServer: response.body.home_server, + })); + }); +}); diff --git a/cypress/support/performance.ts b/cypress/support/performance.ts new file mode 100644 index 00000000000..bbd1fe217d4 --- /dev/null +++ b/cypress/support/performance.ts @@ -0,0 +1,74 @@ +/* +Copyright 2022 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 Chainable = Cypress.Chainable; +import AUTWindow = Cypress.AUTWindow; + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Cypress { + interface Chainable { + /** + * Start measuring the duration of some task. + * @param task The task name. + */ + startMeasuring(task: string): Chainable; + /** + * Stop measuring the duration of some task. + * The duration is reported in the Cypress log. + * @param task The task name. + */ + stopMeasuring(task: string): Chainable; + } + } +} + +function getPrefix(task: string): string { + return `cy:${Cypress.spec.name.split(".")[0]}:${task}`; +} + +function startMeasuring(task: string): Chainable { + return cy.window({ log: false }).then((win) => { + win.mxPerformanceMonitor.start(getPrefix(task)); + }); +} + +function stopMeasuring(task: string): Chainable { + return cy.window({ log: false }).then((win) => { + const measure = win.mxPerformanceMonitor.stop(getPrefix(task)); + cy.log(`**${task}** ${measure.duration} ms`); + }); +} + +Cypress.Commands.add("startMeasuring", startMeasuring); +Cypress.Commands.add("stopMeasuring", stopMeasuring); + +Cypress.on("window:before:unload", (event: BeforeUnloadEvent) => { + const doc = event.target as Document; + if (doc.location.href === "about:blank") return; + const win = doc.defaultView as AUTWindow; + if (!win.mxPerformanceMonitor) return; + const entries = win.mxPerformanceMonitor.getEntries().filter(entry => { + return entry.name.startsWith("cy:"); + }); + if (!entries || entries.length === 0) return; + cy.task("addMeasurements", entries); +}); + +// Needed to make this file a module +export { }; diff --git a/cypress/support/settings.ts b/cypress/support/settings.ts new file mode 100644 index 00000000000..11f48c2db26 --- /dev/null +++ b/cypress/support/settings.ts @@ -0,0 +1,101 @@ +/* +Copyright 2022 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 "./client"; // XXX: without an (any) import here, types break down +import Chainable = Cypress.Chainable; + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Cypress { + interface Chainable { + /** + * Open the top left user menu, returning a handle to the resulting context menu. + */ + openUserMenu(): Chainable>; + + /** + * Open user settings (via user menu), returning a handle to the resulting dialog. + * @param tab the name of the tab to switch to after opening, optional. + */ + openUserSettings(tab?: string): Chainable>; + + /** + * Switch settings tab to the one by the given name, ideally call this in the context of the dialog. + * @param tab the name of the tab to switch to. + */ + switchTabUserSettings(tab: string): Chainable>; + + /** + * Close user settings, ideally call this in the context of the dialog. + */ + closeUserSettings(): Chainable>; + + /** + * Join the given beta, the `Labs` tab must already be opened, + * ideally call this in the context of the dialog. + * @param name the name of the beta to join. + */ + joinBeta(name: string): Chainable>; + + /** + * Leave the given beta, the `Labs` tab must already be opened, + * ideally call this in the context of the dialog. + * @param name the name of the beta to leave. + */ + leaveBeta(name: string): Chainable>; + } + } +} + +Cypress.Commands.add("openUserMenu", (): Chainable> => { + cy.get('[aria-label="User menu"]').click(); + return cy.get(".mx_ContextualMenu"); +}); + +Cypress.Commands.add("openUserSettings", (tab?: string): Chainable> => { + cy.openUserMenu().within(() => { + cy.get('[aria-label="All settings"]').click(); + }); + return cy.get(".mx_UserSettingsDialog").within(() => { + if (tab) { + cy.switchTabUserSettings(tab); + } + }); +}); + +Cypress.Commands.add("switchTabUserSettings", (tab: string): Chainable> => { + return cy.get(".mx_TabbedView_tabLabels").within(() => { + cy.get(".mx_TabbedView_tabLabel").contains(tab).click(); + }); +}); + +Cypress.Commands.add("closeUserSettings", (): Chainable> => { + return cy.get('[aria-label="Close dialog"]').click(); +}); + +Cypress.Commands.add("joinBeta", (name: string): Chainable> => { + return cy.get(".mx_BetaCard_title").contains(name).closest(".mx_BetaCard").within(() => { + return cy.get(".mx_BetaCard_buttons").contains("Join the beta").click(); + }); +}); + +Cypress.Commands.add("leaveBeta", (name: string): Chainable> => { + return cy.get(".mx_BetaCard_title").contains(name).closest(".mx_BetaCard").within(() => { + return cy.get(".mx_BetaCard_buttons").contains("Leave the beta").click(); + }); +}); diff --git a/cypress/support/synapse.ts b/cypress/support/synapse.ts new file mode 100644 index 00000000000..5696e8c015f --- /dev/null +++ b/cypress/support/synapse.ts @@ -0,0 +1,122 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/// + +import * as crypto from 'crypto'; + +import Chainable = Cypress.Chainable; +import AUTWindow = Cypress.AUTWindow; +import { SynapseInstance } from "../plugins/synapsedocker"; + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Cypress { + interface Chainable { + /** + * Start a synapse instance with a given config template. + * @param template path to template within cypress/plugins/synapsedocker/template/ directory. + */ + startSynapse(template: string): Chainable; + + /** + * Custom command wrapping task:synapseStop whilst preventing uncaught exceptions + * for if Synapse stopping races with the app's background sync loop. + * @param synapse the synapse instance returned by startSynapse + */ + stopSynapse(synapse: SynapseInstance): Chainable; + + /** + * Register a user on the given Synapse using the shared registration secret. + * @param synapse the synapse instance returned by startSynapse + * @param username the username of the user to register + * @param password the password of the user to register + * @param displayName optional display name to set on the newly registered user + */ + registerUser( + synapse: SynapseInstance, + username: string, + password: string, + displayName?: string, + ): Chainable; + } + } +} + +function startSynapse(template: string): Chainable { + return cy.task("synapseStart", template); +} + +function stopSynapse(synapse?: SynapseInstance): Chainable { + if (!synapse) return; + // Navigate away from app to stop the background network requests which will race with Synapse shutting down + return cy.window({ log: false }).then((win) => { + win.location.href = 'about:blank'; + cy.task("synapseStop", synapse.synapseId); + }); +} + +interface Credentials { + accessToken: string; + userId: string; + deviceId: string; + homeServer: string; +} + +function registerUser( + synapse: SynapseInstance, + username: string, + password: string, + displayName?: string, +): Chainable { + const url = `${synapse.baseUrl}/_synapse/admin/v1/register`; + return cy.then(() => { + // get a nonce + return cy.request<{ nonce: string }>({ url }); + }).then(response => { + const { nonce } = response.body; + const mac = crypto.createHmac('sha1', synapse.registrationSecret).update( + `${nonce}\0${username}\0${password}\0notadmin`, + ).digest('hex'); + + return cy.request<{ + access_token: string; + user_id: string; + home_server: string; + device_id: string; + }>({ + url, + method: "POST", + body: { + nonce, + username, + password, + mac, + admin: false, + displayname: displayName, + }, + }); + }).then(response => ({ + homeServer: response.body.home_server, + accessToken: response.body.access_token, + userId: response.body.user_id, + deviceId: response.body.device_id, + })); +} + +Cypress.Commands.add("startSynapse", startSynapse); +Cypress.Commands.add("stopSynapse", stopSynapse); +Cypress.Commands.add("registerUser", registerUser); diff --git a/cypress/tsconfig.json b/cypress/tsconfig.json index 85239e1a2a7..e8db14a01f5 100644 --- a/cypress/tsconfig.json +++ b/cypress/tsconfig.json @@ -2,7 +2,7 @@ "compilerOptions": { "target": "es2016", "lib": ["es2020", "dom"], - "types": ["cypress"], + "types": ["cypress", "@percy/cypress"], "moduleResolution": "node" }, "include": ["**/*.ts"] diff --git a/docs/cypress.md b/docs/cypress.md index a6436e4b99a..021f10b215e 100644 --- a/docs/cypress.md +++ b/docs/cypress.md @@ -6,10 +6,11 @@ It aims to cover: * How to run the tests yourself * How the tests work * How to write great Cypress tests + * Visual testing ## Running the Tests Our Cypress tests run automatically as part of our CI along with our other tests, -on every pull request and on every merge to develop. +on every pull request and on every merge to develop & master. However the Cypress tests are run, an element-web must be running on http://localhost:8080 (this is configured in `cypress.json`) - this is what will @@ -31,7 +32,7 @@ This will run the Cypress tests once, non-interactively. You can also run individual tests this way too, as you'd expect: ``` -yarn run test:cypress cypress/integration/1-register/register.spec.ts +yarn run test:cypress --spec cypress/integration/1-register/register.spec.ts ``` Cypress also has its own UI that you can use to run and debug the tests. @@ -53,17 +54,17 @@ Synapse can be launched with different configurations in order to test element in different configurations. `cypress/plugins/synapsedocker/templates` contains template configuration files for each different configuration. -Each test suite can then launch whatever Syanpse instances it needs it whatever +Each test suite can then launch whatever Synapse instances it needs it whatever configurations. Note that although tests should stop the Synapse instances after running and the plugin also stop any remaining instances after all tests have run, it is possible to be left with some stray containers if, for example, you terminate a test such that the `after()` does not run and also exit Cypress uncleanly. All the containers -it starts are prefixed so they are easy to recognise. They can be removed safely. +it starts are prefixed, so they are easy to recognise. They can be removed safely. -After each test run, logs from the Syanpse instances are saved in `cypress/synapselogs` -with each instance in a separate directory named after it's ID. These logs are removed +After each test run, logs from the Synapse instances are saved in `cypress/synapselogs` +with each instance in a separate directory named after its ID. These logs are removed at the start of each test run. ## Writing Tests @@ -73,23 +74,29 @@ https://docs.cypress.io/guides/references/best-practices . ### Getting a Synapse The key difference is in starting Synapse instances. Tests use this plugin via -`cy.task()` to provide a Synapse instance to log into: +`cy.startSynapse()` to provide a Synapse instance to log into: -``` -cy.task("synapseStart", "consent").then(result => { - synapseId = result.synapseId; - synapsePort = result.port; +```javascript +cy.startSynapse("consent").then(result => { + synapse = result; }); ``` This returns an object with information about the Synapse instance, including what port it was started on and the ID that needs to be passed to shut it down again. It also returns the registration shared secret (`registrationSecret`) that can be used to -register users via the REST API. +register users via the REST API. The Synapse has been ensured ready to go by awaiting +its internal health-check. Synapse instances should be reasonably cheap to start (you may see the first one take a while as it pulls the Docker image), so it's generally expected that tests will start a -Synapse instance for each test suite, ie. in `before()`, and then tear it down in `after()`. +Synapse instance for each test suite, i.e. in `before()`, and then tear it down in `after()`. + +To later destroy your Synapse you should call `stopSynapse`, passing the SynapseInstance +object you received when starting it. +```javascript +cy.stopSynapse(synapse); +``` ### Synapse Config Templates When a Synapse instance is started, it's given a config generated from one of the config @@ -100,6 +107,7 @@ in these templates: * `REGISTRATION_SECRET`: The secret used to register users via the REST API. * `MACAROON_SECRET_KEY`: Generated each time for security * `FORM_SECRET`: Generated each time for security + * `PUBLIC_BASEURL`: The localhost url + port combination the synapse is accessible at * `localhost.signing.key`: A signing key is auto-generated and saved to this file. Config templates should not contain a signing key and instead assume that one will exist in this file. @@ -108,42 +116,41 @@ All other files in the template are copied recursively to `/data/`, so the file in a template can be referenced in the config as `/data/foo.html`. ### Logging In -This doesn't quite exist yet. Most tests will just want to start with the client in a 'logged in' -state, so we should provide an easy way to start a test with element in this state. The -`registrationSecret` provided when starting a Synapse can be used to create a user (porting -the code from https://github.com/matrix-org/matrix-react-sdk/blob/develop/test/end-to-end-tests/src/rest/creator.ts#L49). -We'd then need to log in as this user. Ways of doing this would be: - -1. Fill in the login form. This isn't ideal as it's effectively testing the login process in each - test, and will just be slower. -1. Mint an access token using https://matrix-org.github.io/synapse/develop/admin_api/user_admin_api.html#login-as-a-user - then inject this into element-web. This would probably be fastest, although also relies on correctly - setting up localstorage -1. Mint a login token, inject the Homeserver URL into localstorage and then load element, passing the login - token as a URL parameter. This is a supported way of logging in to element-web, but there's no API - on Synapse to make such a token currently. It would be fairly easy to add a synapse-specific admin API - to do so. We should write tests for token login (and the rest of SSO) at some point anyway though. - -If we make this as a convenience API, it can easily be swapped out later: we could start with option 1 -and then switch later. +There exists a basic utility to start the app with a random user already logged in: +```javascript +cy.initTestUser(synapse, "Jeff"); +``` +It takes the SynapseInstance you received from `startSynapse` and a display name for your test user. +This custom command will register a random userId using the registrationSecret with a random password +and the given display name. The returned Chainable will contain details about the credentials for if +they are needed for User-Interactive Auth or similar but localStorage will already be seeded with them +and the app loaded (path `/`). + +The internals of how this custom command run may be swapped out later, +but the signature can be maintained for simpler maintenance. ### Joining a Room Many tests will also want to start with the client in a room, ready to send & receive messages. Best way to do this may be to get an access token for the user and use this to create a room with the REST -API before logging the user in. +API before logging the user in. You can make use of `cy.getBot(synapse)` and `cy.getClient()` to do this. ### Convenience APIs We should probably end up with convenience APIs that wrap the synapse creation, logging in and room creation that can be called to set up tests. +### Using matrix-js-sdk +Due to the way we run the Cypress tests in CI, at this time you can only use the matrix-js-sdk module +exposed on `window.matrixcs`. This has the limitation that it is only accessible with the app loaded. +This may be revisited in the future. + ## Good Test Hygiene This section mostly summarises general good Cypress testing practice, and should not be news to anyone already familiar with Cypress. 1. Test a well-isolated unit of functionality. The more specific, the easier it will be to tell what's - wrong when they fail. + wrong when they fail. 1. Don't depend on state from other tests: any given test should be able to run in isolation. -1. Try to avoid driving the UI for anything other than the UI you're trying to test. eg. if you're +1. Try to avoid driving the UI for anything other than the UI you're trying to test. e.g. if you're testing that the user can send a reaction to a message, it's best to send a message using a REST API, then react to it using the UI, rather than using the element-web UI to send the message. 1. Avoid explicit waits. `cy.get()` will implicitly wait for the specified element to appear and @@ -160,3 +167,14 @@ already familiar with Cypress. This is a small selection - the Cypress best practices guide, linked above, has more good advice, and we should generally try to adhere to them. + +## Percy Visual Testing +We also support visual testing via Percy, this extracts the DOM from Cypress and renders it using custom renderers +for Safari, Firefox, Chrome & Edge, allowing us to spot visual regressions before they become release regressions. +Right now we run it as part of the standard Pull Request CI automation but due to only having 25k screenshots/month, +and each `cy.percySnapshot()` call results in 8 screenshots (4 browsers, 2 sizes) this could quickly be exhausted and +at that point we would likely run it on a CRON interval or before releases. + +To record a snapshot use `cy.percySnapshot()`, you may have to pass `percyCSS` into the 2nd argument to hide certain +elements which contain dynamic/generated data to avoid them cause false positives in the Percy screenshot diffs. + diff --git a/package.json b/package.json index 40c455b57ed..2745cf91fef 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-react-sdk", - "version": "3.44.0", + "version": "3.45.0", "description": "SDK for matrix.org using React", "author": "matrix.org", "repository": { @@ -83,15 +83,15 @@ "is-ip": "^3.1.0", "jszip": "^3.7.0", "katex": "^0.12.0", - "linkify-element": "^4.0.0-beta.4", - "linkify-string": "^4.0.0-beta.4", - "linkifyjs": "^4.0.0-beta.4", + "linkify-element": "4.0.0-beta.4", + "linkify-string": "4.0.0-beta.4", + "linkifyjs": "4.0.0-beta.4", "lodash": "^4.17.20", "maplibre-gl": "^1.15.2", - "matrix-analytics-events": "github:matrix-org/matrix-analytics-events.git#4aef17b56798639906f26a8739043a3c5c5fde7e", + "matrix-analytics-events": "github:matrix-org/matrix-analytics-events.git#a0687ca6fbdb7258543d49b99fb88b9201e900b0", "matrix-encrypt-attachment": "^1.0.3", "matrix-events-sdk": "^0.0.1-beta.7", - "matrix-js-sdk": "17.2.0", + "matrix-js-sdk": "18.0.0", "matrix-widget-api": "^0.1.0-beta.18", "minimist": "^1.2.5", "opus-recorder": "^8.0.3", @@ -134,6 +134,8 @@ "@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.8.tgz", "@peculiar/webcrypto": "^1.1.4", + "@percy/cli": "^1.1.4", + "@percy/cypress": "^3.1.1", "@sentry/types": "^6.10.0", "@sinonjs/fake-timers": "^9.1.2", "@types/classnames": "^2.2.11", @@ -167,14 +169,14 @@ "babel-jest": "^26.6.3", "blob-polyfill": "^6.0.20211015", "chokidar": "^3.5.1", - "cypress": "^9.5.4", + "cypress": "^9.6.1", "enzyme": "^3.11.0", "enzyme-to-json": "^3.6.2", "eslint": "8.9.0", "eslint-config-google": "^0.14.0", "eslint-plugin-import": "^2.25.4", "eslint-plugin-jsx-a11y": "^6.5.1", - "eslint-plugin-matrix-org": "^0.4.0", + "eslint-plugin-matrix-org": "^0.5.2", "eslint-plugin-react": "^7.28.0", "eslint-plugin-react-hooks": "^4.3.0", "fs-extra": "^10.0.1", diff --git a/res/css/_common.scss b/res/css/_common.scss index 8fbbf5428b9..a0c7bbbd9ac 100644 --- a/res/css/_common.scss +++ b/res/css/_common.scss @@ -21,6 +21,7 @@ limitations under the License. @import "./_font-weights.scss"; @import "./_border-radii.scss"; @import "./_animations.scss"; +@import "./_spacing.scss"; @import url("maplibre-gl/dist/maplibre-gl.css"); $hover-transition: 0.08s cubic-bezier(.46, .03, .52, .96); // quadratic @@ -386,15 +387,22 @@ legend { color: $alert; } -.mx_Dialog_cancelButton { +@define-mixin customisedCancelButton { mask: url('$(res)/img/feather-customised/cancel.svg'); mask-repeat: no-repeat; mask-position: center; mask-size: cover; - width: 14px; - height: 14px; background-color: $dialog-close-fg-color; cursor: pointer; + position: unset; + width: unset; + height: unset; +} + +.mx_Dialog_cancelButton { + @mixin customisedCancelButton; + width: 14px; + height: 14px; position: absolute; top: 10px; right: 0; @@ -408,7 +416,8 @@ legend { } .mx_Dialog_buttons { - margin-top: 20px; + margin-top: $spacing-20; + margin-inline-start: auto; text-align: right; .mx_Dialog_buttons_additive { @@ -417,6 +426,22 @@ legend { } } +.mx_Dialog_buttons_row { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + text-align: initial; + margin-inline-start: auto; + + // default gap among elements + column-gap: $spacing-8; // See margin-right below inside the button style + row-gap: 5px; // See margin-bottom below inside the button style + + button { + margin: 0 !important; // override the margin settings + } +} + /* XXX: Our button style are a mess: buttons that happen to appear in dialogs get special styles applied * to them that no button anywhere else in the app gets by default. In practice, buttons in other places * in the app look the same by being AccessibleButtons, or possibly by having explict button classes. diff --git a/res/css/_components.scss b/res/css/_components.scss index d2836fdb76c..1b1b87b39d4 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -6,6 +6,7 @@ @import "./_spacing.scss"; @import "./components/views/beacon/_BeaconListItem.scss"; @import "./components/views/beacon/_BeaconStatus.scss"; +@import "./components/views/beacon/_BeaconStatusTooltip.scss"; @import "./components/views/beacon/_BeaconViewDialog.scss"; @import "./components/views/beacon/_DialogOwnBeaconStatus.scss"; @import "./components/views/beacon/_DialogSidebar.scss"; @@ -57,6 +58,7 @@ @import "./structures/_ViewSource.scss"; @import "./structures/auth/_CompleteSecurity.scss"; @import "./structures/auth/_Login.scss"; +@import "./structures/auth/_Registration.scss"; @import "./structures/auth/_SetupEncryptionBody.scss"; @import "./views/audio_messages/_AudioPlayer.scss"; @import "./views/audio_messages/_PlayPauseButton.scss"; @@ -80,6 +82,7 @@ @import "./views/avatars/_WidgetAvatar.scss"; @import "./views/beta/_BetaCard.scss"; @import "./views/context_menus/_CallContextMenu.scss"; +@import "./views/context_menus/_DeviceContextMenu.scss"; @import "./views/context_menus/_IconizedContextMenu.scss"; @import "./views/context_menus/_MessageContextMenu.scss"; @import "./views/dialogs/_AddExistingToSpaceDialog.scss"; @@ -125,7 +128,6 @@ @import "./views/dialogs/_SpacePreferencesDialog.scss"; @import "./views/dialogs/_SpaceSettingsDialog.scss"; @import "./views/dialogs/_SpotlightDialog.scss"; -@import "./views/dialogs/_TabbedIntegrationManagerDialog.scss"; @import "./views/dialogs/_TermsDialog.scss"; @import "./views/dialogs/_UntrustedDeviceDialog.scss"; @import "./views/dialogs/_UploadConfirmDialog.scss"; @@ -143,7 +145,6 @@ @import "./views/elements/_AddressSelector.scss"; @import "./views/elements/_AddressTile.scss"; @import "./views/elements/_CopyableText.scss"; -@import "./views/elements/_DesktopBuildsNotice.scss"; @import "./views/elements/_DesktopCapturerSourcePicker.scss"; @import "./views/elements/_DialPadBackspaceButton.scss"; @import "./views/elements/_DirectorySearchBox.scss"; @@ -162,6 +163,7 @@ @import "./views/elements/_InviteReason.scss"; @import "./views/elements/_ManageIntegsButton.scss"; @import "./views/elements/_MiniAvatarUploader.scss"; +@import "./views/elements/_Pill.scss"; @import "./views/elements/_PowerSelector.scss"; @import "./views/elements/_ProgressBar.scss"; @import "./views/elements/_QRCode.scss"; @@ -171,6 +173,7 @@ @import "./views/elements/_RoleButton.scss"; @import "./views/elements/_RoomAliasField.scss"; @import "./views/elements/_SSOButtons.scss"; +@import "./views/elements/_SearchWarning.scss"; @import "./views/elements/_ServerPicker.scss"; @import "./views/elements/_SettingsFlag.scss"; @import "./views/elements/_Slider.scss"; @@ -255,9 +258,11 @@ @import "./views/rooms/_ReplyTile.scss"; @import "./views/rooms/_RoomBreadcrumbs.scss"; @import "./views/rooms/_RoomHeader.scss"; +@import "./views/rooms/_RoomInfoLine.scss"; @import "./views/rooms/_RoomList.scss"; @import "./views/rooms/_RoomListHeader.scss"; @import "./views/rooms/_RoomPreviewBar.scss"; +@import "./views/rooms/_RoomPreviewCard.scss"; @import "./views/rooms/_RoomSublist.scss"; @import "./views/rooms/_RoomTile.scss"; @import "./views/rooms/_RoomTileSc.scss"; diff --git a/res/css/components/views/beacon/_BeaconListItem.scss b/res/css/components/views/beacon/_BeaconListItem.scss index 60311a4466f..dd99192cf56 100644 --- a/res/css/components/views/beacon/_BeaconListItem.scss +++ b/res/css/components/views/beacon/_BeaconListItem.scss @@ -40,6 +40,7 @@ limitations under the License. .mx_BeaconListItem_info { flex: 1 1 0; + width: 0; display: flex; flex-direction: column; align-items: stretch; diff --git a/res/css/components/views/beacon/_BeaconStatus.scss b/res/css/components/views/beacon/_BeaconStatus.scss index 4dd3d325475..95c41749111 100644 --- a/res/css/components/views/beacon/_BeaconStatus.scss +++ b/res/css/components/views/beacon/_BeaconStatus.scss @@ -46,14 +46,15 @@ limitations under the License. } .mx_BeaconStatus_description { - flex: 1; + flex: 1 1 0; display: flex; flex-direction: column; line-height: $font-14px; padding-right: $spacing-8; - // TODO handle text-overflow + white-space: nowrap; + overflow: hidden; } .mx_BeaconStatus_expiryTime { @@ -62,4 +63,6 @@ limitations under the License. .mx_BeaconStatus_label { margin-bottom: 2px; + overflow: hidden; + text-overflow: ellipsis; } diff --git a/res/css/components/views/beacon/_BeaconStatusTooltip.scss b/res/css/components/views/beacon/_BeaconStatusTooltip.scss new file mode 100644 index 00000000000..07b3a43cc01 --- /dev/null +++ b/res/css/components/views/beacon/_BeaconStatusTooltip.scss @@ -0,0 +1,37 @@ +/* +Copyright 2022 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_BeaconStatusTooltip { + position: absolute; + top: 42px; + max-width: 150px; + height: 38px; + box-sizing: content-box; + padding-top: $spacing-8; + + // override copyable text style to make compact + .mx_CopyableText_copyButton { + margin-left: 0 !important; + } +} + +.mx_BeaconStatusTooltip_inner { + position: relative; + height: 100%; + border-radius: 4px; + background: $menu-bg-color; + box-shadow: 4px 4px 12px 0 $menu-box-shadow-color; +} diff --git a/res/css/structures/_RoomDirectory.scss b/res/css/structures/_RoomDirectory.scss index 9473e18f783..62d12965e41 100644 --- a/res/css/structures/_RoomDirectory.scss +++ b/res/css/structures/_RoomDirectory.scss @@ -155,7 +155,7 @@ limitations under the License. line-height: $font-20px; padding: 0 5px; color: $accent-fg-color; - background-color: $rte-room-pill-color; + background-color: $pill-bg-color; } .mx_RoomDirectory_topic { diff --git a/res/css/structures/_SpaceRoomView.scss b/res/css/structures/_SpaceRoomView.scss index eed3d8830f6..f4d37e0e246 100644 --- a/res/css/structures/_SpaceRoomView.scss +++ b/res/css/structures/_SpaceRoomView.scss @@ -137,124 +137,6 @@ $SpaceRoomViewInnerWidth: 428px; } } - .mx_SpaceRoomView_preview, - .mx_SpaceRoomView_landing { - .mx_SpaceRoomView_info_memberCount { - color: inherit; - position: relative; - padding: 0 0 0 16px; - font-size: $font-15px; - display: inline; // cancel inline-flex - - &::before { - content: "·"; // visual separator - position: absolute; - left: 6px; - } - } - } - - .mx_SpaceRoomView_preview { - padding: 32px 24px !important; // override default padding from above - margin: auto; - max-width: 480px; - box-sizing: border-box; - box-shadow: 2px 15px 30px $dialog-shadow-color; - border-radius: 8px; - position: relative; - - // XXX remove this when spaces leaves Beta - .mx_BetaCard_betaPill { - position: absolute; - right: 24px; - top: 32px; - } - - // XXX remove this when spaces leaves Beta - .mx_SpaceRoomView_preview_spaceBetaPrompt { - font-weight: $font-semi-bold; - font-size: $font-14px; - line-height: $font-24px; - color: $primary-content; - margin-top: 24px; - position: relative; - padding-left: 24px; - - .mx_AccessibleButton_kind_link { - display: inline; - padding: 0; - font-size: inherit; - line-height: inherit; - } - - &::before { - content: ""; - position: absolute; - height: $font-24px; - width: 20px; - left: 0; - mask-repeat: no-repeat; - mask-position: center; - mask-size: contain; - mask-image: url('$(res)/img/element-icons/room/room-summary.svg'); - background-color: $secondary-content; - } - } - - .mx_SpaceRoomView_preview_inviter { - display: flex; - align-items: center; - margin-bottom: 20px; - font-size: $font-15px; - - > div { - margin-left: 8px; - - .mx_SpaceRoomView_preview_inviter_name { - line-height: $font-18px; - } - - .mx_SpaceRoomView_preview_inviter_mxid { - line-height: $font-24px; - color: $secondary-content; - } - } - } - - > .mx_RoomAvatar_isSpaceRoom { - &.mx_BaseAvatar_image, .mx_BaseAvatar_image { - border-radius: 12px; - } - } - - h1.mx_SpaceRoomView_preview_name { - margin: 20px 0 !important; // override default margin from above - } - - .mx_SpaceRoomView_preview_topic { - font-size: $font-14px; - line-height: $font-22px; - color: $secondary-content; - margin: 20px 0; - max-height: 160px; - overflow-y: auto; - } - - .mx_SpaceRoomView_preview_joinButtons { - margin-top: 20px; - - .mx_AccessibleButton { - width: 200px; - box-sizing: border-box; - padding: 14px 0; - - & + .mx_AccessibleButton { - margin-left: 20px; - } - } - } - } - .mx_SpaceRoomView_landing { display: flex; flex-direction: column; @@ -314,40 +196,6 @@ $SpaceRoomViewInnerWidth: 428px; flex-wrap: wrap; line-height: $font-24px; - .mx_SpaceRoomView_info { - color: $secondary-content; - font-size: $font-15px; - display: inline-block; - - .mx_SpaceRoomView_info_public, - .mx_SpaceRoomView_info_private { - padding-left: 20px; - position: relative; - - &::before { - position: absolute; - content: ""; - width: 20px; - height: 20px; - top: 0; - left: -2px; - mask-position: center; - mask-repeat: no-repeat; - background-color: $tertiary-content; - } - } - - .mx_SpaceRoomView_info_public::before { - mask-size: 12px; - mask-image: url("$(res)/img/globe.svg"); - } - - .mx_SpaceRoomView_info_private::before { - mask-size: 14px; - mask-image: url("$(res)/img/element-icons/lock.svg"); - } - } - .mx_SpaceRoomView_landing_infoBar_interactive { display: flex; flex-wrap: wrap; diff --git a/res/css/structures/_VideoRoomView.scss b/res/css/structures/_VideoRoomView.scss index d99b3f5894b..3577e7b73e1 100644 --- a/res/css/structures/_VideoRoomView.scss +++ b/res/css/structures/_VideoRoomView.scss @@ -24,8 +24,7 @@ limitations under the License. margin-right: calc($container-gap-width / 2); background-color: $header-panel-bg-color; - padding-top: 33px; // to match the right panel chat heading - border: 8px solid $header-panel-bg-color; + padding: 8px; border-radius: 8px; .mx_AppTile { diff --git a/res/css/structures/auth/_CompleteSecurity.scss b/res/css/structures/auth/_CompleteSecurity.scss index bf5aeb15f56..4c3602ac264 100644 --- a/res/css/structures/auth/_CompleteSecurity.scss +++ b/res/css/structures/auth/_CompleteSecurity.scss @@ -34,14 +34,9 @@ limitations under the License. } .mx_CompleteSecurity_skip { - mask: url('$(res)/img/feather-customised/cancel.svg'); - mask-repeat: no-repeat; - mask-position: center; - mask-size: cover; + @mixin customisedCancelButton; width: 18px; height: 18px; - background-color: $dialog-close-fg-color; - cursor: pointer; position: absolute; right: 24px; } diff --git a/res/css/structures/auth/_Registration.scss b/res/css/structures/auth/_Registration.scss new file mode 100644 index 00000000000..b415e78f107 --- /dev/null +++ b/res/css/structures/auth/_Registration.scss @@ -0,0 +1,53 @@ +/* +Copyright 2022 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_Register_mainContent { + display: flex; + flex-direction: column; + flex-grow: 1; + min-height: 270px; + + p { + font-size: $font-14px; + color: $authpage-primary-color; + + &.secondary { + color: $authpage-secondary-color; + } + } + + > img:first-child { + margin-bottom: 16px; + width: max-content; + } + + .mx_Login_submit { + margin-bottom: 0; + } +} + +.mx_Register_footerActions { + display: flex; + flex-direction: row; + justify-content: space-between; + padding-top: 16px; + margin-top: 16px; + border-top: 1px solid rgba(141, 151, 165, 0.2); + + > * { + flex-basis: content; + } +} diff --git a/res/css/views/audio_messages/_AudioPlayer.scss b/res/css/views/audio_messages/_AudioPlayer.scss index 3c2551e36a5..6b8d0ca4383 100644 --- a/res/css/views/audio_messages/_AudioPlayer.scss +++ b/res/css/views/audio_messages/_AudioPlayer.scss @@ -58,10 +58,10 @@ limitations under the License. } .mx_Clock { - width: $font-42px; // we're not using a monospace font, so fake it min-width: $font-42px; // for flexbox - padding-left: 4px; // isolate from seek bar - text-align: right; + padding-left: $spacing-4; // isolate from seek bar + text-align: justify; + white-space: nowrap; } } } diff --git a/res/css/views/auth/_AuthBody.scss b/res/css/views/auth/_AuthBody.scss index 33adeff9960..516f3f22de2 100644 --- a/res/css/views/auth/_AuthBody.scss +++ b/res/css/views/auth/_AuthBody.scss @@ -24,6 +24,11 @@ limitations under the License. padding: 25px 60px; box-sizing: border-box; + &.mx_AuthBody_flex { + display: flex; + flex-direction: column; + } + h2 { font-size: $font-24px; font-weight: 600; @@ -139,7 +144,6 @@ limitations under the License. .mx_AuthBody_changeFlow { display: block; text-align: center; - width: 100%; > a { font-weight: $font-semi-bold; diff --git a/res/css/views/auth/_AuthPage.scss b/res/css/views/auth/_AuthPage.scss index 816293e5db2..100d98095b1 100644 --- a/res/css/views/auth/_AuthPage.scss +++ b/res/css/views/auth/_AuthPage.scss @@ -28,10 +28,12 @@ limitations under the License. border-radius: $border-radius-4px; box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.33); background-color: $authpage-modal-bg-color; -} -@media only screen and (max-width: 480px) { - .mx_AuthPage_modal { + @media only screen and (max-height: 768px) { + margin-top: 50px; + } + + @media only screen and (max-width: 480px) { margin-top: 0; } } diff --git a/res/css/views/auth/_InteractiveAuthEntryComponents.scss b/res/css/views/auth/_InteractiveAuthEntryComponents.scss index b65e733d141..4bdac745f27 100644 --- a/res/css/views/auth/_InteractiveAuthEntryComponents.scss +++ b/res/css/views/auth/_InteractiveAuthEntryComponents.scss @@ -14,35 +14,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_InteractiveAuthEntryComponents_emailWrapper { - padding-right: 100px; - position: relative; - margin-top: 32px; - margin-bottom: 32px; - - &::before, &::after { - position: absolute; - width: 116px; - height: 116px; - content: ""; - right: -10px; - } - - &::before { - background-color: rgba(244, 246, 250, 0.91); - border-radius: 50%; - top: -20px; - } - - &::after { - background-image: url('$(res)/img/element-icons/email-prompt.svg'); - background-repeat: no-repeat; - background-position: center; - background-size: contain; - top: -25px; - } -} - .mx_InteractiveAuthEntryComponents_msisdnWrapper { text-align: center; } @@ -103,3 +74,21 @@ limitations under the License. margin-left: 5px; } } + +.mx_InteractiveAuthEntryComponents_emailWrapper { + // "Resend" button/link + .mx_AccessibleButton_kind_link_inline { + // We need this to be an inline-block so positioning works correctly + display: inline-block !important; + + // Spinner as end adornment of the "resend" button/link + .mx_Spinner { + // Spinners are usually block elements, but we need it as inline element + display: inline-flex !important; + // Spinners by default fill all available width, but we don't want that + width: auto !important; + // We need to center the spinner relative to the button/link + vertical-align: middle !important; + } + } +} diff --git a/res/css/views/avatars/_BaseAvatar.scss b/res/css/views/avatars/_BaseAvatar.scss index 964e8156261..16261f000e3 100644 --- a/res/css/views/avatars/_BaseAvatar.scss +++ b/res/css/views/avatars/_BaseAvatar.scss @@ -47,6 +47,7 @@ limitations under the License. .mx_BaseAvatar_image { object-fit: cover; + aspect-ratio: 1; border-radius: 125px; vertical-align: top; background-color: $background; diff --git a/res/css/views/beta/_BetaCard.scss b/res/css/views/beta/_BetaCard.scss index 658e43f051b..d54dd4a4c82 100644 --- a/res/css/views/beta/_BetaCard.scss +++ b/res/css/views/beta/_BetaCard.scss @@ -119,7 +119,7 @@ limitations under the License. font-size: 12px; font-weight: $font-semi-bold; line-height: 15px; - color: #FFFFFF; + color: $button-primary-fg-color; display: inline-block; vertical-align: text-bottom; word-break: keep-all; // avoid multiple lines on CJK language diff --git a/res/css/views/context_menus/_DeviceContextMenu.scss b/res/css/views/context_menus/_DeviceContextMenu.scss new file mode 100644 index 00000000000..4b886279d7d --- /dev/null +++ b/res/css/views/context_menus/_DeviceContextMenu.scss @@ -0,0 +1,27 @@ +/* +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_DeviceContextMenu { + max-width: 252px; + + .mx_DeviceContextMenu_device_icon { + display: none; + } + + .mx_IconizedContextMenu_label { + padding-left: 0 !important; + } +} diff --git a/res/css/views/context_menus/_IconizedContextMenu.scss b/res/css/views/context_menus/_IconizedContextMenu.scss index 430970d7cd2..fa744ce732f 100644 --- a/res/css/views/context_menus/_IconizedContextMenu.scss +++ b/res/css/views/context_menus/_IconizedContextMenu.scss @@ -25,6 +25,11 @@ limitations under the License. padding-right: 20px; } + .mx_IconizedContextMenu_optionList_label { + font-size: $font-15px; + font-weight: $font-semi-bold; + } + // the notFirst class is for cases where the optionList might be under a header of sorts. &:nth-child(n + 2), .mx_IconizedContextMenu_optionList_notFirst { // This is a bit of a hack when we could just use a simple border-top property, diff --git a/res/css/views/dialogs/_CompoundDialog.scss b/res/css/views/dialogs/_CompoundDialog.scss index d90c7e0f8e6..28e7388e0ea 100644 --- a/res/css/views/dialogs/_CompoundDialog.scss +++ b/res/css/views/dialogs/_CompoundDialog.scss @@ -38,14 +38,9 @@ limitations under the License. } .mx_CompoundDialog_cancelButton { - mask: url('$(res)/img/feather-customised/cancel.svg'); - mask-repeat: no-repeat; - mask-position: center; - mask-size: cover; + @mixin customisedCancelButton; width: 20px; height: 20px; - background-color: $dialog-close-fg-color; - cursor: pointer; // Align with middle of title, 34px from right edge position: absolute; diff --git a/res/css/views/dialogs/_ForwardDialog.scss b/res/css/views/dialogs/_ForwardDialog.scss index ad7bf9a8167..2cdec19ebfb 100644 --- a/res/css/views/dialogs/_ForwardDialog.scss +++ b/res/css/views/dialogs/_ForwardDialog.scss @@ -85,6 +85,10 @@ limitations under the License. margin-top: 24px; } + .mx_ForwardList_resultsList { + padding-right: 8px; + } + .mx_ForwardList_entry { display: flex; justify-content: space-between; diff --git a/res/css/views/dialogs/_MessageEditHistoryDialog.scss b/res/css/views/dialogs/_MessageEditHistoryDialog.scss index 5838939d9bf..1d7759fe2bb 100644 --- a/res/css/views/dialogs/_MessageEditHistoryDialog.scss +++ b/res/css/views/dialogs/_MessageEditHistoryDialog.scss @@ -55,6 +55,12 @@ limitations under the License. text-decoration: underline; } + .mx_EventTile { + .mx_MessageTimestamp { + position: absolute; + } + } + .mx_EventTile_line, .mx_EventTile_content { margin-right: 0px; } diff --git a/res/css/views/dialogs/_TabbedIntegrationManagerDialog.scss b/res/css/views/dialogs/_TabbedIntegrationManagerDialog.scss deleted file mode 100644 index c096ec2a7ce..00000000000 --- a/res/css/views/dialogs/_TabbedIntegrationManagerDialog.scss +++ /dev/null @@ -1,62 +0,0 @@ -/* -Copyright 2019 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -.mx_TabbedIntegrationManagerDialog .mx_Dialog { - width: 60%; - height: 70%; - overflow: hidden; - padding: 0; - max-width: initial; - max-height: initial; - position: relative; -} - -.mx_TabbedIntegrationManagerDialog_container { - // Full size of the dialog, whatever it is - position: absolute; - top: 0; - bottom: 0; - left: 0; - right: 0; - - .mx_TabbedIntegrationManagerDialog_currentManager { - width: 100%; - height: 100%; - border-top: 1px solid $accent; - - iframe { - background-color: #fff; - border: 0; - width: 100%; - height: 100%; - } - } -} - -.mx_TabbedIntegrationManagerDialog_tab { - display: inline-block; - border: 1px solid $accent; - border-bottom: 0; - border-top-left-radius: $border-radius-3px; - border-top-right-radius: $border-radius-3px; - padding: 10px 8px; - margin-right: 5px; -} - -.mx_TabbedIntegrationManagerDialog_currentTab { - background-color: $accent; - color: $accent-fg-color; -} diff --git a/res/css/views/dialogs/security/_AccessSecretStorageDialog.scss b/res/css/views/dialogs/security/_AccessSecretStorageDialog.scss index 3d716800508..f3558212cca 100644 --- a/res/css/views/dialogs/security/_AccessSecretStorageDialog.scss +++ b/res/css/views/dialogs/security/_AccessSecretStorageDialog.scss @@ -14,38 +14,14 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_AccessSecretStorageDialog_reset { - position: relative; - padding-left: 24px; // 16px icon + 8px padding - margin-top: 7px; // vertical alignment to buttons - margin-bottom: 7px; // space between the buttons and the text when float is activated - text-align: left; - - &::before { - content: ""; - display: inline-block; - position: absolute; - height: 16px; - width: 16px; - left: 0; - top: 2px; // alignment - background-image: url("$(res)/img/element-icons/warning-badge.svg"); - background-size: contain; - } - - .mx_AccessSecretStorageDialog_reset_link { - color: $alert; - } -} - .mx_AccessSecretStorageDialog_titleWithIcon::before { content: ''; display: inline-block; width: 24px; height: 24px; - margin-right: 8px; + margin-inline-end: $spacing-8; position: relative; - top: 5px; + top: 5px; // TODO: spacing variable background-color: $primary-content; } @@ -84,7 +60,7 @@ limitations under the License. } .mx_AccessSecretStorageDialog_recoveryKeyEntry_entryControlSeparatorText { - margin: 16px; + margin: $spacing-16; } .mx_AccessSecretStorageDialog_recoveryKeyFeedback { @@ -97,7 +73,7 @@ limitations under the License. mask-repeat: no-repeat; mask-position: center; mask-size: 20px; - margin-right: 5px; + margin-inline-end: 5px; // TODO: spacing variable } } @@ -120,3 +96,44 @@ limitations under the License. .mx_AccessSecretStorageDialog_recoveryKeyEntry_fileInput { display: none; } + +.mx_AccessSecretStorageDialog_primaryContainer { + .mx_Dialog_buttons { + $spacingStart: $spacing-24; // 16px icon + 8px padding + + text-align: initial; + display: flex; + flex-flow: column; + gap: 14px; // TODO: spacing variable + + .mx_Dialog_buttons_additive { + float: none; + + .mx_AccessSecretStorageDialog_reset { + position: relative; + padding-inline-start: $spacingStart; + + &::before { + content: ""; + display: inline-block; + position: absolute; + height: 16px; + width: 16px; + left: 0; + top: 2px; // alignment + background-image: url("$(res)/img/element-icons/warning-badge.svg"); + background-size: contain; + } + + .mx_AccessSecretStorageDialog_reset_link { + color: $alert; + } + } + } + + .mx_Dialog_buttons_row { + gap: $spacing-16; // TODO: needs normalization + padding-inline-start: $spacingStart; + } + } +} diff --git a/res/css/views/elements/_EditableItemList.scss b/res/css/views/elements/_EditableItemList.scss index 91ef20539cf..87824562490 100644 --- a/res/css/views/elements/_EditableItemList.scss +++ b/res/css/views/elements/_EditableItemList.scss @@ -25,14 +25,12 @@ limitations under the License. } .mx_EditableItem_delete { + @mixin customisedCancelButton; order: 3; margin-right: 5px; - cursor: pointer; vertical-align: middle; width: 14px; height: 14px; - mask-image: url('$(res)/img/feather-customised/cancel.svg'); - mask-repeat: no-repeat; background-color: $alert; mask-size: 100%; } diff --git a/res/css/views/elements/_FacePile.scss b/res/css/views/elements/_FacePile.scss index 90f1c590a14..e40695fcf14 100644 --- a/res/css/views/elements/_FacePile.scss +++ b/res/css/views/elements/_FacePile.scss @@ -15,6 +15,9 @@ limitations under the License. */ .mx_FacePile { + display: flex; + align-items: center; + .mx_FacePile_faces { display: inline-flex; flex-direction: row-reverse; diff --git a/res/css/views/elements/_ImageView.scss b/res/css/views/elements/_ImageView.scss index 787d33ddc22..e0fb9144471 100644 --- a/res/css/views/elements/_ImageView.scss +++ b/res/css/views/elements/_ImageView.scss @@ -79,6 +79,11 @@ $button-gap: 24px; font-weight: bold; } +.mx_ImageView_title { + color: $lightbox-fg-color; + font-size: $font-12px; +} + .mx_ImageView_toolbar { padding-right: 16px; pointer-events: initial; diff --git a/res/css/views/elements/_InteractiveTooltip.scss b/res/css/views/elements/_InteractiveTooltip.scss index 8196441b6d7..2240fbc3ac8 100644 --- a/res/css/views/elements/_InteractiveTooltip.scss +++ b/res/css/views/elements/_InteractiveTooltip.scss @@ -16,7 +16,7 @@ limitations under the License. .mx_InteractiveTooltip_wrapper { position: fixed; - z-index: 5000; + z-index: 3999; } .mx_InteractiveTooltip { diff --git a/res/css/views/elements/_MiniAvatarUploader.scss b/res/css/views/elements/_MiniAvatarUploader.scss index 46ffd9a01cd..577cf40727a 100644 --- a/res/css/views/elements/_MiniAvatarUploader.scss +++ b/res/css/views/elements/_MiniAvatarUploader.scss @@ -25,7 +25,9 @@ limitations under the License. z-index: unset; width: max-content; left: 72px; - top: 0; + // top edge starting at 50 % of parent - 50 % of itself -> centered vertically + top: 50%; + transform: translateY(-50%); } .mx_MiniAvatarUploader_indicator { diff --git a/res/css/views/elements/_Pill.scss b/res/css/views/elements/_Pill.scss new file mode 100644 index 00000000000..e9ccef666a6 --- /dev/null +++ b/res/css/views/elements/_Pill.scss @@ -0,0 +1,61 @@ +/* +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_Pill { + padding: $font-1px 0.4em $font-1px 0.4em; + line-height: $font-17px; + border-radius: $font-16px; + vertical-align: text-top; + display: inline-flex; + align-items: center; + + cursor: pointer; + + color: $accent-fg-color !important; // To override .markdown-body + background-color: $pill-bg-color !important; // To override .markdown-body + + &.mx_UserPill_me, + &.mx_AtRoomPill { + background-color: $alert !important; // To override .markdown-body + } + + &:hover { + background-color: $pill-hover-bg-color !important; // To override .markdown-body + } + + &.mx_UserPill_me:hover { + background-color: #ff6b75 !important; // To override .markdown-body | same on both themes + } + + // We don't want to indicate clickability + &.mx_AtRoomPill:hover { + background-color: $alert !important; // To override .markdown-body + cursor: unset; + } + + &::before, + .mx_BaseAvatar { + margin-left: -0.3em; // Otherwise the gap is too large + margin-right: 0.2em; + } + + a& { + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + text-decoration: none !important; // To override .markdown-body + } +} diff --git a/res/css/views/elements/_RichText.scss b/res/css/views/elements/_RichText.scss index d349947a5de..69df80b166d 100644 --- a/res/css/views/elements/_RichText.scss +++ b/res/css/views/elements/_RichText.scss @@ -2,104 +2,6 @@ // naming scheme; it's completely unclear where or how they're being used // --Matthew -.mx_UserPill, -.mx_RoomPill, -.mx_AtRoomPill { - display: inline-flex; - align-items: center; - vertical-align: middle; - border-radius: $font-16px; - line-height: $font-15px; - padding-left: 0; -} - -.mx_CustomEmojiPill { - display: inline-flex; - align-items: center; - vertical-align: middle; - padding-left: 1px; - font-size: 0; -} - -a.mx_Pill { - text-overflow: ellipsis; - white-space: nowrap; - overflow: hidden; - max-width: 100%; -} - -.mx_Pill { - padding: $font-1px; - padding-right: 0.4em; - vertical-align: text-top; - line-height: $font-17px; -} - -/* More specific to override `.markdown-body a` text-decoration */ -.mx_EventTile_content .markdown-body a.mx_Pill { - text-decoration: none; -} - -/* More specific to override `.markdown-body a` color */ -.mx_EventTile_content .markdown-body a.mx_UserPill, -.mx_UserPill { - color: $primary-content; - background-color: $other-user-pill-bg-color; -} - -.mx_UserPill_selected { - background-color: $accent !important; -} - -/* More specific to override `.markdown-body a` color */ -.mx_EventTile_highlight .mx_EventTile_content .markdown-body a.mx_UserPill_me, -.mx_EventTile_content .markdown-body a.mx_AtRoomPill, -.mx_EventTile_content .mx_AtRoomPill, -.mx_MessageComposer_input .mx_AtRoomPill { - color: $accent-fg-color; - background-color: $alert; -} - -/* More specific to override `.markdown-body a` color */ -.mx_EventTile_content .markdown-body a.mx_RoomPill, -.mx_RoomPill { - color: $primary-content; - background-color: $rte-room-pill-color; -} - -.mx_EventTile_body .mx_UserPill, -.mx_EventTile_body .mx_RoomPill { - cursor: pointer; -} - -.mx_UserPill .mx_BaseAvatar, -.mx_RoomPill .mx_BaseAvatar, -.mx_AtRoomPill .mx_BaseAvatar { - position: relative; - display: inline-flex; - align-items: center; - border-radius: 10rem; - margin-right: 0.24rem; - pointer-events: none; -} - -.mx_Emoji { - // Should be 1.8rem for our default 1.4rem message bodies, - // and scale with the size of the surrounding text - font-size: calc(18 / 14 * 1em); - vertical-align: bottom; -} - -// same for custom emojis -img[data-mx-emoticon] { - // Should be 1.8rem for our default 1.4rem message bodies, - // and scale with the size of the surrounding text - max-height: unset !important; - height: calc(18 / 14 * 1em) !important; - vertical-align: bottom; - object-position: center; -} - .mx_Markdown_BOLD { font-weight: bold; } @@ -133,3 +35,10 @@ img[data-mx-emoticon] { .mx_Markdown_STRIKETHROUGH { text-decoration: line-through; } + +.mx_Emoji { + // Should be 1.8rem for our default message bodies, and scale with the + // surrounding text + font-size: max($font-18px, 1em); + vertical-align: bottom; +} diff --git a/res/css/views/elements/_DesktopBuildsNotice.scss b/res/css/views/elements/_SearchWarning.scss similarity index 96% rename from res/css/views/elements/_DesktopBuildsNotice.scss rename to res/css/views/elements/_SearchWarning.scss index 3672595bf1e..c69cfd561b9 100644 --- a/res/css/views/elements/_DesktopBuildsNotice.scss +++ b/res/css/views/elements/_SearchWarning.scss @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_DesktopBuildsNotice { +.mx_SearchWarning { text-align: center; padding: 0 16px; diff --git a/res/css/views/elements/_Tooltip.scss b/res/css/views/elements/_Tooltip.scss index 48b36377f7f..7fd568e2e70 100644 --- a/res/css/views/elements/_Tooltip.scss +++ b/res/css/views/elements/_Tooltip.scss @@ -70,14 +70,17 @@ limitations under the License. font-weight: 500; max-width: 300px; word-break: break-word; - margin-left: 6px; - margin-right: 6px; background-color: #21262C; // Same on both themes color: $accent-fg-color; border: 0; text-align: center; + &:not(.mx_Tooltip_noMargin) { + margin-left: 6px; + margin-right: 6px; + } + .mx_Tooltip_chevron { display: none; } diff --git a/res/css/views/messages/_MImageBody.scss b/res/css/views/messages/_MImageBody.scss index 8fbe5eb13d6..0cbcbd46582 100644 --- a/res/css/views/messages/_MImageBody.scss +++ b/res/css/views/messages/_MImageBody.scss @@ -17,6 +17,28 @@ limitations under the License. $timeline-image-border-radius: $border-radius-8px; +.mx_MImageBody_banner { + position: absolute; + bottom: 4px; + left: 4px; + padding: 4px; + border-radius: $timeline-image-border-radius; + font-size: $font-15px; + + pointer-events: none; // let the cursor go through to the media underneath + + // Trying to match the width of the image is surprisingly difficult, so arbitrarily break it off early. + max-width: min(100%, 350px); + + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + + // Hardcoded colours because it's the same on all themes + background-color: rgba(0, 0, 0, 0.6); + color: #ffffff; +} + .mx_MImageBody_placeholder { // Position the placeholder on top of the thumbnail, so that the reveal animation can work position: absolute; diff --git a/res/css/views/messages/_MImageReplyBody.scss b/res/css/views/messages/_MImageReplyBody.scss index 3207443d65b..2bdf571f0d1 100644 --- a/res/css/views/messages/_MImageReplyBody.scss +++ b/res/css/views/messages/_MImageReplyBody.scss @@ -20,6 +20,10 @@ limitations under the License. .mx_MImageBody_thumbnail_container { flex: 1; margin-right: 4px; + + .mx_MImageBody_banner { + display: none; + } } .mx_MImageReplyBody_info { diff --git a/res/css/views/messages/_MLocationBody.scss b/res/css/views/messages/_MLocationBody.scss index 72202ca6e1b..cbbd34526db 100644 --- a/res/css/views/messages/_MLocationBody.scss +++ b/res/css/views/messages/_MLocationBody.scss @@ -15,7 +15,10 @@ limitations under the License. */ .mx_MLocationBody { + max-width: 100%; + .mx_MLocationBody_map { + max-width: 100%; width: 450px; height: 300px; z-index: 0; // keeps the entire map under the message action bar @@ -27,15 +30,15 @@ limitations under the License. /* In the timeline, we fit the width of the container */ .mx_EventTile_line .mx_MLocationBody .mx_MLocationBody_map { - width: 100%; max-width: 450px; + width: 100%; } -.mx_EventTile[data-layout="bubble"] .mx_EventTile_line .mx_MLocationBody { +.mx_EventTile[data-layout="bubble"] .mx_EventTile_line .mx_MLocationBody .mx_MLocationBody_map { max-width: 100%; + width: 450px; +} - .mx_MLocationBody_map { - max-width: 100%; - width: 450px; - } +.mx_DisambiguatedProfile ~ .mx_MLocationBody { + margin-top: 6px; // See: https://github.com/matrix-org/matrix-react-sdk/pull/8442 } diff --git a/res/css/views/messages/_MessageActionBar.scss b/res/css/views/messages/_MessageActionBar.scss index 593e1f4bd95..c00e47a93f6 100644 --- a/res/css/views/messages/_MessageActionBar.scss +++ b/res/css/views/messages/_MessageActionBar.scss @@ -16,11 +16,14 @@ limitations under the License. */ .mx_MessageActionBar { + --MessageActionBar-size-button: 28px; + --MessageActionBar-size-box: 32px; // 28px + 2px (margin) * 2 + position: absolute; visibility: hidden; cursor: pointer; display: flex; - height: 32px; + height: var(--MessageActionBar-size-box); line-height: $font-24px; border-radius: $border-radius-8px; background: $background; @@ -28,8 +31,9 @@ limitations under the License. top: -32px; right: 8px; user-select: none; - // Ensure the action bar appears above over things, like the read marker. - z-index: 1; + // Ensure the action bar appears above other things like the read marker + // and sender avatar (for small screens) + z-index: 10; // Adds a previous event safe area so that you can't accidentally hover the // previous event while trying to mouse into the action bar or from the @@ -63,8 +67,8 @@ limitations under the License. } .mx_MessageActionBar_maskButton { - width: 28px; - height: 28px; + width: var(--MessageActionBar-size-button); + height: var(--MessageActionBar-size-button); &:disabled, &[disabled] { diff --git a/res/css/views/messages/_MessageTimestamp.scss b/res/css/views/messages/_MessageTimestamp.scss index 85c910296af..d496ff82c8c 100644 --- a/res/css/views/messages/_MessageTimestamp.scss +++ b/res/css/views/messages/_MessageTimestamp.scss @@ -18,4 +18,5 @@ limitations under the License. color: $event-timestamp-color; font-size: $font-10px; font-variant-numeric: tabular-nums; + width: $MessageTimestamp_width; } diff --git a/res/css/views/messages/_TextualEvent.scss b/res/css/views/messages/_TextualEvent.scss index 607ed9cecba..530ad50587b 100644 --- a/res/css/views/messages/_TextualEvent.scss +++ b/res/css/views/messages/_TextualEvent.scss @@ -17,6 +17,7 @@ limitations under the License. .mx_TextualEvent { opacity: 0.5; overflow-y: hidden; + line-height: normal; a { color: $accent; diff --git a/res/css/views/messages/_ViewSourceEvent.scss b/res/css/views/messages/_ViewSourceEvent.scss index 5e288eb19ac..c0803eafd05 100644 --- a/res/css/views/messages/_ViewSourceEvent.scss +++ b/res/css/views/messages/_ViewSourceEvent.scss @@ -19,8 +19,11 @@ limitations under the License. opacity: 0.6; font-size: $font-12px; width: 100%; + overflow-x: auto; // Cancel overflow setting of .mx_EventTile_content + line-height: normal; // Align with avatar and E2E icon - pre, code { + pre, + code { flex: 1; } diff --git a/res/css/views/right_panel/_BaseCard.scss b/res/css/views/right_panel/_BaseCard.scss index f933b317b9b..a615f5a8814 100644 --- a/res/css/views/right_panel/_BaseCard.scss +++ b/res/css/views/right_panel/_BaseCard.scss @@ -15,6 +15,8 @@ limitations under the License. */ .mx_BaseCard { + --BaseCard_EventTile_line-padding-block: 2px; + padding: 0 8px; overflow: hidden; display: flex; diff --git a/res/css/views/right_panel/_ThreadPanel.scss b/res/css/views/right_panel/_ThreadPanel.scss index 9e9c59d2cbb..bab7c2e608c 100644 --- a/res/css/views/right_panel/_ThreadPanel.scss +++ b/res/css/views/right_panel/_ThreadPanel.scss @@ -104,11 +104,13 @@ limitations under the License. } } - .mx_AutoHideScrollbar { + .mx_AutoHideScrollbar, + .mx_RoomView_messagePanelSpinner { background-color: $background; border-radius: 8px; padding-inline-end: 0; overflow-y: scroll; // set gap between the thread tile and the right border + height: 100%; } // Override _GroupLayout.scss for the thread panel @@ -189,17 +191,13 @@ limitations under the License. } } - .mx_GenericEventListSummary > .mx_EventTile_line { - padding-left: 30px !important; // Override main timeline styling - align summary text with message text - } - - .mx_EventTile:not([data-layout=bubble]) { - .mx_EventTile_e2eIcon { - left: 8px; + .mx_GenericEventListSummary { + &[data-layout=bubble] > .mx_EventTile_line { + padding-left: 30px !important; // Override main timeline styling - align summary text with message text } - &:hover .mx_EventTile_line { - box-shadow: unset !important; // don't show the verification left stroke in the thread list + &:not([data-layout=bubble]) > .mx_EventTile_line { + padding-inline-start: var(--ThreadView_group_spacing-start); // align summary text with message text } } @@ -239,27 +237,64 @@ limitations under the License. color: $secondary-content; } - // handling for hidden events (e.g reactions) in the thread view - &.mx_ThreadView .mx_EventTile_info { - padding-top: 0 !important; // override main timeline padding - - .mx_EventTile_line { - padding-left: 0 !important; // override main timeline padding + &.mx_ThreadView .mx_EventTile { + // handling for hidden events (e.g reactions) in the thread view - .mx_EventTile_content { - margin-left: 48px; // align with text - width: calc(100% - 48px - 8px); // match width of parent + &:not([data-layout=bubble]) { + &:hover.mx_EventTile_verified.mx_EventTile_info .mx_EventTile_line, + &:hover.mx_EventTile_unverified.mx_EventTile_info .mx_EventTile_line, + &:hover.mx_EventTile_unknown.mx_EventTile_info .mx_EventTile_line { + padding-inline-start: 0; // Override } } - .mx_EventTile_avatar { - position: absolute; - left: 30px !important; // override main timeline positioning - z-index: 9; // position above the hover styling - } + &.mx_EventTile_info { + padding-top: 0; + + &.mx_EventTile_selected .mx_EventTile_line, + .mx_EventTile_line { + $line-height: $font-12px; - .mx_ViewSourceEvent_toggle { - display: none; // hide the hidden event expand button, not enough space, view source can still be used + padding-inline-start: 0; + line-height: $line-height; + + .mx_EventTile_content, + .mx_RedactedBody { + width: auto; + margin-inline-start: calc(var(--ThreadView_group_spacing-start) + 14px + 6px); // 14px: avatar width, 6px: 20px - 14px + font-size: $line-height; + } + } + + &:not([data-layout=bubble]) { + .mx_MessageTimestamp { + top: 2px; // Align with avatar + } + + .mx_EventTile_avatar { + left: calc($MessageTimestamp_width + 14px - 4px); // 14px: avatar width, 4px: align with text + z-index: 9; // position above the hover styling + } + } + + &[data-layout=bubble] { + .mx_EventTile_avatar { + inset-inline-start: 0; + } + } + + .mx_EventTile_avatar { + position: absolute; + top: 1.5px; // Align with hidden event content + margin-top: 0; + margin-bottom: 0; + width: 14px; // avatar img size + height: 14px; // avatar img size + } + + .mx_ViewSourceEvent_toggle { + display: none; // hide the hidden event expand button, not enough space, view source can still be used + } } } diff --git a/res/css/views/right_panel/_TimelineCard.scss b/res/css/views/right_panel/_TimelineCard.scss index 5a121a8f61c..6b4c0d23610 100644 --- a/res/css/views/right_panel/_TimelineCard.scss +++ b/res/css/views/right_panel/_TimelineCard.scss @@ -43,7 +43,8 @@ limitations under the License. } .mx_NewRoomIntro { - margin-left: 36px; + margin-inline-start: 36px; // TODO: Use a variable + margin-inline-end: 36px; // TODO: Use a variable } .mx_EventTile_content { @@ -51,31 +52,45 @@ limitations under the License. } .mx_EventTile:not([data-layout="bubble"]) { + $left-gutter: 36px; + .mx_EventTile_line { - padding-left: 36px; - padding-right: 36px; + padding-inline-start: $left-gutter; + padding-inline-end: 36px; + padding-top: var(--BaseCard_EventTile_line-padding-block); + padding-bottom: var(--BaseCard_EventTile_line-padding-block); + + .mx_EventTile_e2eIcon { + inset-inline-start: 8px; + } + } + + .mx_DisambiguatedProfile, + .mx_ReactionsRow, + .mx_ThreadSummary { + margin-inline-start: $left-gutter; } .mx_ReactionsRow { padding: 0; // See margin setting of ReactionsRow on _EventTile.scss - margin-left: 36px; margin-right: 8px; } .mx_ThreadSummary { - margin-left: 36px; margin-right: 0; max-width: min(calc(100% - 36px), 600px); } .mx_EventTile_avatar { + position: absolute; // for IRC layout top: 12px; left: -3px; } .mx_MessageTimestamp { + position: absolute; // for modern layout and IRC layout right: -4px; left: auto; } @@ -86,7 +101,7 @@ limitations under the License. &.mx_EventTile_info { .mx_EventTile_line { - padding-left: 36px; + padding-left: $left-gutter; } .mx_EventTile_avatar { @@ -95,18 +110,6 @@ limitations under the License. } } - .mx_GroupLayout { - .mx_EventTile { - > .mx_DisambiguatedProfile { - margin-left: 36px; - } - - .mx_EventTile_line { - padding-bottom: 8px; - } - } - } - .mx_CallEvent_wrapper { justify-content: center; margin: auto 5px; diff --git a/res/css/views/right_panel/_VerificationPanel.scss b/res/css/views/right_panel/_VerificationPanel.scss index d971ba31733..e416cd8234d 100644 --- a/res/css/views/right_panel/_VerificationPanel.scss +++ b/res/css/views/right_panel/_VerificationPanel.scss @@ -23,16 +23,36 @@ limitations under the License. } } -.mx_UserInfo { +.mx_UserInfo.mx_BaseCard { + .mx_UserInfo_container:not(.mx_UserInfo_separator) { + + > div > p { + margin-top: 0; + margin-bottom: 0; + } + + .mx_VerificationPanel_verifyByEmojiButton, + .mx_UserInfo_wideButton { + width: fit-content; + } + + .mx_EncryptionInfo_spinner, + .mx_VerificationShowSas { + margin-inline-start: auto; + margin-inline-end: auto; + } + + .mx_Spinner, + .mx_VerificationShowSas { + align-items: center; + } + } + .mx_EncryptionPanel_cancel { - mask: url('$(res)/img/feather-customised/cancel.svg'); - mask-repeat: no-repeat; - mask-position: center; - mask-size: cover; + @mixin customisedCancelButton; width: 14px; height: 14px; background-color: $settings-subsection-fg-color; - cursor: pointer; position: absolute; z-index: 100; top: 14px; diff --git a/res/css/views/rooms/_BasicMessageComposer.scss b/res/css/views/rooms/_BasicMessageComposer.scss index 051a74b7ca1..8bb8fc0aacb 100644 --- a/res/css/views/rooms/_BasicMessageComposer.scss +++ b/res/css/views/rooms/_BasicMessageComposer.scss @@ -51,9 +51,15 @@ limitations under the License. } &.mx_BasicMessageComposer_input_shouldShowPillAvatar { - span.mx_UserPill, span.mx_RoomPill { - position: relative; + span.mx_UserPill, span.mx_RoomPill, span.mx_SpacePill { user-select: all; + position: relative; + cursor: unset; // We don't want indicate clickability + + &:hover { + // We don't want indicate clickability | To override the overriding of .markdown-body + background-color: $pill-bg-color !important; + } // avatar psuedo element &::before { @@ -90,14 +96,6 @@ limitations under the License. font-weight: normal; } } - - span.mx_UserPill { - cursor: pointer; - } - - span.mx_RoomPill { - cursor: default; - } } &.mx_BasicMessageComposer_input_disabled { diff --git a/res/css/views/rooms/_EditMessageComposer.scss b/res/css/views/rooms/_EditMessageComposer.scss index 452d94a8987..f4c15dac5ef 100644 --- a/res/css/views/rooms/_EditMessageComposer.scss +++ b/res/css/views/rooms/_EditMessageComposer.scss @@ -16,13 +16,13 @@ limitations under the License. */ .mx_EditMessageComposer { - + display: flex; + flex-direction: column; + gap: 5px; padding: 3px; - // this is to try not make the text move but still have some - // padding around and in the editor. - // Actual values from fiddling around in inspector - margin: -7px -10px -5px -10px; - overflow: visible !important; // override mx_EventTile_content + + // Make sure the formatting bar is visible + overflow: visible !important; // override mx_EventTile_content .mx_BasicMessageComposer_input { border-radius: $border-radius-4px; @@ -40,23 +40,10 @@ limitations under the License. display: flex; flex-direction: row; justify-content: flex-end; - padding: 5px; - position: absolute; - left: 0; - background: $header-panel-bg-color; - z-index: 100; - right: 0; - margin: 0 -110px 0 0; - padding-right: 147px; + gap: 5px; .mx_AccessibleButton { - margin-left: 5px; padding: 5px 40px; } } } - -.mx_EventTile_last .mx_EditMessageComposer_buttons { - position: static; - margin-right: -147px; -} diff --git a/res/css/views/rooms/_EventBubbleTile.scss b/res/css/views/rooms/_EventBubbleTile.scss index 29888908fa8..bbc4d3c53fa 100644 --- a/res/css/views/rooms/_EventBubbleTile.scss +++ b/res/css/views/rooms/_EventBubbleTile.scss @@ -31,6 +31,27 @@ limitations under the License. margin-right: 60px; } +.mx_RoomView_searchResultsPanel { + .mx_EventTile[data-layout=bubble] { + .mx_SenderProfile { + // Group layout adds a 64px left margin, which we really don't want on search results + margin-left: 0; + } + + &[data-self=true] { + // The avatars end up overlapping, so just hide them + .mx_EventTile_avatar { + display: none; + } + } + + // Mirror rough designs for "greyed out" text + &.mx_EventTile_contextual .mx_EventTile_line { + opacity: 0.4; + } + } +} + .mx_EventTile[data-layout=bubble] { position: relative; margin-top: var(--gutterSize); @@ -109,7 +130,6 @@ limitations under the License. .mx_MessageActionBar { top: -28px; - right: 0; z-index: 9; // above the avatar } @@ -150,7 +170,13 @@ limitations under the License. } .mx_MessageActionBar { - right: -100px; // to make sure it doesn't overflow to the left or cover sender profile + inset-inline-start: calc(100% - var(--MessageActionBar-size-box)); + right: initial; // Reset the default value + } + + .mx_ThreadSummary { + margin-inline-start: calc(-1 * var(--gutterSize)); + margin-inline-end: auto; } --backgroundColor: $eventbubble-others-bg; @@ -177,8 +203,8 @@ limitations under the License. } .mx_ThreadSummary { - float: right; - margin-right: calc(-1 * var(--gutterSize)); + margin-inline-start: auto; + margin-inline-end: calc(-1 * var(--gutterSize)); } .mx_DisambiguatedProfile { @@ -199,6 +225,7 @@ limitations under the License. order: -1; } } + .mx_EventTile_avatar { top: -19px; // height of the sender block right: -35px; @@ -208,6 +235,10 @@ limitations under the License. background: $eventbubble-self-bg; } + .mx_MessageActionBar { + inset-inline-end: 0; + } + --backgroundColor: $eventbubble-self-bg; } @@ -418,13 +449,6 @@ limitations under the License. } } - .mx_EditMessageComposer_buttons { - position: static; - padding: 0; - margin: 8px 0 0; - background: transparent; - } - .mx_ReactionsRow { margin-right: -18px; margin-left: -9px; @@ -601,6 +625,7 @@ limitations under the License. margin-right: 0; .mx_MessageActionBar { + inset-inline-start: initial; // Reset .mx_EventTile[data-layout="bubble"][data-self="false"] .mx_MessageActionBar right: 48px; // align with that of right-column bubbles } diff --git a/res/css/views/rooms/_EventTile.scss b/res/css/views/rooms/_EventTile.scss index 417f9404cc4..1ec22179271 100644 --- a/res/css/views/rooms/_EventTile.scss +++ b/res/css/views/rooms/_EventTile.scss @@ -50,6 +50,12 @@ $threadInfoLineHeight: calc(2 * $font-12px); // See: _commons.scss .mx_EventTile_receiptSending::before { mask-image: url('$(res)/img/element-icons/circle-sending.svg'); } + + &[data-layout=group] { + .mx_EventTile_line { + line-height: var(--GroupLayout-EventTile-line-height); + } + } } .mx_EventTile:not([data-layout=bubble]) { @@ -83,10 +89,6 @@ $threadInfoLineHeight: calc(2 * $font-12px); // See: _commons.scss margin-left: 64px; } - &.mx_EventTile_info { - padding-top: 1px; - } - .mx_EventTile_avatar { top: 14px; left: 8px; @@ -94,22 +96,35 @@ $threadInfoLineHeight: calc(2 * $font-12px); // See: _commons.scss user-select: none; } - &.mx_EventTile_info .mx_EventTile_avatar { - top: $font-6px; - left: $left-gutter; - } + &.mx_EventTile_info { + padding-top: 0; - &.mx_EventTile_continuation { - padding-top: 0px !important; + .mx_EventTile_avatar, + .mx_EventTile_e2eIcon { + margin: 3px 0 2px; // Align with mx_EventTile_line + } - &.mx_EventTile_isEditing { - padding-top: 5px !important; - margin-top: -5px; + .mx_EventTile_e2eIcon { + top: 0; + } + + .mx_EventTile_avatar { + top: initial; + inset-inline-start: $left-gutter; + height: 14px; + } + + .mx_EventTile_line { + padding: 3px 0 2px; // Align with mx_EventTile_avatar and mx_EventTile_e2eIcon + + .mx_MessageTimestamp { + top: 0; + } } } - &.mx_EventTile_isEditing { - background-color: $header-panel-bg-color; + &.mx_EventTile_continuation { + padding-top: 0px !important; } .mx_DisambiguatedProfile { @@ -157,6 +172,7 @@ $threadInfoLineHeight: calc(2 * $font-12px); // See: _commons.scss /* this is used for the tile for the event which is selected via the URL. * TODO: ultimately we probably want some transition on here. */ + &.mx_EventTile_isEditing > .mx_EventTile_line, &.mx_EventTile_selected > .mx_EventTile_line { box-shadow: inset calc(50px + $selected-message-border-width) 0 0 -50px $accent; background-color: $event-selected-color; @@ -263,8 +279,14 @@ $threadInfoLineHeight: calc(2 * $font-12px); // See: _commons.scss } } -.mx_GenericEventListSummary:not([data-layout=bubble]) .mx_EventTile_line { - padding-left: $left-gutter; +.mx_GenericEventListSummary:not([data-layout=bubble]) { + .mx_EventTile_line { + padding-left: $left-gutter; + + .mx_RedactedBody { + line-height: 1; // remove spacing between lines + } + } } .mx_EventTile:not([data-layout=bubble]).mx_EventTile_info .mx_EventTile_line, @@ -710,7 +732,7 @@ $threadInfoLineHeight: calc(2 * $font-12px); // See: _commons.scss .mx_MessagePanel_narrow .mx_ThreadSummary { min-width: initial; - max-width: initial; + max-width: 100%; // prevent overflow width: initial; } @@ -730,11 +752,15 @@ $threadInfoLineHeight: calc(2 * $font-12px); // See: _commons.scss align-items: center; &:hover, - // To cancel "&.mx_EventTile:hover .mx_EventTile_line" + // Override .mx_EventTile:not([data-layout=bubble]).mx_EventTile:hover .mx_EventTile_line &:not([data-layout=bubble]):hover .mx_EventTile_line { background-color: $system; } + &:not([data-layout=bubble]):hover .mx_EventTile_line { + box-shadow: none; // don't show the verification left stroke in the thread list + } + &::after { content: ""; position: absolute; @@ -814,6 +840,8 @@ $threadInfoLineHeight: calc(2 * $font-12px); // See: _commons.scss } .mx_ThreadView { + --ThreadView_group_spacing-start: 56px; // 56px: 64px - 8px (padding) + display: flex; flex-direction: column; max-height: 100%; @@ -848,14 +876,13 @@ $threadInfoLineHeight: calc(2 * $font-12px); // See: _commons.scss padding-right: 0; } - .mx_ReplyChain { - .mx_MLocationBody { - margin-top: 6px; // See: https://github.com/matrix-org/matrix-react-sdk/pull/8442 - } - } - &:not([data-layout=bubble]) { padding-top: $spacing-16; + + .mx_EventTile_line { + padding-top: var(--BaseCard_EventTile_line-padding-block); + padding-bottom: var(--BaseCard_EventTile_line-padding-block); + } } } @@ -879,16 +906,9 @@ $threadInfoLineHeight: calc(2 * $font-12px); // See: _commons.scss margin: 0 -13px 0 0; // align with normal messages } } - - &[data-self=false] { - .mx_MessageActionBar { - right: -60px; // smaller overlap, otherwise it'll overflow on the right - } - } } .mx_EventTile[data-layout=group] { - $spacing-start: 56px; // 56px: 64px - 8px (padding) width: 100%; .mx_EventTile_content, @@ -899,7 +919,7 @@ $threadInfoLineHeight: calc(2 * $font-12px); // See: _commons.scss .mx_MLocationBody, .mx_ReplyChain_wrapper, .mx_ReactionsRow { - margin-left: $spacing-start; + margin-inline-start: var(--ThreadView_group_spacing-start); margin-right: 8px; .mx_EventTile_content, @@ -942,7 +962,7 @@ $threadInfoLineHeight: calc(2 * $font-12px); // See: _commons.scss } .mx_EventTile_mediaLine { - padding-inline-start: $spacing-start; + padding-inline-start: var(--ThreadView_group_spacing-start); } } @@ -968,13 +988,4 @@ $threadInfoLineHeight: calc(2 * $font-12px); // See: _commons.scss padding-right: 11px; // align with right edge of input margin-right: 0; // align with right edge of background } - - .mx_GroupLayout { - .mx_EventTile { - .mx_EventTile_line { - padding-top: 2px; - padding-bottom: 2px; - } - } - } } diff --git a/res/css/views/rooms/_GroupLayout.scss b/res/css/views/rooms/_GroupLayout.scss index a9005cfc525..28ba80b3895 100644 --- a/res/css/views/rooms/_GroupLayout.scss +++ b/res/css/views/rooms/_GroupLayout.scss @@ -18,6 +18,8 @@ limitations under the License. $left-gutter: 64px; .mx_GroupLayout { + --GroupLayout-EventTile-line-height: $font-22px; + .mx_EventTile { > .mx_DisambiguatedProfile { line-height: $font-20px; @@ -30,14 +32,17 @@ $left-gutter: 64px; } .mx_MessageTimestamp { - position: absolute; - width: $MessageTimestamp_width; + position: absolute; // for modern layout } - .mx_EventTile_line, .mx_EventTile_reply { + .mx_EventTile_line, + .mx_EventTile_reply { padding-top: 1px; padding-bottom: 3px; - line-height: $font-22px; + } + + .mx_EventTile_reply { + line-height: var(--GroupLayout-EventTile-line-height); } } @@ -52,7 +57,8 @@ $left-gutter: 64px; .mx_EventTile { padding-top: 4px; - .mx_EventTile_line, .mx_EventTile_reply { + .mx_EventTile_line, + .mx_EventTile_reply { padding-top: 0; padding-bottom: 0; } @@ -61,9 +67,12 @@ $left-gutter: 64px; // same as the padding for non-compact .mx_EventTile.mx_EventTile_info padding-top: 0px; font-size: $font-13px; - .mx_EventTile_line, .mx_EventTile_reply { + + .mx_EventTile_line, + .mx_EventTile_reply { line-height: $font-20px; } + .mx_EventTile_avatar { top: 4px; } @@ -76,10 +85,13 @@ $left-gutter: 64px; &.mx_EventTile_emote { // add a bit more space for emotes so that avatars don't collide padding-top: 8px; + .mx_EventTile_avatar { top: 2px; } - .mx_EventTile_line, .mx_EventTile_reply { + + .mx_EventTile_line, + .mx_EventTile_reply { padding-top: 0px; padding-bottom: 1px; } @@ -87,7 +99,9 @@ $left-gutter: 64px; &.mx_EventTile_emote.mx_EventTile_continuation { padding-top: 0; - .mx_EventTile_line, .mx_EventTile_reply { + + .mx_EventTile_line, + .mx_EventTile_reply { padding-top: 0px; padding-bottom: 0px; } diff --git a/res/css/views/rooms/_IRCLayout.scss b/res/css/views/rooms/_IRCLayout.scss index c09ee476767..d689d152348 100644 --- a/res/css/views/rooms/_IRCLayout.scss +++ b/res/css/views/rooms/_IRCLayout.scss @@ -15,7 +15,6 @@ limitations under the License. */ $icon-width: 14px; -$timestamp-width: 45px; $right-padding: 5px; $irc-line-height: $font-18px; @@ -28,7 +27,7 @@ $irc-line-height: $font-18px; // timestamps are links which shouldn't be underlined > a { text-decoration: none; - min-width: 45px; + min-width: $MessageTimestamp_width; } display: flex; @@ -85,7 +84,6 @@ $irc-line-height: $font-18px; .mx_MessageTimestamp { font-size: $font-10px; - width: $timestamp-width; text-align: right; } @@ -141,7 +139,7 @@ $irc-line-height: $font-18px; .mx_GenericEventListSummary { > .mx_EventTile_line { - padding-left: calc(var(--name-width) + $icon-width + $timestamp-width + 3 * $right-padding); // 15 px of padding + padding-left: calc(var(--name-width) + $icon-width + $MessageTimestamp_width + 3 * $right-padding); // 15 px of padding } .mx_GenericEventListSummary_avatars { diff --git a/res/css/views/rooms/_MessageComposer.scss b/res/css/views/rooms/_MessageComposer.scss index fe1a2b4dd41..45c86b72474 100644 --- a/res/css/views/rooms/_MessageComposer.scss +++ b/res/css/views/rooms/_MessageComposer.scss @@ -196,12 +196,6 @@ limitations under the License. } } -.mx_ContextualMenu { - .mx_MessageComposer_button { - padding-left: calc(var(--size) + 6px); - } -} - .mx_MessageComposer_button { --size: 26px; position: relative; @@ -210,20 +204,16 @@ limitations under the License. line-height: var(--size); width: auto; padding-left: var(--size); + border-radius: 50%; + margin-right: 6px; - &:not(.mx_CallContextMenu_item) { - border-radius: 50%; - margin-right: 6px; - - &:last-child { - margin-right: auto; - } + &:last-child { + margin-right: auto; } &::before { content: ''; position: absolute; - top: 3px; left: 3px; height: 20px; @@ -425,18 +415,3 @@ limitations under the License. left: 0; } } - -.mx_MessageComposer_Menu .mx_CallContextMenu_item { - display: flex; - align-items: center; - max-width: unset; - margin: 7px 7px 7px 16px; // space out the buttons -} - -.mx_MessageComposer_Menu .mx_ContextualMenu { - min-width: 150px; - width: max-content; - padding: 5px 10px 5px 0; - box-shadow: 0px 2px 9px rgba(0, 0, 0, 0.25); - border-radius: 8px; -} diff --git a/res/css/views/rooms/_ReplyPreview.scss b/res/css/views/rooms/_ReplyPreview.scss index 8205f2ec1e1..e32686fc219 100644 --- a/res/css/views/rooms/_ReplyPreview.scss +++ b/res/css/views/rooms/_ReplyPreview.scss @@ -16,39 +16,47 @@ limitations under the License. .mx_ReplyPreview { border: 1px solid $primary-hairline-color; - background: $background; border-bottom: none; - border-radius: $border-radius-8px $border-radius-8px 0 0; + background: $background; max-height: 50vh; overflow: auto; - box-shadow: 0px -16px 32px $composer-shadow-color; .mx_ReplyPreview_section { border-bottom: 1px solid $primary-hairline-color; + display: flex; + flex-flow: column; + row-gap: $spacing-8; + padding: $spacing-8 $spacing-8 0 $spacing-8; .mx_ReplyPreview_header { - margin: 8px; + display: flex; + justify-content: space-between; + column-gap: 8px; + color: $primary-content; font-weight: 400; opacity: 0.4; - } - - .mx_ReplyPreview_tile { - margin: 0 8px; - } - .mx_ReplyPreview_title { - float: left; - } - - .mx_ReplyPreview_cancel { - float: right; - cursor: pointer; - display: flex; + .mx_ReplyPreview_header_cancel { + background-color: $primary-content; + mask: url('$(res)/img/cancel.svg'); + mask-repeat: no-repeat; + mask-position: center; + mask-size: 18px; + width: 18px; + height: 18px; + min-width: 18px; + min-height: 18px; + } } + } +} - .mx_ReplyPreview_clear { - clear: both; - } +.mx_RoomView_body { + .mx_ReplyPreview { + // Add box-shadow to the reply preview on the main (left) panel only. + // It is not added to the preview on the (right) panel for threads and a chat with a maximized widget. + box-shadow: 0px -16px 32px $composer-shadow-color; + border-radius: $border-radius-8px $border-radius-8px 0 0; } } diff --git a/res/css/views/rooms/_RoomHeader.scss b/res/css/views/rooms/_RoomHeader.scss index e1ec8261923..fbb65bbecb7 100644 --- a/res/css/views/rooms/_RoomHeader.scss +++ b/res/css/views/rooms/_RoomHeader.scss @@ -140,6 +140,11 @@ limitations under the License. cursor: pointer; } +.mx_RoomTopic { + position: relative; + cursor: pointer; +} + .mx_RoomHeader_topic { $lineHeight: $font-16px; $lines: 2; @@ -214,6 +219,7 @@ limitations under the License. .mx_RoomHeader_appsButton::before { mask-image: url('$(res)/img/element-icons/room/apps.svg'); } + .mx_RoomHeader_appsButton_highlight::before { background-color: $accent; } @@ -244,6 +250,7 @@ limitations under the License. padding: 0; margin: 0; } + .mx_RoomHeader { overflow: hidden; } diff --git a/res/css/views/rooms/_RoomInfoLine.scss b/res/css/views/rooms/_RoomInfoLine.scss new file mode 100644 index 00000000000..5c0aea7c0bd --- /dev/null +++ b/res/css/views/rooms/_RoomInfoLine.scss @@ -0,0 +1,58 @@ +/* +Copyright 2022 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_RoomInfoLine { + color: $secondary-content; + display: inline-block; + + &::before { + content: ""; + display: inline-block; + height: 1.2em; + mask-position-y: center; + mask-repeat: no-repeat; + background-color: $tertiary-content; + vertical-align: text-bottom; + margin-right: 6px; + } + + &.mx_RoomInfoLine_public::before { + width: 12px; + mask-size: 12px; + mask-image: url("$(res)/img/globe.svg"); + } + + &.mx_RoomInfoLine_private::before { + width: 14px; + mask-size: 14px; + mask-image: url("$(res)/img/element-icons/lock.svg"); + } + + &.mx_RoomInfoLine_video::before { + width: 16px; + mask-size: 16px; + mask-image: url("$(res)/img/element-icons/call/video-call.svg"); + } + + .mx_RoomInfoLine_members { + color: inherit; + + &::before { + content: "·"; // visual separator + margin: 0 6px; + } + } +} diff --git a/res/css/views/rooms/_RoomPreviewCard.scss b/res/css/views/rooms/_RoomPreviewCard.scss new file mode 100644 index 00000000000..b561bf666df --- /dev/null +++ b/res/css/views/rooms/_RoomPreviewCard.scss @@ -0,0 +1,136 @@ +/* +Copyright 2022 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_RoomPreviewCard { + padding: $spacing-32 $spacing-24 !important; // Override SpaceRoomView's default padding + margin: auto; + flex-grow: 1; + max-width: 480px; + box-sizing: border-box; + background-color: $system; + border-radius: 8px; + position: relative; + font-size: $font-14px; + + .mx_RoomPreviewCard_notice { + font-weight: $font-semi-bold; + line-height: $font-24px; + color: $primary-content; + margin-top: $spacing-24; + position: relative; + padding-left: calc(20px + $spacing-8); + + .mx_AccessibleButton_kind_link { + display: inline; + padding: 0; + font-size: inherit; + line-height: inherit; + } + + &::before { + content: ""; + position: absolute; + height: $font-24px; + width: 20px; + left: 0; + mask-repeat: no-repeat; + mask-position: center; + mask-size: contain; + mask-image: url('$(res)/img/element-icons/room/room-summary.svg'); + background-color: $secondary-content; + } + } + + .mx_RoomPreviewCard_inviter { + display: flex; + align-items: center; + margin-bottom: $spacing-20; + font-size: $font-15px; + + > div { + margin-left: $spacing-8; + + .mx_RoomPreviewCard_inviter_name { + line-height: $font-18px; + } + + .mx_RoomPreviewCard_inviter_mxid { + color: $secondary-content; + } + } + } + + .mx_RoomPreviewCard_avatar { + display: flex; + align-items: center; + + .mx_RoomAvatar_isSpaceRoom { + &.mx_BaseAvatar_image, .mx_BaseAvatar_image { + border-radius: 12px; + } + } + + .mx_RoomPreviewCard_video { + width: 50px; + height: 50px; + border-radius: calc((50px + 2 * 3px) / 2); + background-color: $accent; + border: 3px solid $system; + + position: relative; + left: calc(-50px / 4 - 3px); + + &::before { + content: ""; + background-color: $button-primary-fg-color; + position: absolute; + width: 50px; + height: 50px; + mask-size: 22px; + mask-position: center; + mask-repeat: no-repeat; + mask-image: url('$(res)/img/element-icons/call/video-call.svg'); + } + } + } + + h1.mx_RoomPreviewCard_name { + margin: $spacing-16 0 !important; // Override SpaceRoomView's default margins + } + + .mx_RoomPreviewCard_topic { + line-height: $font-22px; + margin-top: $spacing-16; + max-height: 160px; + overflow-y: auto; + } + + .mx_FacePile { + margin-top: $spacing-20; + } + + .mx_RoomPreviewCard_joinButtons { + margin-top: $spacing-20; + display: flex; + gap: $spacing-20; + + .mx_AccessibleButton { + max-width: 200px; + padding: 14px 0; + flex-grow: 1; + } + } +} diff --git a/res/css/views/rooms/_RoomSublist.scss b/res/css/views/rooms/_RoomSublist.scss index 5cf477dfef5..48e659275a3 100644 --- a/res/css/views/rooms/_RoomSublist.scss +++ b/res/css/views/rooms/_RoomSublist.scss @@ -420,10 +420,6 @@ limitations under the License. } } -.mx_RoomSublist_addRoomTooltip { - margin-top: -3px; -} - .mx_RoomSublist_skeletonUI { position: relative; margin-left: 4px; diff --git a/res/css/views/rooms/_RoomTile.scss b/res/css/views/rooms/_RoomTile.scss index deaeae4505c..7d43155625e 100644 --- a/res/css/views/rooms/_RoomTile.scss +++ b/res/css/views/rooms/_RoomTile.scss @@ -50,6 +50,10 @@ limitations under the License. } } + .mx_RoomTile_details { + min-width: 0; + } + .mx_RoomTile_titleContainer { min-width: 0; flex-basis: 0; diff --git a/res/css/views/rooms/_SendMessageComposer.scss b/res/css/views/rooms/_SendMessageComposer.scss index 1e2b060096a..3e2cf68f1db 100644 --- a/res/css/views/rooms/_SendMessageComposer.scss +++ b/res/css/views/rooms/_SendMessageComposer.scss @@ -20,7 +20,7 @@ limitations under the License. flex-direction: column; font-size: $font-14px; // fixed line height to prevent emoji from being taller than text - line-height: calc(1.2 * $font-14px); + line-height: $font-18px; justify-content: center; margin-right: 6px; // don't grow wider than available space diff --git a/res/css/views/settings/_ProfileSettings.scss b/res/css/views/settings/_ProfileSettings.scss index ae77a432e0a..6ab13058d40 100644 --- a/res/css/views/settings/_ProfileSettings.scss +++ b/res/css/views/settings/_ProfileSettings.scss @@ -28,12 +28,6 @@ limitations under the License. .mx_ProfileSettings_controls { flex-grow: 1; margin-right: 54px; - - // We put the header under the controls with some minor styling to cheat - // alignment of the field with the avatar - .mx_SettingsTab_subheading { - margin-top: 0; - } } .mx_ProfileSettings_controls .mx_Field #profileTopic { diff --git a/res/css/views/settings/tabs/_SettingsTab.scss b/res/css/views/settings/tabs/_SettingsTab.scss index 5f6109cea42..1eb4868e557 100644 --- a/res/css/views/settings/tabs/_SettingsTab.scss +++ b/res/css/views/settings/tabs/_SettingsTab.scss @@ -27,6 +27,7 @@ limitations under the License. font-weight: 600; color: $primary-content; margin-bottom: 10px; + margin-top: 10px; } .mx_SettingsTab_heading:nth-child(n + 2) { diff --git a/res/css/views/settings/tabs/user/_AppearanceUserSettingsTab.scss b/res/css/views/settings/tabs/user/_AppearanceUserSettingsTab.scss index 57cca73edaf..58443216e67 100644 --- a/res/css/views/settings/tabs/user/_AppearanceUserSettingsTab.scss +++ b/res/css/views/settings/tabs/user/_AppearanceUserSettingsTab.scss @@ -26,6 +26,7 @@ limitations under the License. .mx_AppearanceUserSettingsTab { > .mx_SettingsTab_SubHeading { margin-bottom: 32px; + margin-top: 12px; } } diff --git a/res/css/views/settings/tabs/user/_SecurityUserSettingsTab.scss b/res/css/views/settings/tabs/user/_SecurityUserSettingsTab.scss index 80cb72f2547..2f64cde2714 100644 --- a/res/css/views/settings/tabs/user/_SecurityUserSettingsTab.scss +++ b/res/css/views/settings/tabs/user/_SecurityUserSettingsTab.scss @@ -27,6 +27,9 @@ limitations under the License. } .mx_SecurityUserSettingsTab { + .mx_SettingsTab_heading { + margin-bottom: 22px; + } .mx_SettingsTab_section { .mx_AccessibleButton_kind_link { padding: 0; diff --git a/res/css/views/settings/tabs/user/_SidebarUserSettingsTab.scss b/res/css/views/settings/tabs/user/_SidebarUserSettingsTab.scss index 42a8f1aaafb..5000f3e9a69 100644 --- a/res/css/views/settings/tabs/user/_SidebarUserSettingsTab.scss +++ b/res/css/views/settings/tabs/user/_SidebarUserSettingsTab.scss @@ -16,7 +16,7 @@ limitations under the License. .mx_SidebarUserSettingsTab { .mx_SettingsTab_section { - margin-top: 10px; + margin-top: 12px; } .mx_SidebarUserSettingsTab_subheading { diff --git a/res/css/views/voip/CallView/_CallViewButtons.scss b/res/css/views/voip/CallView/_CallViewButtons.scss index 9305d07f3b7..4c375ee2222 100644 --- a/res/css/views/voip/CallView/_CallViewButtons.scss +++ b/res/css/views/voip/CallView/_CallViewButtons.scss @@ -1,7 +1,7 @@ /* Copyright 2015, 2016 OpenMarket Ltd Copyright 2020 - 2021 The Matrix.org Foundation C.I.C. -Copyright 2021 Šimon Brandner +Copyright 2021 - 2022 Šimon Brandner Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -27,7 +27,7 @@ limitations under the License. position: absolute; display: flex; justify-content: center; - bottom: 24px; + bottom: 32px; opacity: 1; transition: opacity 0.5s; z-index: 200; // To be above _all_ feeds @@ -46,6 +46,10 @@ limitations under the License. justify-content: center; align-items: center; + position: relative; + + box-shadow: 0px 4px 4px 0px #00000026; // Same on both themes + &::before { content: ''; display: inline-block; @@ -60,6 +64,25 @@ limitations under the License. width: 24px; } + &.mx_CallViewButtons_dropdownButton { + width: 16px; + height: 16px; + + position: absolute; + right: 0; + bottom: 0; + + &::before { + width: 14px; + height: 14px; + mask-image: url('$(res)/img/element-icons/message/chevron-up.svg'); + } + + &.mx_CallViewButtons_dropdownButton_collapsed::before { + transform: rotate(180deg); + } + } + // State buttons &.mx_CallViewButtons_button_on { background-color: $call-view-button-on-background; diff --git a/res/css/views/voip/_CallView.scss b/res/css/views/voip/_CallView.scss index da80d0102c2..e31f42727a0 100644 --- a/res/css/views/voip/_CallView.scss +++ b/res/css/views/voip/_CallView.scss @@ -1,6 +1,7 @@ /* Copyright 2015, 2016 OpenMarket Ltd -Copyright 2020 The Matrix.org Foundation C.I.C. +Copyright 2020 - 2021 The Matrix.org Foundation C.I.C. +Copyright 2021 - 2022 Šimon Brandner Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -22,202 +23,176 @@ border-radius: $border-radius-8px; padding-right: 8px; // XXX: PiPContainer sets pointer-events: none - should probably be set back in a better place pointer-events: initial; -} - -.mx_CallView_large { - padding-bottom: 10px; - margin: $container-gap-width; - // The left side gap is fully handled by this margin. To prohibit bleeding on webkit browser. - margin-right: calc($container-gap-width / 2); - margin-bottom: 10px; - display: flex; - flex-direction: column; - flex: 1; - - .mx_CallView_voice { - flex: 1; - } - - &.mx_CallView_belowWidget { - margin-top: 0; - } -} -.mx_CallView_pip { - width: 320px; - padding-bottom: 8px; - background-color: $system; - box-shadow: 0px 4px 20px rgba(0, 0, 0, 0.2); - border-radius: $border-radius-8px; - - .mx_CallView_video_hold, - .mx_CallView_voice { - height: 180px; - } - - .mx_CallViewButtons { - bottom: 13px; - } + .mx_CallView_toast { + position: absolute; + top: 74px; - .mx_CallViewButtons_button { - width: 34px; - height: 34px; + padding: 4px 8px; - &::before { - width: 22px; - height: 22px; - } - } + border-radius: 4px; + z-index: 50; - .mx_CallView_holdTransferContent { - padding-top: 10px; - padding-bottom: 25px; + // Same on both themes + color: white; + background-color: #17191c; } -} -.mx_CallView_content { - position: relative; - display: flex; - justify-content: center; - border-radius: 8px; + .mx_CallView_content_wrapper { + display: flex; + justify-content: center; - > .mx_VideoFeed { width: 100%; height: 100%; - &.mx_VideoFeed_voice { + overflow: hidden; + + .mx_CallView_content { + position: relative; + display: flex; + flex-direction: column; justify-content: center; align-items: center; - } - .mx_VideoFeed_video { - height: 100%; - background-color: #000; + flex: 1; + overflow: hidden; + + border-radius: 10px; + + padding: 10px; + padding-right: calc(20% + 20px); // Space for the sidebar + + background-color: $call-view-content-background; + + .mx_CallView_status { + z-index: 50; + color: $accent-fg-color; + } + + .mx_CallView_avatarsContainer { + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + + div { + margin-left: 12px; + margin-right: 12px; + } + } + + .mx_CallView_holdBackground { + position: absolute; + left: 0; + right: 0; + + width: 100%; + height: 100%; + + background-repeat: no-repeat; + background-size: cover; + background-position: center; + filter: blur(20px); + + &::after { + content: ""; + display: block; + position: absolute; + width: 100%; + height: 100%; + left: 0; + right: 0; + background-color: rgba(0, 0, 0, 0.6); + } + } + + &.mx_CallView_content_hold .mx_CallView_status { + font-weight: bold; + text-align: center; + + &::before { + display: block; + margin-left: auto; + margin-right: auto; + content: ""; + width: 40px; + height: 40px; + background-image: url("$(res)/img/voip/paused.svg"); + background-position: center; + background-size: cover; + } + + .mx_CallView_pip &::before { + width: 30px; + height: 30px; + } + + .mx_AccessibleButton_hasKind { + padding: 0px; + } + } } + } - .mx_VideoFeed_mic { - left: 10px; - bottom: 10px; + &:not(.mx_CallView_sidebar) .mx_CallView_content { + padding: 0; + width: 100%; + height: 100%; + + .mx_VideoFeed_primary { + aspect-ratio: unset; + border: 0; + + width: 100%; + height: 100%; } } -} -.mx_CallView_voice { - align-items: center; - justify-content: center; - flex-direction: column; - background-color: $inverted-bg-color; -} + &.mx_CallView_pip { + width: 320px; + padding-bottom: 8px; -.mx_CallView_voice_avatarsContainer { - display: flex; - flex-direction: row; - align-items: center; - justify-content: center; - div { - margin-left: 12px; - margin-right: 12px; - } -} + border-radius: 8px; -.mx_CallView_voice .mx_CallView_holdTransferContent { - // This masks the avatar image so when it's blurred, the edge is still crisp - .mx_CallView_voice_avatarContainer { - border-radius: 2000px; - overflow: hidden; - position: relative; - } -} + background-color: $system; + box-shadow: 0px 4px 20px rgba(0, 0, 0, 0.2); -.mx_CallView_holdTransferContent { - height: 20px; - padding-top: 20px; - padding-bottom: 15px; - color: $accent-fg-color; - user-select: none; + .mx_CallViewButtons { + bottom: 13px; - .mx_AccessibleButton_hasKind { - padding: 0px; - font-weight: bold; + .mx_CallViewButtons_button { + width: 34px; + height: 34px; + + &::before { + width: 22px; + height: 22px; + } + } + } + + .mx_CallView_content { + min-height: 180px; + } } -} -.mx_CallView_video { - width: 100%; - height: 100%; - z-index: 30; - overflow: hidden; -} + &.mx_CallView_large { + display: flex; + flex-direction: column; + align-items: center; -.mx_CallView_video_hold { - overflow: hidden; + flex: 1; - // we keep these around in the DOM: it saved wiring them up again when the call - // is resumed and keeps the container the right size - .mx_VideoFeed { - visibility: hidden; - } -} + padding-bottom: 10px; -.mx_CallView_video_holdBackground { - position: absolute; - width: 100%; - height: 100%; - left: 0; - right: 0; - background-repeat: no-repeat; - background-size: cover; - background-position: center; - filter: blur(20px); - &::after { - content: ""; - display: block; - position: absolute; - width: 100%; - height: 100%; - left: 0; - right: 0; - background-color: rgba(0, 0, 0, 0.6); + margin: $container-gap-width; + // The left side gap is fully handled by this margin. To prohibit bleeding on webkit browser. + margin-right: calc($container-gap-width / 2); + margin-bottom: 10px; } -} -.mx_CallView_video .mx_CallView_holdTransferContent { - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - font-weight: bold; - color: $accent-fg-color; - text-align: center; - - &::before { - display: block; - margin-left: auto; - margin-right: auto; - content: ""; - width: 40px; - height: 40px; - background-image: url("$(res)/img/voip/paused.svg"); - background-position: center; - background-size: cover; - } - .mx_CallView_pip &::before { - width: 30px; - height: 30px; - } - .mx_AccessibleButton_hasKind { - padding: 0px; + &.mx_CallView_belowWidget { + margin-top: 0; } } - -.mx_CallView_presenting { - position: absolute; - margin-top: 18px; - padding: 4px 8px; - border-radius: 4px; - - // Same on both themes - color: white; - background-color: #17191c; -} diff --git a/res/css/views/voip/_CallViewHeader.scss b/res/css/views/voip/_CallViewHeader.scss index 358357f1343..6280da8cbb7 100644 --- a/res/css/views/voip/_CallViewHeader.scss +++ b/res/css/views/voip/_CallViewHeader.scss @@ -1,5 +1,6 @@ /* Copyright 2021 The Matrix.org Foundation C.I.C. +Copyright 2021 - 2022 Šimon Brandner Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -19,8 +20,9 @@ limitations under the License. display: flex; flex-direction: row; align-items: center; - justify-content: left; + justify-content: space-between; flex-shrink: 0; + width: 100%; &.mx_CallViewHeader_pip { cursor: pointer; @@ -43,6 +45,8 @@ limitations under the License. .mx_CallViewHeader_controls { margin-left: auto; + display: flex; + gap: 5px; } .mx_CallViewHeader_button { @@ -61,17 +65,23 @@ limitations under the License. mask-size: contain; mask-position: center; } -} -.mx_CallViewHeader_button_fullscreen { - &::before { - mask-image: url('$(res)/img/element-icons/call/fullscreen.svg'); + &.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_button_pin { + &::before { + mask-image: url('$(res)/img/element-icons/room/pin-upright.svg'); + } + } + + &.mx_CallViewHeader_button_expand { + &::before { + mask-image: url('$(res)/img/element-icons/call/expand.svg'); + } } } diff --git a/res/css/views/voip/_CallViewSidebar.scss b/res/css/views/voip/_CallViewSidebar.scss index 4871ccfe65e..351f4061f4b 100644 --- a/res/css/views/voip/_CallViewSidebar.scss +++ b/res/css/views/voip/_CallViewSidebar.scss @@ -1,5 +1,5 @@ /* -Copyright 2021 Šimon Brandner +Copyright 2021 - 2022 Šimon Brandner Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -16,18 +16,15 @@ limitations under the License. .mx_CallViewSidebar { position: absolute; - right: 16px; - bottom: 16px; - z-index: 100; // To be above the primary feed + right: 10px; - overflow: auto; - - height: calc(100% - 32px); // Subtract the top and bottom padding width: 20%; + height: 100%; + overflow: auto; display: flex; - flex-direction: column-reverse; - justify-content: flex-start; + flex-direction: column; + justify-content: center; align-items: flex-end; gap: 12px; @@ -42,15 +39,6 @@ limitations under the License. background-color: $video-feed-secondary-background; } - - .mx_VideoFeed_video { - border-radius: 4px; - } - - .mx_VideoFeed_mic { - left: 6px; - bottom: 6px; - } } &.mx_CallViewSidebar_pipMode { diff --git a/res/css/views/voip/_DialPadContextMenu.scss b/res/css/views/voip/_DialPadContextMenu.scss index 905486de8a3..046db3133e9 100644 --- a/res/css/views/voip/_DialPadContextMenu.scss +++ b/res/css/views/voip/_DialPadContextMenu.scss @@ -35,15 +35,10 @@ limitations under the License. } .mx_DialPadContextMenu_cancel { + @mixin customisedCancelButton; float: right; - mask: url('$(res)/img/feather-customised/cancel.svg'); - mask-repeat: no-repeat; - mask-position: center; - mask-size: cover; width: 14px; height: 14px; - background-color: $dialog-close-fg-color; - cursor: pointer; } .mx_DialPadContextMenu_header:focus-within { diff --git a/res/css/views/voip/_DialPadModal.scss b/res/css/views/voip/_DialPadModal.scss index ff1ded029c3..75ad8a19029 100644 --- a/res/css/views/voip/_DialPadModal.scss +++ b/res/css/views/voip/_DialPadModal.scss @@ -45,15 +45,10 @@ limitations under the License. } .mx_DialPadModal_cancel { + @mixin customisedCancelButton; float: right; - mask: url('$(res)/img/feather-customised/cancel.svg'); - mask-repeat: no-repeat; - mask-position: center; - mask-size: cover; width: 14px; height: 14px; - background-color: $dialog-close-fg-color; - cursor: pointer; margin-right: 16px; } diff --git a/res/css/views/voip/_VideoFeed.scss b/res/css/views/voip/_VideoFeed.scss index 29dcb5cba3c..a0ab8269c0a 100644 --- a/res/css/views/voip/_VideoFeed.scss +++ b/res/css/views/voip/_VideoFeed.scss @@ -1,5 +1,6 @@ /* -Copyright 2015, 2016, 2020 The Matrix.org Foundation C.I.C. +Copyright 2015, 2016, 2020, 2021 The Matrix.org Foundation C.I.C. +Copyright 2021 - 2022 Šimon Brandner Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -20,15 +21,32 @@ limitations under the License. box-sizing: border-box; border: transparent 2px solid; display: flex; + border-radius: 4px; + + &.mx_VideoFeed_secondary { + position: absolute; + right: 24px; + bottom: 72px; + width: 20%; + } &.mx_VideoFeed_voice { background-color: $inverted-bg-color; - aspect-ratio: 16 / 9; + + display: flex; + justify-content: center; + align-items: center; + + &:not(.mx_VideoFeed_primary) { + aspect-ratio: 16 / 9; + } } .mx_VideoFeed_video { + height: 100%; width: 100%; - background-color: transparent; + border-radius: 4px; + background-color: #000000; &.mx_VideoFeed_video_mirror { transform: scale(-1, 1); @@ -37,6 +55,8 @@ limitations under the License. .mx_VideoFeed_mic { position: absolute; + left: 6px; + bottom: 6px; display: flex; align-items: center; justify-content: center; diff --git a/res/img/element-icons/email-prompt.svg b/res/img/element-icons/email-prompt.svg index 19b8f824498..126fff6dd3c 100644 --- a/res/img/element-icons/email-prompt.svg +++ b/res/img/element-icons/email-prompt.svg @@ -1,13 +1,6 @@ - - - - - - - - - - - - + + + + + diff --git a/res/themes/dark/css/_dark.scss b/res/themes/dark/css/_dark.scss index 2fb3dcb0cc3..00c1c2ceab7 100644 --- a/res/themes/dark/css/_dark.scss +++ b/res/themes/dark/css/_dark.scss @@ -94,8 +94,8 @@ $roomheader-addroom-fg-color: $primary-content; // Rich-text-editor // ******************** -$rte-room-pill-color: rgba(255, 255, 255, 0.15); -$other-user-pill-bg-color: rgba(255, 255, 255, 0.15); +$pill-bg-color: $room-highlight-color; +$pill-hover-bg-color: #545a66; // ******************** // Inputs @@ -184,6 +184,7 @@ $call-view-button-on-foreground: $primary-content; $call-view-button-on-background: $system; $call-view-button-off-foreground: $system; $call-view-button-off-background: $primary-content; +$call-view-content-background: $quinary-content; $video-feed-secondary-background: $system; @@ -267,6 +268,10 @@ $selected-color: $room-highlight-color; } // ******************** +body { + color-scheme: dark; +} + // Nasty hacks to apply a filter to arbitrary monochrome artwork to make it // better match the theme. Typically applied to dark grey 'off' buttons or // light grey 'on' buttons. diff --git a/res/themes/legacy-dark/css/_legacy-dark.scss b/res/themes/legacy-dark/css/_legacy-dark.scss index 81bfbb01039..fc9e2cf6417 100644 --- a/res/themes/legacy-dark/css/_legacy-dark.scss +++ b/res/themes/legacy-dark/css/_legacy-dark.scss @@ -27,8 +27,8 @@ $light-fg-color: $header-panel-text-secondary-color; // used for focusing form controls $focus-bg-color: $room-highlight-color; -$other-user-pill-bg-color: rgba(255, 255, 255, 0.15); -$rte-room-pill-color: rgba(255, 255, 255, 0.15); +$pill-bg-color: $room-highlight-color; +$pill-hover-bg-color: #545a66; // informational plinth $info-plinth-bg-color: $header-panel-bg-color; @@ -117,6 +117,7 @@ $call-view-button-on-foreground: $primary-content; $call-view-button-on-background: $system; $call-view-button-off-foreground: $system; $call-view-button-off-background: $primary-content; +$call-view-content-background: $quinary-content; $video-feed-secondary-background: $system; @@ -241,6 +242,10 @@ $location-live-secondary-color: #e0e0e0; text-decoration: none; } +body { + color-scheme: dark; +} + // Nasty hacks to apply a filter to arbitrary monochrome artwork to make it // better match the theme. Typically applied to dark grey 'off' buttons or // light grey 'on' buttons. diff --git a/res/themes/legacy-light/css/_fonts.scss b/res/themes/legacy-light/css/_fonts.scss index 68d9496276c..e8a397bcf8a 100644 --- a/res/themes/legacy-light/css/_fonts.scss +++ b/res/themes/legacy-light/css/_fonts.scss @@ -1,122 +1,3 @@ -/* the 'src' links are relative to the bundle.css, which is in a subdirectory. - */ - -/* Inter unexpectedly contains various codepoints which collide with emoji, even - when variation-16 is applied to request the emoji variant. From eyeballing - the emoji picker, these are: 20e3, 23cf, 24c2, 25a0-25c1, 2665, 2764, 2b06, 2b1c. - Therefore we define a unicode-range to load which excludes the glyphs - (to avoid having to maintain a fork of Inter). */ - -$inter-unicode-range: U+0000-20e2,U+20e4-23ce,U+23d0-24c1,U+24c3-259f,U+25c2-2664,U+2666-2763,U+2765-2b05,U+2b07-2b1b,U+2b1d-10FFFF; - -@font-face { - font-family: 'Inter'; - font-style: normal; - font-weight: 400; - font-display: swap; - unicode-range: $inter-unicode-range; - src: url("$(res)/fonts/Inter/Inter-Regular.woff2?v=3.18") format("woff2"), - url("$(res)/fonts/Inter/Inter-Regular.woff?v=3.18") format("woff"); -} -@font-face { - font-family: 'Inter'; - font-style: italic; - font-weight: 400; - font-display: swap; - unicode-range: $inter-unicode-range; - src: url("$(res)/fonts/Inter/Inter-Italic.woff2?v=3.18") format("woff2"), - url("$(res)/fonts/Inter/Inter-Italic.woff?v=3.18") format("woff"); -} - -@font-face { - font-family: 'Inter'; - font-style: normal; - font-weight: 500; - font-display: swap; - unicode-range: $inter-unicode-range; - src: url("$(res)/fonts/Inter/Inter-Medium.woff2?v=3.18") format("woff2"), - url("$(res)/fonts/Inter/Inter-Medium.woff?v=3.18") format("woff"); -} -@font-face { - font-family: 'Inter'; - font-style: italic; - font-weight: 500; - font-display: swap; - unicode-range: $inter-unicode-range; - src: url("$(res)/fonts/Inter/Inter-MediumItalic.woff2?v=3.18") format("woff2"), - url("$(res)/fonts/Inter/Inter-MediumItalic.woff?v=3.18") format("woff"); -} - -@font-face { - font-family: 'Inter'; - font-style: normal; - font-weight: 600; - font-display: swap; - unicode-range: $inter-unicode-range; - src: url("$(res)/fonts/Inter/Inter-SemiBold.woff2?v=3.18") format("woff2"), - url("$(res)/fonts/Inter/Inter-SemiBold.woff?v=3.18") format("woff"); -} -@font-face { - font-family: 'Inter'; - font-style: italic; - font-weight: 600; - font-display: swap; - unicode-range: $inter-unicode-range; - src: url("$(res)/fonts/Inter/Inter-SemiBoldItalic.woff2?v=3.18") format("woff2"), - url("$(res)/fonts/Inter/Inter-SemiBoldItalic.woff?v=3.18") format("woff"); -} - -@font-face { - font-family: 'Inter'; - font-style: normal; - font-weight: 700; - font-display: swap; - unicode-range: $inter-unicode-range; - src: url("$(res)/fonts/Inter/Inter-Bold.woff2?v=3.18") format("woff2"), - url("$(res)/fonts/Inter/Inter-Bold.woff?v=3.18") format("woff"); -} -@font-face { - font-family: 'Inter'; - font-style: italic; - font-weight: 700; - font-display: swap; - unicode-range: $inter-unicode-range; - src: url("$(res)/fonts/Inter/Inter-BoldItalic.woff2?v=3.18") format("woff2"), - url("$(res)/fonts/Inter/Inter-BoldItalic.woff?v=3.18") format("woff"); -} - -/* latin-ext */ -@font-face { - font-family: 'Inconsolata'; - font-style: normal; - font-weight: 400; - src: local('Inconsolata Regular'), local('Inconsolata-Regular'), url('$(res)/fonts/Inconsolata/QldKNThLqRwH-OJ1UHjlKGlX5qhExfHwNJU.woff2') format('woff2'); - unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF; -} -/* latin */ -@font-face { - font-family: 'Inconsolata'; - font-style: normal; - font-weight: 400; - font-display: swap; - src: local('Inconsolata Regular'), local('Inconsolata-Regular'), url('$(res)/fonts/Inconsolata/QldKNThLqRwH-OJ1UHjlKGlZ5qhExfHw.woff2') format('woff2'); - unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; -} -/* latin-ext */ -@font-face { - font-family: 'Inconsolata'; - font-style: normal; - font-weight: 700; - font-display: swap; - src: local('Inconsolata Bold'), local('Inconsolata-Bold'), url('$(res)/fonts/Inconsolata/QldXNThLqRwH-OJ1UHjlKGHiw71n5_zaDpwm80E.woff2') format('woff2'); - unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF; -} -/* latin */ -@font-face { - font-family: 'Inconsolata'; - font-style: normal; - font-weight: 700; - font-display: swap; - src: local('Inconsolata Bold'), local('Inconsolata-Bold'), url('$(res)/fonts/Inconsolata/QldXNThLqRwH-OJ1UHjlKGHiw71p5_zaDpwm.woff2') format('woff2'); - unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; -} +// Grab the other fonts from the current theme, so we can override to Inter +// in custom fonts if needed. +@import "../../light/css/_fonts.scss"; diff --git a/res/themes/legacy-light/css/_legacy-light.scss b/res/themes/legacy-light/css/_legacy-light.scss index a5c35f312f0..4b59630d392 100644 --- a/res/themes/legacy-light/css/_legacy-light.scss +++ b/res/themes/legacy-light/css/_legacy-light.scss @@ -37,8 +37,6 @@ $selection-fg-color: $primary-bg-color; $focus-brightness: 105%; -$other-user-pill-bg-color: rgba(0, 0, 0, 0.13); - // informational plinth $info-plinth-bg-color: #f7f7f7; $info-plinth-fg-color: #888; @@ -117,10 +115,12 @@ $settings-subsection-fg-color: #616161; $rte-bg-color: #e0e0e0; $rte-code-bg-color: rgba(0, 0, 0, 0.04); -$rte-room-pill-color: rgba(0, 0, 0, 0.13); $header-panel-text-primary-color: #757575; +$pill-bg-color: #aaa; +$pill-hover-bg-color: #ccc; + $topleftmenu-color: #212121; $roomheader-bg-color: $primary-bg-color; $roomheader-addroom-bg-color: #757575; @@ -175,6 +175,7 @@ $call-view-button-on-foreground: $secondary-content; $call-view-button-on-background: $background; $call-view-button-off-foreground: $background; $call-view-button-off-background: $secondary-content; +$call-view-content-background: #21262C; $video-feed-secondary-background: #424242; // XXX: Color from dark theme @@ -353,6 +354,10 @@ $location-live-secondary-color: #e0e0e0; text-decoration: none; } +body { + color-scheme: light; +} + // diff highlight colors .hljs-addition { background: #dfd; diff --git a/res/themes/light-custom/css/_custom.scss b/res/themes/light-custom/css/_custom.scss index b85f7c5f457..38995139d02 100644 --- a/res/themes/light-custom/css/_custom.scss +++ b/res/themes/light-custom/css/_custom.scss @@ -142,6 +142,6 @@ $message-bubble-background-selected: var(--eventbubble-selected-bg, $message-bub $reaction-row-button-selected-bg-color: var(--reaction-row-button-selected-bg-color, $reaction-row-button-selected-bg-color); $menu-selected-color: var(--menu-selected-color, $menu-selected-color); -$rte-room-pill-color: var(--rte-room-pill-color, $rte-room-pill-color); -$other-user-pill-bg-color: var(--other-user-pill-bg-color, $other-user-pill-bg-color); +$pill-bg-color: var(--other-user-pill-bg-color, $pill-bg-color); +$pill-hover-bg-color: var(--other-user-pill-bg-color, $pill-hover-bg-color); $icon-button-color: var(--icon-button-color, $icon-button-color); diff --git a/res/themes/light/css/_light.scss b/res/themes/light/css/_light.scss index 9b627f31d3e..8ab2e4e4fcf 100644 --- a/res/themes/light/css/_light.scss +++ b/res/themes/light/css/_light.scss @@ -155,9 +155,9 @@ $roomheader-addroom-fg-color: #616161; // Rich-text-editor // ******************** -$rte-room-pill-color: rgba(0, 0, 0, 0.13); -$other-user-pill-bg-color: rgba(0, 0, 0, 0.13); -$rte-bg-color: #e0e0e0; +$pill-bg-color: #aaa; +$pill-hover-bg-color: #ccc; +$rte-bg-color: #e9e9e9; $rte-code-bg-color: rgba(0, 0, 0, 0.04); // ******************** @@ -283,6 +283,7 @@ $call-view-button-on-foreground: $secondary-content; $call-view-button-on-background: $background; $call-view-button-off-foreground: $background; $call-view-button-off-background: $secondary-content; +$call-view-content-background: #21262C; $video-feed-secondary-background: #424242; // XXX: Color from dark theme $voipcall-plinth-color: $system; @@ -383,6 +384,10 @@ $location-live-secondary-color: #e0e0e0; text-decoration: none; } +body { + color-scheme: light; +} + // ******************** // diff highlight colors diff --git a/scripts/fetchdep.sh b/scripts/fetchdep.sh index 737e87844f5..81f0784ff96 100755 --- a/scripts/fetchdep.sh +++ b/scripts/fetchdep.sh @@ -10,6 +10,9 @@ defbranch="$3" rm -r "$defrepo" || true +PR_ORG=${PR_ORG:-"matrix-org"} +PR_REPO=${PR_REPO:-"matrix-react-sdk"} + # A function that clones a branch of a repo based on the org, repo and branch clone() { org=$1 @@ -29,8 +32,7 @@ getPRInfo() { if [ -n "$number" ]; then echo "Getting info about a PR with number $number" - apiEndpoint="https://api.github.com/repos/${REPOSITORY:-"matrix-org/matrix-react-sdk"}/pulls/" - apiEndpoint+=$number + apiEndpoint="https://api.github.com/repos/$PR_ORG/$PR_REPO/pulls/$number" head=$(curl $apiEndpoint | jq -r '.head.label') fi @@ -58,7 +60,7 @@ TRY_ORG=$deforg TRY_BRANCH=${BRANCH_ARRAY[0]} if [[ "$head" == *":"* ]]; then # ... but only match that fork if it's a real fork - if [ "${BRANCH_ARRAY[0]}" != "matrix-org" ]; then + if [ "${BRANCH_ARRAY[0]}" != "$PR_ORG" ]; then TRY_ORG=${BRANCH_ARRAY[0]} fi TRY_BRANCH=${BRANCH_ARRAY[1]} diff --git a/sonar-project.properties b/sonar-project.properties index b6516cb92ac..47814f9d418 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -1,13 +1,6 @@ sonar.projectKey=matrix-react-sdk sonar.organization=matrix-org -# This is the name and version displayed in the SonarCloud UI. -#sonar.projectName=matrix-react-sdk -#sonar.projectVersion=1.0 - -# Path is relative to the sonar-project.properties file. Replace "\" by "/" on Windows. -#sonar.sources=. - # Encoding of the source code. Default is default system encoding #sonar.sourceEncoding=UTF-8 @@ -17,5 +10,5 @@ sonar.exclusions=__mocks__,docs sonar.typescript.tsconfigPath=./tsconfig.json sonar.javascript.lcov.reportPaths=coverage/lcov.info -sonar.coverage.exclusions=spec/*.ts +sonar.coverage.exclusions=test/**/*,cypress/**/* sonar.testExecutionReportPaths=coverage/test-report.xml diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts index d0f266470c9..4d87e0a2f02 100644 --- a/src/@types/global.d.ts +++ b/src/@types/global.d.ts @@ -51,6 +51,7 @@ import { ConsoleLogger, IndexedDBLogStore } from "../rageshake/rageshake"; import ActiveWidgetStore from "../stores/ActiveWidgetStore"; import AutoRageshakeStore from "../stores/AutoRageshakeStore"; import { IConfigOptions } from "../IConfigOptions"; +import { MatrixDispatcher } from "../dispatcher/dispatcher"; /* eslint-disable @typescript-eslint/naming-convention */ @@ -109,6 +110,7 @@ declare global { mxSendSentryReport: (userText: string, issueUrl: string, error: Error) => Promise; mxLoginWithAccessToken: (hsUrl: string, accessToken: string) => Promise; mxAutoRageshakeStore?: AutoRageshakeStore; + mxDispatcher: MatrixDispatcher; } interface Electron { diff --git a/src/BasePlatform.ts b/src/BasePlatform.ts index b7f52d38952..95f34597635 100644 --- a/src/BasePlatform.ts +++ b/src/BasePlatform.ts @@ -237,7 +237,7 @@ export default abstract class BasePlatform { } /** - * Restarts the application, without neccessarily reloading + * Restarts the application, without necessarily reloading * any application code */ abstract reload(); diff --git a/src/CallHandler.tsx b/src/CallHandler.tsx index 2b934251b72..787f602fb77 100644 --- a/src/CallHandler.tsx +++ b/src/CallHandler.tsx @@ -948,7 +948,7 @@ export default class CallHandler extends EventEmitter { ): Promise { if (consultFirst) { // if we're consulting, we just start by placing a call to the transfer - // target (passing the transferee so the actual tranfer can happen later) + // target (passing the transferee so the actual transfer can happen later) this.dialNumber(destination, call); return; } diff --git a/src/ContentMessages.ts b/src/ContentMessages.ts index 1d54b1adc3a..7cb0ad1db9c 100644 --- a/src/ContentMessages.ts +++ b/src/ContentMessages.ts @@ -380,11 +380,11 @@ export default class ContentMessages { const tooBigFiles = []; const okFiles = []; - for (let i = 0; i < files.length; ++i) { - if (this.isFileSizeAcceptable(files[i])) { - okFiles.push(files[i]); + for (const file of files) { + if (this.isFileSizeAcceptable(file)) { + okFiles.push(file); } else { - tooBigFiles.push(files[i]); + tooBigFiles.push(file); } } @@ -450,13 +450,7 @@ export default class ContentMessages { } public cancelUpload(promise: Promise, matrixClient: MatrixClient): void { - let upload: IUpload; - for (let i = 0; i < this.inprogress.length; ++i) { - if (this.inprogress[i].promise === promise) { - upload = this.inprogress[i]; - break; - } - } + const upload = this.inprogress.find(item => item.promise === promise); if (upload) { upload.canceled = true; matrixClient.cancelUpload(upload.promise); diff --git a/src/DecryptionFailureTracker.ts b/src/DecryptionFailureTracker.ts index 2bb522e7fe9..c56b245f259 100644 --- a/src/DecryptionFailureTracker.ts +++ b/src/DecryptionFailureTracker.ts @@ -31,18 +31,19 @@ export class DecryptionFailure { type ErrorCode = "OlmKeysNotSentError" | "OlmIndexError" | "UnknownError" | "OlmUnspecifiedError"; -type TrackingFn = (count: number, trackedErrCode: ErrorCode) => void; +type TrackingFn = (count: number, trackedErrCode: ErrorCode, rawError: string) => void; export type ErrCodeMapFn = (errcode: string) => ErrorCode; export class DecryptionFailureTracker { - private static internalInstance = new DecryptionFailureTracker((total, errorCode) => { + private static internalInstance = new DecryptionFailureTracker((total, errorCode, rawError) => { Analytics.trackEvent('E2E', 'Decryption failure', errorCode, String(total)); for (let i = 0; i < total; i++) { PosthogAnalytics.instance.trackEvent({ eventName: "Error", domain: "E2EE", name: errorCode, + context: `mxc_crypto_error_type_${rawError}`, }); } }, (errorCode) => { @@ -236,7 +237,7 @@ export class DecryptionFailureTracker { if (this.failureCounts[errorCode] > 0) { const trackedErrorCode = this.errorCodeMapFn(errorCode); - this.fn(this.failureCounts[errorCode], trackedErrorCode); + this.fn(this.failureCounts[errorCode], trackedErrorCode, errorCode); this.failureCounts[errorCode] = 0; } } diff --git a/src/HtmlUtils.tsx b/src/HtmlUtils.tsx index ba3b4e6004c..09ec78121bb 100644 --- a/src/HtmlUtils.tsx +++ b/src/HtmlUtils.tsx @@ -27,13 +27,17 @@ import katex from 'katex'; import { AllHtmlEntities } from 'html-entities'; import { IContent } from 'matrix-js-sdk/src/models/event'; -import { _linkifyElement, _linkifyString } from './linkify-matrix'; +import { + _linkifyElement, + _linkifyString, + ELEMENT_URL_PATTERN, + options as linkifyMatrixOptions, +} from './linkify-matrix'; import { IExtendedSanitizeOptions } from './@types/sanitize-html'; import SettingsStore from './settings/SettingsStore'; import { tryTransformPermalinkToLocalHref } from "./utils/permalinks/Permalinks"; import { getEmojiFromUnicode } from "./emoji"; import { mediaFromMxc } from "./customisations/Media"; -import { ELEMENT_URL_PATTERN, options as linkifyMatrixOptions } from './linkify-matrix'; import { stripHTMLReply, stripPlainReply } from './utils/Reply'; // Anything outside the basic multilingual plane will be a surrogate pair @@ -45,10 +49,10 @@ const SURROGATE_PAIR_PATTERN = /([\ud800-\udbff])([\udc00-\udfff])/; const SYMBOL_PATTERN = /([\u2100-\u2bff])/; // Regex pattern for Zero-Width joiner unicode characters -const ZWJ_REGEX = new RegExp("\u200D|\u2003", "g"); +const ZWJ_REGEX = /[\u200D\u2003]/g; // Regex pattern for whitespace characters -const WHITESPACE_REGEX = new RegExp("\\s", "g"); +const WHITESPACE_REGEX = /\s/g; const BIGEMOJI_REGEX = new RegExp(`^(${EMOJIBASE_REGEX.source})+$`, 'i'); @@ -183,7 +187,7 @@ const transformTags: IExtendedSanitizeOptions["transformTags"] = { // custom to delete attribs.target; } } else { - // Delete the href attrib if it is falsey + // Delete the href attrib if it is falsy delete attribs.href; } diff --git a/src/ImageUtils.ts b/src/ImageUtils.ts index 9bfab371936..acf8daa607a 100644 --- a/src/ImageUtils.ts +++ b/src/ImageUtils.ts @@ -25,7 +25,7 @@ limitations under the License. * reflect the actual height the scaled thumbnail occupies. * * This is very useful for calculating how much height a thumbnail will actually - * consume in the timeline, when performing scroll offset calcuations + * consume in the timeline, when performing scroll offset calculations * (e.g. scroll locking) */ export function thumbHeight(fullWidth: number, fullHeight: number, thumbWidth: number, thumbHeight: number) { diff --git a/src/KeyBindingsDefaults.ts b/src/KeyBindingsDefaults.ts index d4f4ffc6811..8ce30252f92 100644 --- a/src/KeyBindingsDefaults.ts +++ b/src/KeyBindingsDefaults.ts @@ -15,14 +15,10 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { isMac, Key } from "./Keyboard"; +import { IS_MAC, Key } from "./Keyboard"; import SettingsStore from "./settings/SettingsStore"; import SdkConfig from "./SdkConfig"; -import { - IKeyBindingsProvider, - KeyBinding, - KeyCombo, -} from "./KeyBindingsManager"; +import { IKeyBindingsProvider, KeyBinding } from "./KeyBindingsManager"; import { CATEGORIES, CategoryName, @@ -31,13 +27,10 @@ import { import { getKeyboardShortcuts } from "./accessibility/KeyboardShortcutUtils"; export const getBindingsByCategory = (category: CategoryName): KeyBinding[] => { - return CATEGORIES[category].settingNames.reduce((bindings, name) => { - const value = getKeyboardShortcuts()[name]?.default; - if (value) { - bindings.push({ - action: name as KeyBindingAction, - keyCombo: value as KeyCombo, - }); + return CATEGORIES[category].settingNames.reduce((bindings, action) => { + const keyCombo = getKeyboardShortcuts()[action]?.default; + if (keyCombo) { + bindings.push({ action, keyCombo }); } return bindings; }, []); @@ -81,7 +74,7 @@ const messageComposerBindings = (): KeyBinding[] => { shiftKey: true, }, }); - if (isMac) { + if (IS_MAC) { bindings.push({ action: KeyBindingAction.NewLine, keyCombo: { diff --git a/src/KeyBindingsManager.ts b/src/KeyBindingsManager.ts index 7a79a69ce87..aee403e31d1 100644 --- a/src/KeyBindingsManager.ts +++ b/src/KeyBindingsManager.ts @@ -17,7 +17,7 @@ limitations under the License. import { KeyBindingAction } from "./accessibility/KeyboardShortcuts"; import { defaultBindingsProvider } from './KeyBindingsDefaults'; -import { isMac } from './Keyboard'; +import { IS_MAC } from './Keyboard'; /** * Represent a key combination. @@ -127,7 +127,7 @@ export class KeyBindingsManager { ): KeyBindingAction | undefined { for (const getter of getters) { const bindings = getter(); - const binding = bindings.find(it => isKeyComboMatch(ev, it.keyCombo, isMac)); + const binding = bindings.find(it => isKeyComboMatch(ev, it.keyCombo, IS_MAC)); if (binding) { return binding.action; } diff --git a/src/Keyboard.ts b/src/Keyboard.ts index 8d7d39fc190..efecd791fd8 100644 --- a/src/Keyboard.ts +++ b/src/Keyboard.ts @@ -74,10 +74,10 @@ export const Key = { Z: "z", }; -export const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0; +export const IS_MAC = navigator.platform.toUpperCase().includes('MAC'); export function isOnlyCtrlOrCmdKeyEvent(ev) { - if (isMac) { + if (IS_MAC) { return ev.metaKey && !ev.altKey && !ev.ctrlKey && !ev.shiftKey; } else { return ev.ctrlKey && !ev.altKey && !ev.metaKey && !ev.shiftKey; @@ -85,7 +85,7 @@ export function isOnlyCtrlOrCmdKeyEvent(ev) { } export function isOnlyCtrlOrCmdIgnoreShiftKeyEvent(ev) { - if (isMac) { + if (IS_MAC) { return ev.metaKey && !ev.altKey && !ev.ctrlKey; } else { return ev.ctrlKey && !ev.altKey && !ev.metaKey; diff --git a/src/Lifecycle.ts b/src/Lifecycle.ts index 6b7268d57ed..5d864cc1cc7 100644 --- a/src/Lifecycle.ts +++ b/src/Lifecycle.ts @@ -60,6 +60,8 @@ import StorageEvictedDialog from "./components/views/dialogs/StorageEvictedDialo import { setSentryUser } from "./sentry"; import SdkConfig from "./SdkConfig"; import { DialogOpener } from "./utils/DialogOpener"; +import VideoChannelStore from "./stores/VideoChannelStore"; +import { fixStuckDevices } from "./utils/VideoChannelUtils"; import { Action } from "./dispatcher/actions"; import AbstractLocalStorageSettingsHandler from "./settings/handlers/AbstractLocalStorageSettingsHandler"; @@ -665,7 +667,7 @@ async function persistCredentials(credentials: IMatrixClientCreds): Promise { } // Now that we have a MatrixClientPeg, update the Jitsi info - await Jitsi.getInstance().start(); + Jitsi.getInstance().start(); + + // In case we disconnected uncleanly from a video room, clean up the stuck device + if (VideoChannelStore.instance.roomId) { + fixStuckDevices(MatrixClientPeg.get().getRoom(VideoChannelStore.instance.roomId), false); + } // dispatch that we finished starting up to wire up any other bits // of the matrix client that cannot be set prior to starting up. diff --git a/src/Login.ts b/src/Login.ts index f7b188c64ac..a16f570fa90 100644 --- a/src/Login.ts +++ b/src/Login.ts @@ -51,7 +51,7 @@ export interface IIdentityProvider { export interface ISSOFlow { type: "m.login.sso" | "m.login.cas"; // eslint-disable-next-line camelcase - identity_providers: IIdentityProvider[]; + identity_providers?: IIdentityProvider[]; } export type LoginFlow = ISSOFlow | IPasswordFlow; diff --git a/src/Markdown.ts b/src/Markdown.ts index 53841c809ef..9480f19ffb4 100644 --- a/src/Markdown.ts +++ b/src/Markdown.ts @@ -308,7 +308,6 @@ export default class Markdown { renderer.html_inline = function(node: commonmark.Node) { if (isAllowedHtmlTag(node)) { this.lit(node.literal); - return; } else { this.lit(escape(node.literal)); } diff --git a/src/MatrixClientPeg.ts b/src/MatrixClientPeg.ts index 46d599b156d..a281ba65625 100644 --- a/src/MatrixClientPeg.ts +++ b/src/MatrixClientPeg.ts @@ -49,6 +49,12 @@ export interface IMatrixClientCreds { freshLogin?: boolean; } +/** + * Holds the current instance of the `MatrixClient` to use across the codebase. + * Looking for an `MatrixClient`? Just look for the `MatrixClientPeg` on the peg + * board. "Peg" is the literal meaning of something you hang something on. So + * you'll find a `MatrixClient` hanging on the `MatrixClientPeg`. + */ export interface IMatrixClientPeg { opts: IStartClientOpts; @@ -123,9 +129,6 @@ class MatrixClientPegClass implements IMatrixClientPeg { // used if we tear it down & recreate it with a different store private currentClientCreds: IMatrixClientCreds; - constructor() { - } - public get(): MatrixClient { return this.matrixClient; } @@ -314,6 +317,10 @@ class MatrixClientPegClass implements IMatrixClientPeg { } } +/** + * Note: You should be using a React context with access to a client rather than + * using this, as in a multi-account world this will not exist! + */ export const MatrixClientPeg: IMatrixClientPeg = new MatrixClientPegClass(); if (!window.mxMatrixClientPeg) { diff --git a/src/MediaDeviceHandler.ts b/src/MediaDeviceHandler.ts index ddf1977bf04..59f624f0808 100644 --- a/src/MediaDeviceHandler.ts +++ b/src/MediaDeviceHandler.ts @@ -72,12 +72,12 @@ export default class MediaDeviceHandler extends EventEmitter { /** * Retrieves devices from the SettingsStore and tells the js-sdk to use them */ - public static loadDevices(): void { + public static async loadDevices(): Promise { const audioDeviceId = SettingsStore.getValue("webrtc_audioinput"); const videoDeviceId = SettingsStore.getValue("webrtc_videoinput"); - MatrixClientPeg.get().getMediaHandler().setAudioInput(audioDeviceId); - MatrixClientPeg.get().getMediaHandler().setVideoInput(videoDeviceId); + await MatrixClientPeg.get().getMediaHandler().setAudioInput(audioDeviceId); + await MatrixClientPeg.get().getMediaHandler().setVideoInput(videoDeviceId); } public setAudioOutput(deviceId: string): void { @@ -90,9 +90,9 @@ export default class MediaDeviceHandler extends EventEmitter { * need to be ended and started again for this change to take effect * @param {string} deviceId */ - public setAudioInput(deviceId: string): void { + public async setAudioInput(deviceId: string): Promise { SettingsStore.setValue("webrtc_audioinput", null, SettingLevel.DEVICE, deviceId); - MatrixClientPeg.get().getMediaHandler().setAudioInput(deviceId); + return MatrixClientPeg.get().getMediaHandler().setAudioInput(deviceId); } /** @@ -100,16 +100,16 @@ export default class MediaDeviceHandler extends EventEmitter { * need to be ended and started again for this change to take effect * @param {string} deviceId */ - public setVideoInput(deviceId: string): void { + public async setVideoInput(deviceId: string): Promise { SettingsStore.setValue("webrtc_videoinput", null, SettingLevel.DEVICE, deviceId); - MatrixClientPeg.get().getMediaHandler().setVideoInput(deviceId); + return MatrixClientPeg.get().getMediaHandler().setVideoInput(deviceId); } - public setDevice(deviceId: string, kind: MediaDeviceKindEnum): void { + public async setDevice(deviceId: string, kind: MediaDeviceKindEnum): Promise { switch (kind) { case MediaDeviceKindEnum.AudioOutput: this.setAudioOutput(deviceId); break; - case MediaDeviceKindEnum.AudioInput: this.setAudioInput(deviceId); break; - case MediaDeviceKindEnum.VideoInput: this.setVideoInput(deviceId); break; + case MediaDeviceKindEnum.AudioInput: await this.setAudioInput(deviceId); break; + case MediaDeviceKindEnum.VideoInput: await this.setVideoInput(deviceId); break; } } @@ -124,4 +124,17 @@ export default class MediaDeviceHandler extends EventEmitter { public static getVideoInput(): string { return SettingsStore.getValueAt(SettingLevel.DEVICE, "webrtc_videoinput"); } + + /** + * Returns the current set deviceId for a device kind + * @param {MediaDeviceKindEnum} kind of the device that will be returned + * @returns {string} the deviceId + */ + public static getDevice(kind: MediaDeviceKindEnum): string { + switch (kind) { + case MediaDeviceKindEnum.AudioOutput: return this.getAudioOutput(); + case MediaDeviceKindEnum.AudioInput: return this.getAudioInput(); + case MediaDeviceKindEnum.VideoInput: return this.getVideoInput(); + } + } } diff --git a/src/NodeAnimator.tsx b/src/NodeAnimator.tsx index 1a8942f5f53..2bb79542404 100644 --- a/src/NodeAnimator.tsx +++ b/src/NodeAnimator.tsx @@ -1,3 +1,19 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + import React from "react"; import ReactDom from "react-dom"; diff --git a/src/Notifier.ts b/src/Notifier.ts index 62e2f093703..892d5bc19cc 100644 --- a/src/Notifier.ts +++ b/src/Notifier.ts @@ -408,10 +408,6 @@ export const Notifier = { // don't bother notifying as user was recently active in this room return; } - if (SettingsStore.getValue("doNotDisturb")) { - // Don't bother the user if they didn't ask to be bothered - return; - } if (this.isEnabled()) { this._displayPopupNotification(ev, room); diff --git a/src/PlatformPeg.ts b/src/PlatformPeg.ts index 1d2b813ebc6..14714a1d349 100644 --- a/src/PlatformPeg.ts +++ b/src/PlatformPeg.ts @@ -18,11 +18,15 @@ limitations under the License. import BasePlatform from "./BasePlatform"; /* - * Holds the current Platform object used by the code to do anything - * specific to the platform we're running on (eg. web, electron) - * Platforms are provided by the app layer. - * This allows the app layer to set a Platform without necessarily - * having to have a MatrixChat object + * Holds the current instance of the `Platform` to use across the codebase. + * Looking for an `Platform`? Just look for the `PlatformPeg` on the peg board. + * "Peg" is the literal meaning of something you hang something on. So you'll + * find a `Platform` hanging on the `PlatformPeg`. + * + * Used by the code to do anything specific to the platform we're running on + * (eg. web, electron). Platforms are provided by the app layer. This allows the + * app layer to set a Platform without necessarily having to have a MatrixChat + * object. */ export class PlatformPeg { platform: BasePlatform = null; diff --git a/src/RoomInvite.tsx b/src/RoomInvite.tsx index 200da2f7cf8..e9204996ed2 100644 --- a/src/RoomInvite.tsx +++ b/src/RoomInvite.tsx @@ -19,6 +19,7 @@ import { Room } from "matrix-js-sdk/src/models/room"; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { User } from "matrix-js-sdk/src/models/user"; import { logger } from "matrix-js-sdk/src/logger"; +import { EventType } from "matrix-js-sdk/src/@types/event"; import { MatrixClientPeg } from './MatrixClientPeg'; import MultiInviter, { CompletionStates } from './utils/MultiInviter'; @@ -84,12 +85,12 @@ export function showRoomInviteDialog(roomId: string, initialText = ""): void { * @returns {boolean} True if valid, false otherwise */ export function isValid3pidInvite(event: MatrixEvent): boolean { - if (!event || event.getType() !== "m.room.third_party_invite") return false; + if (!event || event.getType() !== EventType.RoomThirdPartyInvite) return false; // any events without these keys are not valid 3pid invites, so we ignore them const requiredKeys = ['key_validity_url', 'public_key', 'display_name']; - for (let i = 0; i < requiredKeys.length; ++i) { - if (!event.getContent()[requiredKeys[i]]) return false; + if (requiredKeys.some(key => !event.getContent()[key])) { + return false; } // Valid enough by our standards diff --git a/src/RoomNotifs.ts b/src/RoomNotifs.ts index 39d1d5d4aa7..97e4785104b 100644 --- a/src/RoomNotifs.ts +++ b/src/RoomNotifs.ts @@ -17,7 +17,13 @@ limitations under the License. import { PushProcessor } from 'matrix-js-sdk/src/pushprocessor'; import { NotificationCountType, Room } from "matrix-js-sdk/src/models/room"; -import { ConditionKind, IPushRule, PushRuleActionName, PushRuleKind } from "matrix-js-sdk/src/@types/PushRules"; +import { + ConditionKind, + IPushRule, + PushRuleActionName, + PushRuleKind, + TweakName, +} from "matrix-js-sdk/src/@types/PushRules"; import { EventType } from 'matrix-js-sdk/src/@types/event'; import { MatrixClientPeg } from './MatrixClientPeg'; @@ -144,13 +150,13 @@ function setRoomNotifsStateMuted(roomId: string): Promise { promises.push(cli.addPushRule('global', PushRuleKind.Override, roomId, { conditions: [ { - kind: 'event_match', + kind: ConditionKind.EventMatch, key: 'room_id', pattern: roomId, }, ], actions: [ - 'dont_notify', + PushRuleActionName.DontNotify, ], })); @@ -174,7 +180,7 @@ function setRoomNotifsStateUnmuted(roomId: string, newState: RoomNotifState): Pr } else if (newState === RoomNotifState.MentionsOnly) { promises.push(cli.addPushRule('global', PushRuleKind.RoomSpecific, roomId, { actions: [ - 'dont_notify', + PushRuleActionName.DontNotify, ], })); // https://matrix.org/jira/browse/SPEC-400 @@ -182,9 +188,9 @@ function setRoomNotifsStateUnmuted(roomId: string, newState: RoomNotifState): Pr } else if (newState === RoomNotifState.AllMessagesLoud) { promises.push(cli.addPushRule('global', PushRuleKind.RoomSpecific, roomId, { actions: [ - 'notify', + PushRuleActionName.Notify, { - set_tweak: 'sound', + set_tweak: TweakName.Sound, value: 'default', }, ], diff --git a/src/Rooms.ts b/src/Rooms.ts index f6afbfb3af2..a7b562c06a6 100644 --- a/src/Rooms.ts +++ b/src/Rooms.ts @@ -53,7 +53,7 @@ export function looksLikeDirectMessageRoom(room: Room, myUserId: string): boolea // Used to split rooms via tags const tagNames = Object.keys(room.tags); // Used for 1:1 direct chats - // Show 1:1 chats in seperate "Direct Messages" section as long as they haven't + // Show 1:1 chats in separate "Direct Messages" section as long as they haven't // been moved to a different tag section const totalMemberCount = room.currentState.getJoinedMemberCount() + room.currentState.getInvitedMemberCount(); diff --git a/src/SecurityManager.ts b/src/SecurityManager.ts index f3d254d0590..c67e8ec8d96 100644 --- a/src/SecurityManager.ts +++ b/src/SecurityManager.ts @@ -83,9 +83,11 @@ async function confirmToDismiss(): Promise { return !sure; } +type KeyParams = { passphrase: string, recoveryKey: string }; + function makeInputToKey( keyInfo: ISecretStorageKeyInfo, -): (keyParams: { passphrase: string, recoveryKey: string }) => Promise { +): (keyParams: KeyParams) => Promise { return async ({ passphrase, recoveryKey }) => { if (passphrase) { return deriveKey( @@ -101,11 +103,10 @@ function makeInputToKey( async function getSecretStorageKey( { keys: keyInfos }: { keys: Record }, - ssssItemName, ): Promise<[string, Uint8Array]> { const cli = MatrixClientPeg.get(); let keyId = await cli.getDefaultSecretStorageKeyId(); - let keyInfo; + let keyInfo: ISecretStorageKeyInfo; if (keyId) { // use the default SSSS key if set keyInfo = keyInfos[keyId]; @@ -154,9 +155,9 @@ async function getSecretStorageKey( /* props= */ { keyInfo, - checkPrivateKey: async (input) => { + checkPrivateKey: async (input: KeyParams) => { const key = await inputToKey(input); - return await MatrixClientPeg.get().checkSecretStorageKey(key, keyInfo); + return MatrixClientPeg.get().checkSecretStorageKey(key, keyInfo); }, }, /* className= */ null, @@ -171,11 +172,11 @@ async function getSecretStorageKey( }, }, ); - const [input] = await finished; - if (!input) { + const [keyParams] = await finished; + if (!keyParams) { throw new AccessCancelledError(); } - const key = await inputToKey(input); + const key = await inputToKey(keyParams); // Save to cache to avoid future prompts in the current session cacheSecretStorageKey(keyId, keyInfo, key); diff --git a/src/TextForEvent.tsx b/src/TextForEvent.tsx index bb6d9eab3c7..10530d7c9b4 100644 --- a/src/TextForEvent.tsx +++ b/src/TextForEvent.tsx @@ -29,7 +29,6 @@ import { M_POLL_END, PollStartEvent, } from "matrix-events-sdk"; -import { M_LOCATION } from "matrix-js-sdk/src/@types/location"; import { _t } from './languageHandler'; import * as Roles from './Roles'; @@ -46,6 +45,7 @@ import AccessibleButton from './components/views/elements/AccessibleButton'; import RightPanelStore from './stores/right-panel/RightPanelStore'; import UserIdentifierCustomisations from './customisations/UserIdentifier'; import { ViewRoomPayload } from "./dispatcher/payloads/ViewRoomPayload"; +import { isLocationEvent } from './utils/EventUtils'; export function getSenderName(event: MatrixEvent): string { return event.sender?.name ?? event.getSender() ?? _t("Someone"); @@ -224,7 +224,7 @@ const onViewJoinRuleSettingsClick = () => { }); }; -function textForJoinRulesEvent(ev: MatrixEvent, allowJSX: boolean): () => string | JSX.Element | null { +function textForJoinRulesEvent(ev: MatrixEvent, allowJSX: boolean): () => Renderable { const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); switch (ev.getContent().join_rule) { case JoinRule.Public: @@ -281,7 +281,7 @@ function textForServerACLEvent(ev: MatrixEvent): () => string | null { const prev = { deny: Array.isArray(prevContent.deny) ? prevContent.deny : [], allow: Array.isArray(prevContent.allow) ? prevContent.allow : [], - allow_ip_literals: !(prevContent.allow_ip_literals === false), + allow_ip_literals: prevContent.allow_ip_literals !== false, }; let getText = null; @@ -305,11 +305,7 @@ function textForServerACLEvent(ev: MatrixEvent): () => string | null { } function textForMessageEvent(ev: MatrixEvent): () => string | null { - const type = ev.getType(); - const content = ev.getContent(); - const msgtype = content.msgtype; - - if (M_LOCATION.matches(type) || M_LOCATION.matches(msgtype)) { + if (isLocationEvent(ev)) { return textForLocationEvent(ev); } @@ -372,13 +368,15 @@ function textForCanonicalAliasEvent(ev: MatrixEvent): () => string | null { addresses: addedAltAliases.join(", "), count: addedAltAliases.length, }); - } if (removedAltAliases.length && !addedAltAliases.length) { + } + if (removedAltAliases.length && !addedAltAliases.length) { return () => _t('%(senderName)s removed the alternative addresses %(addresses)s for this room.', { senderName, addresses: removedAltAliases.join(", "), count: removedAltAliases.length, }); - } if (removedAltAliases.length && addedAltAliases.length) { + } + if (removedAltAliases.length && addedAltAliases.length) { return () => _t('%(senderName)s changed the alternative addresses for this room.', { senderName, }); @@ -504,7 +502,7 @@ const onPinnedMessagesClick = (): void => { RightPanelStore.instance.setCard({ phase: RightPanelPhases.PinnedMessages }, false); }; -function textForPinnedEvent(event: MatrixEvent, allowJSX: boolean): () => string | JSX.Element | null { +function textForPinnedEvent(event: MatrixEvent, allowJSX: boolean): () => Renderable { if (!SettingsStore.getValue("feature_pinning")) return null; const senderName = getSenderName(event); const roomId = event.getRoomId(); @@ -758,10 +756,12 @@ function textForPollEndEvent(event: MatrixEvent): () => string | null { }); } +type Renderable = string | JSX.Element | null; + interface IHandlers { [type: string]: (ev: MatrixEvent, allowJSX: boolean, showHiddenEvents?: boolean) => - (() => string | JSX.Element | null); + (() => Renderable); } const handlers: IHandlers = { diff --git a/src/accessibility/KeyboardShortcutUtils.ts b/src/accessibility/KeyboardShortcutUtils.ts index 434116d4303..1dff38cde34 100644 --- a/src/accessibility/KeyboardShortcutUtils.ts +++ b/src/accessibility/KeyboardShortcutUtils.ts @@ -15,7 +15,7 @@ limitations under the License. */ import { KeyCombo } from "../KeyBindingsManager"; -import { isMac, Key } from "../Keyboard"; +import { IS_MAC, Key } from "../Keyboard"; import { _t, _td } from "../languageHandler"; import PlatformPeg from "../PlatformPeg"; import SettingsStore from "../settings/SettingsStore"; @@ -96,7 +96,7 @@ export const getKeyboardShortcuts = (): IKeyboardShortcuts => { return Object.keys(KEYBOARD_SHORTCUTS).filter((k: KeyBindingAction) => { if (KEYBOARD_SHORTCUTS[k]?.controller?.settingDisabled) return false; - if (MAC_ONLY_SHORTCUTS.includes(k) && !isMac) return false; + if (MAC_ONLY_SHORTCUTS.includes(k) && !IS_MAC) return false; if (DESKTOP_SHORTCUTS.includes(k) && !overrideBrowserShortcuts) return false; return true; diff --git a/src/accessibility/KeyboardShortcuts.ts b/src/accessibility/KeyboardShortcuts.ts index 97e428d2a0f..50992eb299a 100644 --- a/src/accessibility/KeyboardShortcuts.ts +++ b/src/accessibility/KeyboardShortcuts.ts @@ -16,7 +16,7 @@ limitations under the License. */ import { _td } from "../languageHandler"; -import { isMac, Key } from "../Keyboard"; +import { IS_MAC, Key } from "../Keyboard"; import { IBaseSetting } from "../settings/Settings"; import IncompatibleController from "../settings/controllers/IncompatibleController"; import { KeyCombo } from "../KeyBindingsManager"; @@ -200,7 +200,7 @@ export const KEY_ICON: Record = { [Key.ARROW_LEFT]: "←", [Key.ARROW_RIGHT]: "→", }; -if (isMac) { +if (IS_MAC) { KEY_ICON[Key.META] = "⌘"; KEY_ICON[Key.ALT] = "⌥"; } @@ -528,8 +528,8 @@ export const KEYBOARD_SHORTCUTS: IKeyboardShortcuts = { [KeyBindingAction.GoToHome]: { default: { ctrlOrCmdKey: true, - altKey: !isMac, - shiftKey: isMac, + altKey: !IS_MAC, + shiftKey: IS_MAC, key: Key.H, }, displayName: _td("Go to Home View"), @@ -621,25 +621,25 @@ export const KEYBOARD_SHORTCUTS: IKeyboardShortcuts = { }, [KeyBindingAction.EditRedo]: { default: { - key: isMac ? Key.Z : Key.Y, + key: IS_MAC ? Key.Z : Key.Y, ctrlOrCmdKey: true, - shiftKey: isMac, + shiftKey: IS_MAC, }, displayName: _td("Redo edit"), }, [KeyBindingAction.PreviousVisitedRoomOrSpace]: { default: { - metaKey: isMac, - altKey: !isMac, - key: isMac ? Key.SQUARE_BRACKET_LEFT : Key.ARROW_LEFT, + metaKey: IS_MAC, + altKey: !IS_MAC, + key: IS_MAC ? Key.SQUARE_BRACKET_LEFT : Key.ARROW_LEFT, }, displayName: _td("Previous recently visited room or space"), }, [KeyBindingAction.NextVisitedRoomOrSpace]: { default: { - metaKey: isMac, - altKey: !isMac, - key: isMac ? Key.SQUARE_BRACKET_RIGHT : Key.ARROW_RIGHT, + metaKey: IS_MAC, + altKey: !IS_MAC, + key: IS_MAC ? Key.SQUARE_BRACKET_RIGHT : Key.ARROW_RIGHT, }, displayName: _td("Next recently visited room or space"), }, diff --git a/src/async-components/views/dialogs/security/CreateKeyBackupDialog.tsx b/src/async-components/views/dialogs/security/CreateKeyBackupDialog.tsx index 560eaadab1d..707e3cbaf7e 100644 --- a/src/async-components/views/dialogs/security/CreateKeyBackupDialog.tsx +++ b/src/async-components/views/dialogs/security/CreateKeyBackupDialog.tsx @@ -291,7 +291,7 @@ export default class CreateKeyBackupDialog extends React.PureComponent

{ _t("Unable to create key backup") }

-
- -
+ ; } else { switch (this.state.phase) { diff --git a/src/async-components/views/dialogs/security/CreateSecretStorageDialog.tsx b/src/async-components/views/dialogs/security/CreateSecretStorageDialog.tsx index 53df137f6d6..f58a8b2003d 100644 --- a/src/async-components/views/dialogs/security/CreateSecretStorageDialog.tsx +++ b/src/async-components/views/dialogs/security/CreateSecretStorageDialog.tsx @@ -276,7 +276,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent void): Promise => { if (this.state.canUploadKeysWithPasswordOnly && this.state.accountPassword) { - await makeRequest({ + makeRequest({ type: 'm.login.password', identifier: { type: 'm.id.user', @@ -649,7 +649,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent { - return await timeout( + return timeout( provider.getCompletions(query, selection, force, limit), null, PROVIDER_COMPLETION_TIMEOUT, diff --git a/src/boundThreepids.ts b/src/boundThreepids.ts index a703d10fd78..6421c1309aa 100644 --- a/src/boundThreepids.ts +++ b/src/boundThreepids.ts @@ -53,7 +53,7 @@ export async function getThreepidsWithBindStatus( } } catch (e) { // Ignore terms errors here and assume other flows handle this - if (!(e.errcode === "M_TERMS_NOT_SIGNED")) { + if (e.errcode !== "M_TERMS_NOT_SIGNED") { throw e; } } diff --git a/src/components/structures/ContextMenu.tsx b/src/components/structures/ContextMenu.tsx index 187e55cc392..695d6ec2a7b 100644 --- a/src/components/structures/ContextMenu.tsx +++ b/src/components/structures/ContextMenu.tsx @@ -157,12 +157,14 @@ export default class ContextMenu extends React.PureComponent { // XXX: This isn't pretty but the only way to allow opening a different context menu on right click whilst // a context menu and its click-guard are up without completely rewriting how the context menus work. setImmediate(() => { - const clickEvent = document.createEvent('MouseEvents'); - clickEvent.initMouseEvent( - 'contextmenu', true, true, window, 0, - 0, 0, x, y, false, false, - false, false, 0, null, - ); + const clickEvent = new MouseEvent("contextmenu", { + clientX: x, + clientY: y, + screenX: 0, + screenY: 0, + button: 0, // Left + relatedTarget: null, + }); document.elementFromPoint(x, y).dispatchEvent(clickEvent); }); } @@ -417,8 +419,8 @@ export type ToRightOf = { // Placement method for to position context menu to right of elementRect with chevronOffset export const toRightOf = (elementRect: Pick, chevronOffset = 12): ToRightOf => { - const left = elementRect.right + window.pageXOffset + 3; - let top = elementRect.top + (elementRect.height / 2) + window.pageYOffset; + const left = elementRect.right + window.scrollX + 3; + let top = elementRect.top + (elementRect.height / 2) + window.scrollY; top -= chevronOffset + 8; // where 8 is half the height of the chevron return { left, top, chevronOffset }; }; @@ -436,9 +438,9 @@ export const aboveLeftOf = ( ): AboveLeftOf => { const menuOptions: IPosition & { chevronFace: ChevronFace } = { chevronFace }; - const buttonRight = elementRect.right + window.pageXOffset; - const buttonBottom = elementRect.bottom + window.pageYOffset; - const buttonTop = elementRect.top + window.pageYOffset; + const buttonRight = elementRect.right + window.scrollX; + const buttonBottom = elementRect.bottom + window.scrollY; + const buttonTop = elementRect.top + window.scrollY; // Align the right edge of the menu to the right edge of the button menuOptions.right = UIStore.instance.windowWidth - buttonRight; // Align the menu vertically on whichever side of the button has more space available. @@ -460,9 +462,9 @@ export const aboveRightOf = ( ): AboveLeftOf => { const menuOptions: IPosition & { chevronFace: ChevronFace } = { chevronFace }; - const buttonLeft = elementRect.left + window.pageXOffset; - const buttonBottom = elementRect.bottom + window.pageYOffset; - const buttonTop = elementRect.top + window.pageYOffset; + const buttonLeft = elementRect.left + window.scrollX; + const buttonBottom = elementRect.bottom + window.scrollY; + const buttonTop = elementRect.top + window.scrollY; // Align the left edge of the menu to the left edge of the button menuOptions.left = buttonLeft; // Align the menu vertically on whichever side of the button has more space available. @@ -484,9 +486,9 @@ export const alwaysAboveLeftOf = ( ) => { const menuOptions: IPosition & { chevronFace: ChevronFace } = { chevronFace }; - const buttonRight = elementRect.right + window.pageXOffset; - const buttonBottom = elementRect.bottom + window.pageYOffset; - const buttonTop = elementRect.top + window.pageYOffset; + const buttonRight = elementRect.right + window.scrollX; + const buttonBottom = elementRect.bottom + window.scrollY; + const buttonTop = elementRect.top + window.scrollY; // Align the right edge of the menu to the right edge of the button menuOptions.right = UIStore.instance.windowWidth - buttonRight; // Align the menu vertically on whichever side of the button has more space available. @@ -508,8 +510,8 @@ export const alwaysAboveRightOf = ( ) => { const menuOptions: IPosition & { chevronFace: ChevronFace } = { chevronFace }; - const buttonLeft = elementRect.left + window.pageXOffset; - const buttonTop = elementRect.top + window.pageYOffset; + const buttonLeft = elementRect.left + window.scrollX; + const buttonTop = elementRect.top + window.scrollY; // Align the left edge of the menu to the left edge of the button menuOptions.left = buttonLeft; // Align the menu vertically above the menu diff --git a/src/components/structures/FilePanel.tsx b/src/components/structures/FilePanel.tsx index 986a2f80553..29b67706a45 100644 --- a/src/components/structures/FilePanel.tsx +++ b/src/components/structures/FilePanel.tsx @@ -27,7 +27,7 @@ import { logger } from "matrix-js-sdk/src/logger"; import { MatrixClientPeg } from '../../MatrixClientPeg'; import EventIndexPeg from "../../indexing/EventIndexPeg"; import { _t } from '../../languageHandler'; -import DesktopBuildsNotice, { WarningKind } from "../views/elements/DesktopBuildsNotice"; +import SearchWarning, { WarningKind } from "../views/elements/SearchWarning"; import BaseCard from "../views/right_panel/BaseCard"; import ResizeNotifier from '../../utils/ResizeNotifier'; import TimelinePanel from "./TimelinePanel"; @@ -277,7 +277,7 @@ class FilePanel extends React.Component { sensor={this.card.current} onMeasurement={this.onMeasurement} /> - + { hasAvatarLabel={_tDom("Great, that'll help people know it's you")} noAvatarLabel={_tDom("Add a photo so people know it's you.")} setAvatarUrl={url => cli.setAvatarUrl(url)} + isUserAvatar + onClick={ev => PosthogTrackers.trackInteraction("WebHomeMiniAvatarUploadButton", ev)} > = ({ justRegistered = false }) => { } let introSection; - if (justRegistered) { + if (justRegistered || !!OwnProfileStore.instance.getHttpAvatarUrl(AVATAR_SIZE)) { introSection = ; } else { const brandingConfig = SdkConfig.getObject("branding"); diff --git a/src/components/structures/InteractiveAuth.tsx b/src/components/structures/InteractiveAuth.tsx index 94c54b32e32..b42e65d57fb 100644 --- a/src/components/structures/InteractiveAuth.tsx +++ b/src/components/structures/InteractiveAuth.tsx @@ -269,6 +269,7 @@ export default class InteractiveAuthComponent extends React.Component { showNotificationsToast(false); } - if (!localStorage.getItem("mx_seen_feature_thread_experimental")) { - setTimeout(() => { - if (SettingsStore.getValue("feature_thread") && SdkConfig.get("show_labs_settings")) { - Modal.createDialog(InfoDialog, { - title: _t("Threads Approaching Beta 🎉"), - description: <> -

- { _t("We're getting closer to releasing a public Beta for Threads.") } -

-

- { _t("As we prepare for it, we need to make some changes: threads created " - + "before this point will be displayed as regular replies.", - {}, { - "strong": sub => { sub }, - }) } -

-

- { _t("This will be a one-off transition, as threads are now part " - + "of the Matrix specification.") } -

- , - button: _t("Got it"), - onFinished: () => { - localStorage.setItem("mx_seen_feature_thread_experimental", "true"); - }, - }); - } - }, 1 * 60 * 1000); // show after 1 minute to not overload user on launch - } - // SC: No search beta toast // eslint-disable-next-line no-constant-condition if (!localStorage.getItem("mx_seen_feature_spotlight_toast") && false) { diff --git a/src/components/structures/MessagePanel.tsx b/src/components/structures/MessagePanel.tsx index 2b0618e9809..3a61c0cdec4 100644 --- a/src/components/structures/MessagePanel.tsx +++ b/src/components/structures/MessagePanel.tsx @@ -23,6 +23,7 @@ import { MatrixEvent } from 'matrix-js-sdk/src/models/event'; import { Relations } from "matrix-js-sdk/src/models/relations"; import { logger } from 'matrix-js-sdk/src/logger'; import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state"; +import { ReceiptType } from "matrix-js-sdk/src/@types/read_receipts"; import { M_BEACON_INFO } from 'matrix-js-sdk/src/@types/beacon'; import shouldHideEvent from '../../shouldHideEvent'; @@ -56,6 +57,7 @@ import { getEventDisplayInfo } from "../../utils/EventRenderingUtils"; import { IReadReceiptInfo } from "../views/rooms/ReadReceiptMarker"; import { haveRendererForEvent } from "../../events/EventTileFactory"; import { editorRoomKey } from "../../Editing"; +import { hasThreadSummary } from "../../utils/EventUtils"; const CONTINUATION_MAX_INTERVAL = 5 * 60 * 1000; // 5 minutes const continuedTypes = [EventType.Sticker, EventType.RoomMessage]; @@ -96,7 +98,7 @@ export function shouldFormContinuation( // Thread summaries in the main timeline should break up a continuation on both sides if (threadsEnabled && - (mxEvent.isThreadRoot || prevEvent.isThreadRoot) && + (hasThreadSummary(mxEvent) || hasThreadSummary(prevEvent)) && timelineRenderingType !== TimelineRenderingType.Thread ) { return false; @@ -860,7 +862,7 @@ export default class MessagePanel extends React.Component { } const receipts: IReadReceiptProps[] = []; room.getReceiptsForEvent(event).forEach((r) => { - if (!r.userId || r.type !== "m.read" || r.userId === myUserId) { + if (!r.userId || ![ReceiptType.Read, ReceiptType.ReadPrivate].includes(r.type) || r.userId === myUserId) { return; // ignore non-read receipts and receipts from self. } if (MatrixClientPeg.get().isUserIgnored(r.userId)) { diff --git a/src/components/structures/RoomDirectory.tsx b/src/components/structures/RoomDirectory.tsx index 99aeb6f5478..aa8f38556a7 100644 --- a/src/components/structures/RoomDirectory.tsx +++ b/src/components/structures/RoomDirectory.tsx @@ -26,7 +26,7 @@ import dis from "../../dispatcher/dispatcher"; import Modal from "../../Modal"; import { _t } from '../../languageHandler'; import SdkConfig from '../../SdkConfig'; -import { instanceForInstanceId, protocolNameForInstanceId } from '../../utils/DirectoryUtils'; +import { instanceForInstanceId, protocolNameForInstanceId, ALL_ROOMS, Protocols } from '../../utils/DirectoryUtils'; import Analytics from '../../Analytics'; import NetworkDropdown from "../views/directory/NetworkDropdown"; import SettingsStore from "../../settings/SettingsStore"; @@ -43,7 +43,6 @@ import PosthogTrackers from "../../PosthogTrackers"; import { PublicRoomTile } from "../views/rooms/PublicRoomTile"; import { getFieldsForThirdPartyLocation, joinRoomByAlias, showRoom } from "../../utils/rooms"; import { GenericError } from "../../utils/error"; -import { ALL_ROOMS, Protocols } from "../../utils/DirectoryUtils"; const LAST_SERVER_KEY = "mx_last_room_directory_server"; const LAST_INSTANCE_KEY = "mx_last_room_directory_instance"; diff --git a/src/components/structures/RoomSearch.tsx b/src/components/structures/RoomSearch.tsx index 94212927641..77faf0f9298 100644 --- a/src/components/structures/RoomSearch.tsx +++ b/src/components/structures/RoomSearch.tsx @@ -28,7 +28,7 @@ import { NameFilterCondition } from "../../stores/room-list/filters/NameFilterCo import { getKeyBindingsManager } from "../../KeyBindingsManager"; import SpaceStore from "../../stores/spaces/SpaceStore"; import { UPDATE_SELECTED_SPACE } from "../../stores/spaces"; -import { isMac, Key } from "../../Keyboard"; +import { IS_MAC, Key } from "../../Keyboard"; import SettingsStore from "../../settings/SettingsStore"; import Modal from "../../Modal"; import SpotlightDialog from "../views/dialogs/SpotlightDialog"; @@ -206,7 +206,7 @@ export default class RoomSearch extends React.PureComponent { ); let shortcutPrompt =
- { isMac ? "⌘ K" : _t(ALTERNATE_KEY_NAME[Key.CONTROL]) + " K" } + { IS_MAC ? "⌘ K" : _t(ALTERNATE_KEY_NAME[Key.CONTROL]) + " K" }
; if (this.props.isMinimized) { diff --git a/src/components/structures/RoomStatusBar.tsx b/src/components/structures/RoomStatusBar.tsx index 94b9905becc..a89f205a88e 100644 --- a/src/components/structures/RoomStatusBar.tsx +++ b/src/components/structures/RoomStatusBar.tsx @@ -223,7 +223,7 @@ export default class RoomStatusBar extends React.PureComponent { "Please contact your service administrator to continue using the service.", ), 'hs_disabled': _td( - "Your message wasn't sent because this homeserver has been blocked by it's administrator. " + + "Your message wasn't sent because this homeserver has been blocked by its administrator. " + "Please contact your service administrator to continue using the service.", ), '': _td( diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index c05250492c3..c87e168456f 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -36,6 +36,7 @@ import { MatrixError } from 'matrix-js-sdk/src/http-api'; import { ClientEvent } from "matrix-js-sdk/src/client"; import { CryptoEvent } from "matrix-js-sdk/src/crypto"; import { THREAD_RELATION_TYPE } from 'matrix-js-sdk/src/models/thread'; +import { HistoryVisibility } from 'matrix-js-sdk/src/@types/partials'; import shouldHideEvent from '../../shouldHideEvent'; import { _t } from '../../languageHandler'; @@ -65,6 +66,7 @@ import ScrollPanel from "./ScrollPanel"; import TimelinePanel from "./TimelinePanel"; import ErrorBoundary from "../views/elements/ErrorBoundary"; import RoomPreviewBar from "../views/rooms/RoomPreviewBar"; +import RoomPreviewCard from "../views/rooms/RoomPreviewCard"; import SearchBar, { SearchScope } from "../views/rooms/SearchBar"; import RoomUpgradeWarningBar from "../views/rooms/RoomUpgradeWarningBar"; import AuxPanel from "../views/rooms/AuxPanel"; @@ -167,7 +169,6 @@ export interface IRoomState { searchHighlights?: string[]; searchInProgress?: boolean; callState?: CallState; - guestsCanJoin: boolean; canPeek: boolean; showApps: boolean; isPeeking: boolean; @@ -255,7 +256,6 @@ export class RoomView extends React.Component { numUnreadMessages: 0, searchResults: null, callState: null, - guestsCanJoin: false, canPeek: false, showApps: false, isPeeking: false, @@ -293,11 +293,9 @@ export class RoomView extends React.Component { context.on(ClientEvent.Room, this.onRoom); context.on(RoomEvent.Timeline, this.onRoomTimeline); context.on(RoomEvent.Name, this.onRoomName); - context.on(RoomEvent.AccountData, this.onRoomAccountData); context.on(RoomStateEvent.Events, this.onRoomStateEvents); context.on(RoomStateEvent.Update, this.onRoomStateUpdate); context.on(RoomEvent.MyMembership, this.onMyMembership); - context.on(ClientEvent.AccountData, this.onAccountData); context.on(CryptoEvent.KeyBackupStatus, this.onKeyBackupStatus); context.on(CryptoEvent.DeviceVerificationChanged, this.onDeviceVerificationChanged); context.on(CryptoEvent.UserTrustStatusChanged, this.onUserVerificationChanged); @@ -356,6 +354,8 @@ export class RoomView extends React.Component { SettingsStore.watchSetting("showHiddenEventsInTimeline", null, (...[,,, value]) => this.setState({ showHiddenEvents: value as boolean }), ), + SettingsStore.watchSetting("urlPreviewsEnabled", null, this.onUrlPreviewsEnabledChange), + SettingsStore.watchSetting("urlPreviewsEnabled_e2ee", null, this.onUrlPreviewsEnabledChange), ]; } @@ -762,11 +762,9 @@ export class RoomView extends React.Component { this.context.removeListener(ClientEvent.Room, this.onRoom); this.context.removeListener(RoomEvent.Timeline, this.onRoomTimeline); this.context.removeListener(RoomEvent.Name, this.onRoomName); - this.context.removeListener(RoomEvent.AccountData, this.onRoomAccountData); this.context.removeListener(RoomStateEvent.Events, this.onRoomStateEvents); this.context.removeListener(RoomEvent.MyMembership, this.onMyMembership); this.context.removeListener(RoomStateEvent.Update, this.onRoomStateUpdate); - this.context.removeListener(ClientEvent.AccountData, this.onAccountData); this.context.removeListener(CryptoEvent.KeyBackupStatus, this.onKeyBackupStatus); this.context.removeListener(CryptoEvent.DeviceVerificationChanged, this.onDeviceVerificationChanged); this.context.removeListener(CryptoEvent.UserTrustStatusChanged, this.onUserVerificationChanged); @@ -1130,19 +1128,10 @@ export class RoomView extends React.Component { } private calculatePeekRules(room: Room) { - const guestAccessEvent = room.currentState.getStateEvents("m.room.guest_access", ""); - if (guestAccessEvent && guestAccessEvent.getContent().guest_access === "can_join") { - this.setState({ - guestsCanJoin: true, - }); - } - - const historyVisibility = room.currentState.getStateEvents("m.room.history_visibility", ""); - if (historyVisibility && historyVisibility.getContent().history_visibility === "world_readable") { - this.setState({ - canPeek: true, - }); - } + const historyVisibility = room.currentState.getStateEvents(EventType.RoomHistoryVisibility, ""); + this.setState({ + canPeek: historyVisibility?.getContent().history_visibility === HistoryVisibility.WorldReadable, + }); } private updatePreviewUrlVisibility({ roomId }: Room) { @@ -1212,10 +1201,8 @@ export class RoomView extends React.Component { this.setState({ e2eStatus }); } - private onAccountData = (event: MatrixEvent) => { - const type = event.getType(); - if ((type === "org.matrix.preview_urls" || type === "im.vector.web.settings") && this.state.room) { - // non-e2ee url previews are stored in legacy event type `org.matrix.room.preview_urls` + private onUrlPreviewsEnabledChange = () => { + if (this.state.room) { this.updatePreviewUrlVisibility(this.state.room); } @@ -1225,16 +1212,6 @@ export class RoomView extends React.Component { } }; - private onRoomAccountData = (event: MatrixEvent, room: Room) => { - if (room.roomId == this.state.roomId) { - const type = event.getType(); - if (type === "org.matrix.room.preview_urls" || type === "im.vector.web.settings") { - // non-e2ee url previews are stored in legacy event type `org.matrix.room.preview_urls` - this.updatePreviewUrlVisibility(room); - } - } - }; - private onRoomStateEvents = (ev: MatrixEvent, state: RoomState) => { // ignore if we don't have a room yet if (!this.state.room || this.state.room.roomId !== state.roomId) return; @@ -1937,6 +1914,21 @@ export class RoomView extends React.Component { } const myMembership = this.state.room.getMyMembership(); + if ( + this.state.room.isElementVideoRoom() && + !(SettingsStore.getValue("feature_video_rooms") && myMembership === "join") + ) { + return +
+ +
; +
; + } + // SpaceRoomView handles invites itself if (myMembership === "invite" && !this.state.room.isSpaceRoom()) { if (this.state.joining || this.state.rejecting) { diff --git a/src/components/structures/ScrollPanel.tsx b/src/components/structures/ScrollPanel.tsx index f2595471c96..5db5e6daad9 100644 --- a/src/components/structures/ScrollPanel.tsx +++ b/src/components/structures/ScrollPanel.tsx @@ -17,14 +17,13 @@ limitations under the License. import React, { createRef, CSSProperties, ReactNode, KeyboardEvent } from "react"; import { logger } from "matrix-js-sdk/src/logger"; +import SettingsStore from '../../settings/SettingsStore'; import Timer from '../../utils/Timer'; import AutoHideScrollbar from "./AutoHideScrollbar"; import { getKeyBindingsManager } from "../../KeyBindingsManager"; import ResizeNotifier from "../../utils/ResizeNotifier"; import { KeyBindingAction } from "../../accessibility/KeyboardShortcuts"; -const DEBUG_SCROLL = false; - // The amount of extra scroll distance to allow prior to unfilling. // See getExcessHeight. const UNPAGINATION_PADDING = 6000; @@ -36,13 +35,11 @@ const UNFILL_REQUEST_DEBOUNCE_MS = 200; // much while the content loads. const PAGE_SIZE = 400; -let debuglog; -if (DEBUG_SCROLL) { - // using bind means that we get to keep useful line numbers in the console - debuglog = logger.log.bind(console, "ScrollPanel debuglog:"); -} else { - debuglog = function() {}; -} +const debuglog = (...args: any[]) => { + if (SettingsStore.getValue("debug_scroll_panel")) { + logger.log.call(console, "ScrollPanel debuglog:", ...args); + } +}; interface IProps { /* stickyBottom: if set to true, then once the user hits the bottom of diff --git a/src/components/structures/SpaceHierarchy.tsx b/src/components/structures/SpaceHierarchy.tsx index 607b2e4f93e..9d3bf54730b 100644 --- a/src/components/structures/SpaceHierarchy.tsx +++ b/src/components/structures/SpaceHierarchy.tsx @@ -36,7 +36,6 @@ import classNames from "classnames"; import { sortBy, uniqBy } from "lodash"; import { GuestAccess, HistoryVisibility } from "matrix-js-sdk/src/@types/partials"; -import dis from "../../dispatcher/dispatcher"; import defaultDispatcher from "../../dispatcher/dispatcher"; import { _t } from "../../languageHandler"; import AccessibleButton, { ButtonEvent } from "../views/elements/AccessibleButton"; @@ -65,6 +64,7 @@ import { ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload"; import { JoinRoomReadyPayload } from "../../dispatcher/payloads/JoinRoomReadyPayload"; import { KeyBindingAction } from "../../accessibility/KeyboardShortcuts"; import { getKeyBindingsManager } from "../../KeyBindingsManager"; +import { Alignment } from "../views/elements/Tooltip"; interface IProps { space: Room; @@ -330,13 +330,13 @@ export const showRoom = (cli: MatrixClient, hierarchy: RoomHierarchy, roomId: st // fail earlier so they don't have to click back to the directory. if (cli.isGuest()) { if (!room.world_readable && !room.guest_can_join) { - dis.dispatch({ action: "require_registration" }); + defaultDispatcher.dispatch({ action: "require_registration" }); return; } } const roomAlias = getDisplayAliasForRoom(room) || undefined; - dis.dispatch({ + defaultDispatcher.dispatch({ action: Action.ViewRoom, should_peek: true, room_alias: roomAlias, @@ -356,7 +356,7 @@ export const joinRoom = (cli: MatrixClient, hierarchy: RoomHierarchy, roomId: st // Don't let the user view a room they won't be able to either peek or join: // fail earlier so they don't have to click back to the directory. if (cli.isGuest()) { - dis.dispatch({ action: "require_registration" }); + defaultDispatcher.dispatch({ action: "require_registration" }); return; } @@ -365,7 +365,7 @@ export const joinRoom = (cli: MatrixClient, hierarchy: RoomHierarchy, roomId: st }); prom.then(() => { - dis.dispatch({ + defaultDispatcher.dispatch({ action: Action.JoinRoomReady, roomId, metricsTrigger: "SpaceHierarchy", @@ -569,7 +569,7 @@ const ManageButtons = ({ hierarchy, selected, setSelected, setError }: IManageBu const selectedRelations = Array.from(selected.keys()).flatMap(parentId => { return [ ...selected.get(parentId).values(), - ].map(childId => [parentId, childId]) as [string, string][]; + ].map(childId => [parentId, childId]); }); const selectionAllSuggested = selectedRelations.every(([parentId, childId]) => { @@ -584,7 +584,7 @@ const ManageButtons = ({ hierarchy, selected, setSelected, setError }: IManageBu Button = AccessibleTooltipButton; props = { tooltip: _t("Select a room below first"), - yOffset: -40, + alignment: Alignment.Top, }; } diff --git a/src/components/structures/SpaceRoomView.tsx b/src/components/structures/SpaceRoomView.tsx index 1e9d5caa0cf..ff77789802f 100644 --- a/src/components/structures/SpaceRoomView.tsx +++ b/src/components/structures/SpaceRoomView.tsx @@ -1,5 +1,5 @@ /* -Copyright 2021 The Matrix.org Foundation C.I.C. +Copyright 2021-2022 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -14,39 +14,32 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { RefObject, useContext, useRef, useState } from "react"; import { EventType, RoomType } from "matrix-js-sdk/src/@types/event"; import { JoinRule, Preset } from "matrix-js-sdk/src/@types/partials"; -import { Room, RoomEvent } from "matrix-js-sdk/src/models/room"; import { logger } from "matrix-js-sdk/src/logger"; +import { Room, RoomEvent } from "matrix-js-sdk/src/models/room"; +import React, { RefObject, useCallback, useContext, useRef, useState } from "react"; import MatrixClientContext from "../../contexts/MatrixClientContext"; -import RoomAvatar from "../views/avatars/RoomAvatar"; -import { _t } from "../../languageHandler"; -import AccessibleButton from "../views/elements/AccessibleButton"; -import RoomName from "../views/elements/RoomName"; -import RoomTopic from "../views/elements/RoomTopic"; -import InlineSpinner from "../views/elements/InlineSpinner"; -import { inviteMultipleToRoom, showRoomInviteDialog } from "../../RoomInvite"; -import { useRoomMembers } from "../../hooks/useRoomMembers"; -import { useFeatureEnabled } from "../../hooks/useSettings"; import createRoom, { IOpts } from "../../createRoom"; -import Field from "../views/elements/Field"; -import { useTypedEventEmitter } from "../../hooks/useEventEmitter"; -import withValidation from "../views/elements/Validation"; -import * as Email from "../../email"; -import defaultDispatcher from "../../dispatcher/dispatcher"; -import dis from "../../dispatcher/dispatcher"; +import { shouldShowComponent } from "../../customisations/helpers/UIComponents"; import { Action } from "../../dispatcher/actions"; -import ResizeNotifier from "../../utils/ResizeNotifier"; -import MainSplit from './MainSplit'; -import ErrorBoundary from "../views/elements/ErrorBoundary"; +import defaultDispatcher from "../../dispatcher/dispatcher"; import { ActionPayload } from "../../dispatcher/payloads"; -import RightPanel from "./RightPanel"; +import { ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload"; +import * as Email from "../../email"; +import { useEventEmitterState } from "../../hooks/useEventEmitter"; +import { useMyRoomMembership } from "../../hooks/useRoomMembers"; +import { useFeatureEnabled } from "../../hooks/useSettings"; +import { useStateArray } from "../../hooks/useStateArray"; +import { _t } from "../../languageHandler"; +import PosthogTrackers from "../../PosthogTrackers"; +import { inviteMultipleToRoom, showRoomInviteDialog } from "../../RoomInvite"; +import { UIComponent } from "../../settings/UIFeature"; +import { UPDATE_EVENT } from "../../stores/AsyncStore"; import RightPanelStore from "../../stores/right-panel/RightPanelStore"; import { RightPanelPhases } from "../../stores/right-panel/RightPanelStorePhases"; -import { useStateArray } from "../../hooks/useStateArray"; -import SpacePublicShare from "../views/spaces/SpacePublicShare"; +import ResizeNotifier from "../../utils/ResizeNotifier"; import { shouldShowSpaceInvite, shouldShowSpaceSettings, @@ -56,31 +49,33 @@ import { showSpaceInvite, showSpaceSettings, } from "../../utils/space"; -import SpaceHierarchy, { showRoom } from "./SpaceHierarchy"; -import MemberAvatar from "../views/avatars/MemberAvatar"; -import RoomFacePile from "../views/elements/RoomFacePile"; +import RoomAvatar from "../views/avatars/RoomAvatar"; +import { BetaPill } from "../views/beta/BetaCard"; +import IconizedContextMenu, { + IconizedContextMenuOption, + IconizedContextMenuOptionList, +} from "../views/context_menus/IconizedContextMenu"; import { AddExistingToSpace, defaultDmsRenderer, defaultRoomsRenderer, } from "../views/dialogs/AddExistingToSpaceDialog"; -import { ChevronFace, ContextMenuButton, useContextMenu } from "./ContextMenu"; -import IconizedContextMenu, { - IconizedContextMenuOption, - IconizedContextMenuOptionList, -} from "../views/context_menus/IconizedContextMenu"; +import AccessibleButton from "../views/elements/AccessibleButton"; import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton"; -import { BetaPill } from "../views/beta/BetaCard"; -import { EffectiveMembership, getEffectiveMembership } from "../../utils/membership"; +import ErrorBoundary from "../views/elements/ErrorBoundary"; +import Field from "../views/elements/Field"; +import RoomFacePile from "../views/elements/RoomFacePile"; +import RoomName from "../views/elements/RoomName"; +import RoomTopic from "../views/elements/RoomTopic"; +import withValidation from "../views/elements/Validation"; +import RoomInfoLine from "../views/rooms/RoomInfoLine"; +import RoomPreviewCard from "../views/rooms/RoomPreviewCard"; import { SpaceFeedbackPrompt } from "../views/spaces/SpaceCreateMenu"; -import { useAsyncMemo } from "../../hooks/useAsyncMemo"; -import { useDispatcher } from "../../hooks/useDispatcher"; -import { useRoomState } from "../../hooks/useRoomState"; -import { shouldShowComponent } from "../../customisations/helpers/UIComponents"; -import { UIComponent } from "../../settings/UIFeature"; -import { UPDATE_EVENT } from "../../stores/AsyncStore"; -import PosthogTrackers from "../../PosthogTrackers"; -import { ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload"; +import SpacePublicShare from "../views/spaces/SpacePublicShare"; +import { ChevronFace, ContextMenuButton, useContextMenu } from "./ContextMenu"; +import MainSplit from './MainSplit'; +import RightPanel from "./RightPanel"; +import SpaceHierarchy, { showRoom } from "./SpaceHierarchy"; interface IProps { space: Room; @@ -107,205 +102,6 @@ enum Phase { PrivateExistingRooms, } -const RoomMemberCount = ({ room, children }) => { - const members = useRoomMembers(room); - const count = members.length; - - if (children) return children(count); - return count; -}; - -const useMyRoomMembership = (room: Room) => { - const [membership, setMembership] = useState(room.getMyMembership()); - useTypedEventEmitter(room, RoomEvent.MyMembership, () => { - setMembership(room.getMyMembership()); - }); - return membership; -}; - -const SpaceInfo = ({ space }: { space: Room }) => { - // summary will begin as undefined whilst loading and go null if it fails to load or we are not invited. - const summary = useAsyncMemo(async () => { - if (space.getMyMembership() !== "invite") return null; - try { - return space.client.getRoomSummary(space.roomId); - } catch (e) { - return null; - } - }, [space]); - const joinRule = useRoomState(space, state => state.getJoinRule()); - const membership = useMyRoomMembership(space); - - let visibilitySection; - if (joinRule === JoinRule.Public) { - visibilitySection = - { _t("Public space") } - ; - } else { - visibilitySection = - { _t("Private space") } - ; - } - - let memberSection; - if (membership === "invite" && summary) { - // Don't trust local state and instead use the summary API - memberSection = - { _t("%(count)s members", { count: summary.num_joined_members }) } - ; - } else if (summary !== undefined) { // summary is not still loading - memberSection = - { (count) => count > 0 ? ( - { - RightPanelStore.instance.setCard({ phase: RightPanelPhases.SpaceMemberList }); - }} - > - { _t("%(count)s members", { count }) } - - ) : null } - ; - } - - return
- { visibilitySection } - { memberSection } -
; -}; - -interface ISpacePreviewProps { - space: Room; - onJoinButtonClicked(): void; - onRejectButtonClicked(): void; -} - -const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }: ISpacePreviewProps) => { - const cli = useContext(MatrixClientContext); - const myMembership = useMyRoomMembership(space); - useDispatcher(defaultDispatcher, payload => { - if (payload.action === Action.JoinRoomError && payload.roomId === space.roomId) { - setBusy(false); // stop the spinner, join failed - } - }); - - const [busy, setBusy] = useState(false); - - const joinRule = useRoomState(space, state => state.getJoinRule()); - const cannotJoin = getEffectiveMembership(myMembership) === EffectiveMembership.Leave - && joinRule !== JoinRule.Public; - - let inviterSection; - let joinButtons; - if (myMembership === "join") { - // XXX remove this when spaces leaves Beta - joinButtons = ( - { - dis.dispatch({ - action: "leave_room", - room_id: space.roomId, - }); - }} - > - { _t("Leave") } - - ); - } else if (myMembership === "invite") { - const inviteSender = space.getMember(cli.getUserId())?.events.member?.getSender(); - const inviter = inviteSender && space.getMember(inviteSender); - - if (inviteSender) { - inviterSection =
- -
-
- { _t(" invites you", {}, { - inviter: () => { inviter?.name || inviteSender }, - }) } -
- { inviter ?
- { inviteSender } -
: null } -
-
; - } - - joinButtons = <> - { - setBusy(true); - onRejectButtonClicked(); - }} - > - { _t("Reject") } - - { - setBusy(true); - onJoinButtonClicked(); - }} - > - { _t("Accept") } - - ; - } else { - joinButtons = ( - { - onJoinButtonClicked(); - if (!cli.isGuest()) { - // user will be shown a modal that won't fire a room join error - setBusy(true); - } - }} - disabled={cannotJoin} - > - { _t("Join") } - - ); - } - - if (busy) { - joinButtons = ; - } - - let footer; - if (cannotJoin) { - footer =
- { _t("To view %(spaceName)s, you need an invite", { - spaceName: space.name, - }) } -
; - } - - return
- { inviterSection } - -

- -

- - - { (topic, ref) => -
- { topic } -
- } -
- { space.getJoinRule() === "public" && } -
- { joinButtons } -
- { footer } -
; -}; - const SpaceLandingAddButton = ({ space }) => { const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu(); const canCreateRoom = shouldShowComponent(UIComponent.CreateRooms); @@ -316,8 +112,8 @@ const SpaceLandingAddButton = ({ space }) => { if (menuDisplayed) { const rect = handle.current.getBoundingClientRect(); contextMenu = { const myMembership = useMyRoomMembership(space); const userId = cli.getUserId(); + const storeIsShowingSpaceMembers = useCallback( + () => RightPanelStore.instance.isOpenForRoom(space.roomId) + && RightPanelStore.instance.currentCardForRoom(space.roomId)?.phase === RightPanelPhases.SpaceMemberList, + [space.roomId], + ); + const isShowingMembers = useEventEmitterState(RightPanelStore.instance, UPDATE_EVENT, storeIsShowingSpaceMembers); + let inviteButton; if (shouldShowSpaceInvite(space) && shouldShowComponent(UIComponent.InviteUsers)) { inviteButton = ( @@ -452,20 +255,19 @@ const SpaceLanding = ({ space }: { space: Room }) => {
- +
- + { inviteButton } { settingsButton }
- - { (topic, ref) => ( -
- { topic } -
- ) } -
+ ; @@ -847,8 +649,8 @@ export default class SpaceRoomView extends React.PureComponent { if (this.state.myMembership === "join") { return ; } else { - return ; diff --git a/src/components/structures/ThreadPanel.tsx b/src/components/structures/ThreadPanel.tsx index 3729cfaeaf1..a84013bb221 100644 --- a/src/components/structures/ThreadPanel.tsx +++ b/src/components/structures/ThreadPanel.tsx @@ -39,6 +39,7 @@ import BetaFeedbackDialog from '../views/dialogs/BetaFeedbackDialog'; import { Action } from '../../dispatcher/actions'; import { UserTab } from '../views/dialogs/UserTab'; import dis from '../../dispatcher/dispatcher'; +import Spinner from "../views/elements/Spinner"; interface IProps { roomId: string; @@ -301,7 +302,9 @@ const ThreadPanel: React.FC = ({ permalinkCreator={permalinkCreator} disableGrouping={true} /> - :
+ :
+ +
} diff --git a/src/components/structures/ThreadView.tsx b/src/components/structures/ThreadView.tsx index 8a75b69a6bc..55dd32bd529 100644 --- a/src/components/structures/ThreadView.tsx +++ b/src/components/structures/ThreadView.tsx @@ -22,6 +22,7 @@ import { TimelineWindow } from 'matrix-js-sdk/src/timeline-window'; import { Direction } from 'matrix-js-sdk/src/models/event-timeline'; import { IRelationsRequestOpts } from 'matrix-js-sdk/src/@types/requests'; import classNames from "classnames"; +import { logger } from 'matrix-js-sdk/src/logger'; import BaseCard from "../views/right_panel/BaseCard"; import { RightPanelPhases } from "../../stores/right-panel/RightPanelStorePhases"; @@ -51,6 +52,7 @@ import Measured from '../views/elements/Measured'; import PosthogTrackers from "../../PosthogTrackers"; import { ButtonEvent } from "../views/elements/AccessibleButton"; import { RoomViewStore } from '../../stores/RoomViewStore'; +import Spinner from "../views/elements/Spinner"; interface IProps { room: Room; @@ -298,11 +300,53 @@ export default class ThreadView extends React.Component { const threadRelation = this.threadRelation; - const messagePanelClassNames = classNames( - "mx_RoomView_messagePanel", - { - "mx_GroupLayout": this.state.layout === Layout.Group, - }); + const messagePanelClassNames = classNames("mx_RoomView_messagePanel", { + "mx_GroupLayout": this.state.layout === Layout.Group, + }); + + let timeline: JSX.Element; + if (this.state.thread) { + if (this.props.initialEvent && this.props.initialEvent.getRoomId() !== this.state.thread.roomId) { + logger.warn("ThreadView attempting to render TimelinePanel with mismatched initialEvent", + this.state.thread.roomId, + this.props.initialEvent.getRoomId(), + this.props.initialEvent.getId(), + ); + } + + timeline = <> + +
- { customStatusSection } { topSection } { primaryOptionList }
; @@ -479,11 +370,6 @@ export default class UserMenu extends React.Component { const displayName = OwnProfileStore.instance.displayName || userId; const avatarUrl = OwnProfileStore.instance.getHttpAvatarUrl(avatarSize); - let badge: JSX.Element; - if (this.state.dndEnabled) { - badge =
; - } - let name: JSX.Element; if (!this.props.isPanelCollapsed) { name =
@@ -498,9 +384,6 @@ export default class UserMenu extends React.Component { label={_t("User menu")} isExpanded={!!this.state.contextMenuPosition} onContextMenu={this.onContextMenu} - className={classNames({ - mx_UserMenu_cutout: badge, - })} >
{ resizeMethod="crop" className="mx_UserMenu_userAvatar_BaseAvatar" /> - { badge }
{ name } diff --git a/src/components/structures/VideoRoomView.tsx b/src/components/structures/VideoRoomView.tsx index 2695dafa798..5535c5c14f8 100644 --- a/src/components/structures/VideoRoomView.tsx +++ b/src/components/structures/VideoRoomView.tsx @@ -14,34 +14,59 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { FC, useContext, useState, useMemo } from "react"; +import React, { FC, useContext, useState, useMemo, useEffect } from "react"; import { logger } from "matrix-js-sdk/src/logger"; import { Room } from "matrix-js-sdk/src/models/room"; import MatrixClientContext from "../../contexts/MatrixClientContext"; import { useEventEmitter } from "../../hooks/useEventEmitter"; -import { getVideoChannel } from "../../utils/VideoChannelUtils"; -import WidgetStore from "../../stores/WidgetStore"; +import WidgetUtils from "../../utils/WidgetUtils"; +import { addVideoChannel, getVideoChannel, fixStuckDevices } from "../../utils/VideoChannelUtils"; +import WidgetStore, { IApp } from "../../stores/WidgetStore"; import { UPDATE_EVENT } from "../../stores/AsyncStore"; import VideoChannelStore, { VideoChannelEvent } from "../../stores/VideoChannelStore"; import AppTile from "../views/elements/AppTile"; import VideoLobby from "../views/voip/VideoLobby"; -const VideoRoomView: FC<{ room: Room, resizing: boolean }> = ({ room, resizing }) => { +interface IProps { + room: Room; + resizing: boolean; +} + +const VideoRoomView: FC = ({ room, resizing }) => { const cli = useContext(MatrixClientContext); const store = VideoChannelStore.instance; // In case we mount before the WidgetStore knows about our Jitsi widget + const [widgetStoreReady, setWidgetStoreReady] = useState(Boolean(WidgetStore.instance.matrixClient)); const [widgetLoaded, setWidgetLoaded] = useState(false); useEventEmitter(WidgetStore.instance, UPDATE_EVENT, (roomId: string) => { - if (roomId === null || roomId === room.roomId) setWidgetLoaded(true); + if (roomId === null) setWidgetStoreReady(true); + if (roomId === null || roomId === room.roomId) { + setWidgetLoaded(Boolean(getVideoChannel(room.roomId))); + } }); - const app = useMemo(() => { - const app = getVideoChannel(room.roomId); - if (!app) logger.warn(`No video channel for room ${room.roomId}`); - return app; - }, [room, widgetLoaded]); // eslint-disable-line react-hooks/exhaustive-deps + const app: IApp = useMemo(() => { + if (widgetStoreReady) { + const app = getVideoChannel(room.roomId); + if (!app) { + logger.warn(`No video channel for room ${room.roomId}`); + // Since widgets in video rooms are mutable, we'll take this opportunity to + // reinstate the Jitsi widget in case another client removed it + if (WidgetUtils.canUserModifyWidgets(room.roomId)) { + addVideoChannel(room.roomId, room.name); + } + } + return app; + } + }, [room, widgetStoreReady, widgetLoaded]); // eslint-disable-line react-hooks/exhaustive-deps + + // We'll also take this opportunity to fix any stuck devices. + // The linter thinks that store.connected should be a dependency, but we explicitly + // *only* want this to happen at mount to avoid racing with normal device updates. + // eslint-disable-next-line react-hooks/exhaustive-deps + useEffect(() => { fixStuckDevices(room, store.connected); }, [room]); const [connected, setConnected] = useState(store.connected && store.roomId === room.roomId); useEventEmitter(store, VideoChannelEvent.Connect, () => setConnected(store.roomId === room.roomId)); diff --git a/src/components/structures/auth/Login.tsx b/src/components/structures/auth/Login.tsx index 297e233444e..e38fdb4180b 100644 --- a/src/components/structures/auth/Login.tsx +++ b/src/components/structures/auth/Login.tsx @@ -222,7 +222,7 @@ export default class LoginComponent extends React.PureComponent "This homeserver has hit its Monthly Active User limit.", ), 'hs_blocked': _td( - "This homeserver has been blocked by it's administrator.", + "This homeserver has been blocked by its administrator.", ), '': _td( "This homeserver has exceeded one of its resource limits.", diff --git a/src/components/structures/auth/Registration.tsx b/src/components/structures/auth/Registration.tsx index ff7e41b9d58..7515a4f0d90 100644 --- a/src/components/structures/auth/Registration.tsx +++ b/src/components/structures/auth/Registration.tsx @@ -15,7 +15,7 @@ limitations under the License. */ import { createClient } from 'matrix-js-sdk/src/matrix'; -import React, { ReactNode } from 'react'; +import React, { Fragment, ReactNode } from 'react'; import { MatrixClient } from "matrix-js-sdk/src/client"; import classNames from "classnames"; import { logger } from "matrix-js-sdk/src/logger"; @@ -36,6 +36,8 @@ import AuthBody from "../../views/auth/AuthBody"; import AuthHeader from "../../views/auth/AuthHeader"; import InteractiveAuth from "../InteractiveAuth"; import Spinner from "../../views/elements/Spinner"; +import { AuthHeaderDisplay } from './header/AuthHeaderDisplay'; +import { AuthHeaderProvider } from './header/AuthHeaderProvider'; interface IProps { serverConfig: ValidatedServerConfig; @@ -295,7 +297,7 @@ export default class Registration extends React.Component { response.data.admin_contact, { 'monthly_active_user': _td("This homeserver has hit its Monthly Active User limit."), - 'hs_blocked': _td("This homeserver has been blocked by it's administrator."), + 'hs_blocked': _td("This homeserver has been blocked by its administrator."), '': _td("This homeserver has exceeded one of its resource limits."), }, ); @@ -619,28 +621,37 @@ export default class Registration extends React.Component { { regDoneText }
; } else { - body =
-

{ _t('Create account') }

- { errorText } - { serverDeadSection } - - { this.renderRegisterComponent() } - { goBack } - { signIn } -
; + body = +
+ } + > + { errorText } + { serverDeadSection } + + { this.renderRegisterComponent() } +
+
+ { goBack } + { signIn } +
+
; } return ( - - { body } - + + + { body } + + ); } diff --git a/src/dispatcher/payloads/OpenTabbedIntegrationManagerDialogPayload.ts b/src/components/structures/auth/header/AuthHeaderContext.tsx similarity index 58% rename from src/dispatcher/payloads/OpenTabbedIntegrationManagerDialogPayload.ts rename to src/components/structures/auth/header/AuthHeaderContext.tsx index 891d1261694..347b26252dd 100644 --- a/src/dispatcher/payloads/OpenTabbedIntegrationManagerDialogPayload.ts +++ b/src/components/structures/auth/header/AuthHeaderContext.tsx @@ -14,16 +14,13 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { Room } from "matrix-js-sdk/src/models/room"; -import { Optional } from "matrix-events-sdk"; +import { createContext, Dispatch, ReducerAction, ReducerState } from "react"; -import { ActionPayload } from "../payloads"; -import { Action } from "../actions"; +import type { AuthHeaderReducer } from "./AuthHeaderProvider"; -export interface OpenTabbedIntegrationManagerDialogPayload extends ActionPayload { - action: Action.OpenTabbedIntegrationManagerDialog; - - room: Optional; - screen: Optional; - integrationId: Optional; +interface AuthHeaderContextType { + state: ReducerState; + dispatch: Dispatch>; } + +export const AuthHeaderContext = createContext(undefined); diff --git a/src/components/structures/auth/header/AuthHeaderDisplay.tsx b/src/components/structures/auth/header/AuthHeaderDisplay.tsx new file mode 100644 index 00000000000..fd5b65a1ebd --- /dev/null +++ b/src/components/structures/auth/header/AuthHeaderDisplay.tsx @@ -0,0 +1,41 @@ +/* +Copyright 2022 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, { Fragment, PropsWithChildren, ReactNode, useContext } from "react"; + +import { AuthHeaderContext } from "./AuthHeaderContext"; + +interface Props { + title: ReactNode; + icon?: ReactNode; + serverPicker: ReactNode; +} + +export function AuthHeaderDisplay({ title, icon, serverPicker, children }: PropsWithChildren) { + const context = useContext(AuthHeaderContext); + if (!context) { + return null; + } + const current = context.state.length ? context.state[0] : null; + return ( + + { current?.icon ?? icon } +

{ current?.title ?? title }

+ { children } + { current?.hideServerPicker !== true && serverPicker } +
+ ); +} diff --git a/src/components/structures/auth/header/AuthHeaderModifier.tsx b/src/components/structures/auth/header/AuthHeaderModifier.tsx new file mode 100644 index 00000000000..a5646ff4f1f --- /dev/null +++ b/src/components/structures/auth/header/AuthHeaderModifier.tsx @@ -0,0 +1,39 @@ +/* +Copyright 2022 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 { ReactNode, useContext, useEffect } from "react"; + +import { AuthHeaderContext } from "./AuthHeaderContext"; +import { AuthHeaderActionType } from "./AuthHeaderProvider"; + +interface Props { + title: ReactNode; + icon?: ReactNode; + hideServerPicker?: boolean; +} + +export function AuthHeaderModifier(props: Props) { + const context = useContext(AuthHeaderContext); + const dispatch = context ? context.dispatch : null; + useEffect(() => { + if (!dispatch) { + return; + } + dispatch({ type: AuthHeaderActionType.Add, value: props }); + return () => dispatch({ type: AuthHeaderActionType.Remove, value: props }); + }, [props, dispatch]); + return null; +} diff --git a/src/components/structures/auth/header/AuthHeaderProvider.tsx b/src/components/structures/auth/header/AuthHeaderProvider.tsx new file mode 100644 index 00000000000..6c2bc5a7509 --- /dev/null +++ b/src/components/structures/auth/header/AuthHeaderProvider.tsx @@ -0,0 +1,52 @@ +/* +Copyright 2022 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 { isEqual } from "lodash"; +import React, { ComponentProps, PropsWithChildren, Reducer, useReducer } from "react"; + +import { AuthHeaderContext } from "./AuthHeaderContext"; +import { AuthHeaderModifier } from "./AuthHeaderModifier"; + +export enum AuthHeaderActionType { + Add, + Remove +} + +interface AuthHeaderAction { + type: AuthHeaderActionType; + value: ComponentProps; +} + +export type AuthHeaderReducer = Reducer[], AuthHeaderAction>; + +export function AuthHeaderProvider({ children }: PropsWithChildren<{}>) { + const [state, dispatch] = useReducer( + (state: ComponentProps[], action: AuthHeaderAction) => { + switch (action.type) { + case AuthHeaderActionType.Add: + return [action.value, ...state]; + case AuthHeaderActionType.Remove: + return (state.length && isEqual(state[0], action.value)) ? state.slice(1) : state; + } + }, + [] as ComponentProps[], + ); + return ( + + { children } + + ); +} diff --git a/src/components/views/auth/AuthBody.tsx b/src/components/views/auth/AuthBody.tsx index 4532ceeaf44..cacab416f64 100644 --- a/src/components/views/auth/AuthBody.tsx +++ b/src/components/views/auth/AuthBody.tsx @@ -14,12 +14,15 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from 'react'; +import classNames from "classnames"; +import React, { PropsWithChildren } from 'react'; -export default class AuthBody extends React.PureComponent { - public render(): React.ReactNode { - return
- { this.props.children } -
; - } +interface Props { + flex?: boolean; +} + +export default function AuthBody({ flex, children }: PropsWithChildren) { + return
+ { children } +
; } diff --git a/src/components/views/auth/CountryDropdown.tsx b/src/components/views/auth/CountryDropdown.tsx index 0318c4b95d7..07866bc98e5 100644 --- a/src/components/views/auth/CountryDropdown.tsx +++ b/src/components/views/auth/CountryDropdown.tsx @@ -135,7 +135,7 @@ export default class CountryDropdown extends React.Component { }); // default value here too, otherwise we need to handle null / undefined - // values between mounting and the initial value propgating + // values between mounting and the initial value propagating const value = this.props.value || this.state.defaultCountry.iso2; return void; submitAuthDict: (auth: IAuthDict) => void; + requestEmailToken?: () => Promise; } interface IPasswordAuthEntryState { @@ -205,7 +210,9 @@ export class RecaptchaAuthEntry extends React.Component; + return ( + + ); } let errorText = this.props.errorText; @@ -349,7 +356,9 @@ export class TermsAuthEntry extends React.Component; + return ( + + ); } const checkboxes = []; @@ -405,9 +414,24 @@ interface IEmailIdentityAuthEntryProps extends IAuthEntryProps { }; } -export class EmailIdentityAuthEntry extends React.Component { +interface IEmailIdentityAuthEntryState { + requested: boolean; + requesting: boolean; +} + +export class EmailIdentityAuthEntry extends + React.Component { static LOGIN_TYPE = AuthType.Email; + constructor(props: IEmailIdentityAuthEntryProps) { + super(props); + + this.state = { + requested: false, + requesting: false, + }; + } + componentDidMount() { this.props.onPhaseChange(DEFAULT_PHASE); } @@ -440,11 +464,51 @@ export class EmailIdentityAuthEntry extends React.Component -

{ _t("A confirmation email has been sent to %(emailAddress)s", + } + hideServerPicker={true} + /> +

{ _t("To create your account, open the link in the email we just sent to %(emailAddress)s.", { emailAddress: { this.props.inputs.emailAddress } }, - ) } -

-

{ _t("Open the link in the email to continue registration.") }

+ ) }

+ { this.state.requesting ? ( +

{ _t("Did not receive it? Resend it", {}, { + a: (text: string) => + null} + disabled + >{ text } + , + }) }

+ ) :

{ _t("Did not receive it? Resend it", {}, { + a: (text: string) => this.setState({ requested: false }) + : undefined} + onClick={async () => { + this.setState({ requesting: true }); + try { + await this.props.requestEmailToken?.(); + } catch (e) { + logger.warn("Email token request failed: ", e); + } finally { + this.setState({ requested: true, requesting: false }); + } + }} + >{ text }, + }) }

} { errorSection }
); @@ -560,7 +624,9 @@ export class MsisdnAuthEntry extends React.Component; + return ( + + ); } else { const enableSubmit = Boolean(this.state.token); const submitClasses = classNames({ @@ -726,13 +792,15 @@ export class SSOAuthEntry extends React.Component - { errorSection } -
- { cancelButton } - { continueButton } -
- ; + return ( + + { errorSection } +
+ { cancelButton } + { continueButton } +
+
+ ); } } @@ -817,6 +885,7 @@ export interface IStageComponentProps extends IAuthEntryProps { fail?(e: Error): void; setEmailSid?(sid: string): void; onCancel?(): void; + requestEmailToken?(): Promise; } export interface IStageComponent extends React.ComponentClass> { diff --git a/src/components/views/beacon/BeaconMarker.tsx b/src/components/views/beacon/BeaconMarker.tsx index f7f284b88ed..644482e48b3 100644 --- a/src/components/views/beacon/BeaconMarker.tsx +++ b/src/components/views/beacon/BeaconMarker.tsx @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { useContext } from 'react'; +import React, { ReactNode, useContext } from 'react'; import maplibregl from 'maplibre-gl'; import { Beacon, @@ -29,12 +29,13 @@ import SmartMarker from '../location/SmartMarker'; interface Props { map: maplibregl.Map; beacon: Beacon; + tooltip?: ReactNode; } /** * Updates a map SmartMarker with latest location from given beacon */ -const BeaconMarker: React.FC = ({ map, beacon }) => { +const BeaconMarker: React.FC = ({ map, beacon, tooltip }) => { const latestLocationState = useEventEmitterState( beacon, BeaconEvent.LocationUpdate, @@ -58,6 +59,7 @@ const BeaconMarker: React.FC = ({ map, beacon }) => { id={beacon.identifier} geoUri={geoUri} roomMember={markerRoomMember} + tooltip={tooltip} useMemberColor />; }; diff --git a/src/components/views/beacon/BeaconStatusTooltip.tsx b/src/components/views/beacon/BeaconStatusTooltip.tsx new file mode 100644 index 00000000000..bc9f3609395 --- /dev/null +++ b/src/components/views/beacon/BeaconStatusTooltip.tsx @@ -0,0 +1,61 @@ +/* +Copyright 2022 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 { Beacon } from 'matrix-js-sdk/src/matrix'; +import { LocationAssetType } from 'matrix-js-sdk/src/@types/location'; + +import MatrixClientContext from '../../../contexts/MatrixClientContext'; +import CopyableText from '../elements/CopyableText'; +import BeaconStatus from './BeaconStatus'; +import { BeaconDisplayStatus } from './displayStatus'; + +interface Props { + beacon: Beacon; +} + +const useBeaconName = (beacon: Beacon): string => { + const matrixClient = useContext(MatrixClientContext); + + if (beacon.beaconInfo.assetType !== LocationAssetType.Self) { + return beacon.beaconInfo.description; + } + const room = matrixClient.getRoom(beacon.roomId); + const member = room?.getMember(beacon.beaconInfoOwner); + + return member?.rawDisplayName || beacon.beaconInfoOwner; +}; + +const BeaconStatusTooltip: React.FC = ({ beacon }) => { + const label = useBeaconName(beacon); + + return
+ + beacon.latestLocationState?.uri} + /> + +
; +}; + +export default BeaconStatusTooltip; diff --git a/src/components/views/beacon/BeaconViewDialog.tsx b/src/components/views/beacon/BeaconViewDialog.tsx index e6c4a423fe9..a7cdb242d37 100644 --- a/src/components/views/beacon/BeaconViewDialog.tsx +++ b/src/components/views/beacon/BeaconViewDialog.tsx @@ -37,6 +37,7 @@ import { _t } from '../../../languageHandler'; import AccessibleButton from '../elements/AccessibleButton'; import DialogSidebar from './DialogSidebar'; import DialogOwnBeaconStatus from './DialogOwnBeaconStatus'; +import BeaconStatusTooltip from './BeaconStatusTooltip'; interface IProps extends IDialogProps { roomId: Room['roomId']; @@ -103,6 +104,7 @@ const BeaconViewDialog: React.FC = ({ key={beacon.identifier} map={map} beacon={beacon} + tooltip={} />) } diff --git a/src/components/views/beacon/LeftPanelLiveShareWarning.tsx b/src/components/views/beacon/LeftPanelLiveShareWarning.tsx index 43b00a8fa76..b2686285925 100644 --- a/src/components/views/beacon/LeftPanelLiveShareWarning.tsx +++ b/src/components/views/beacon/LeftPanelLiveShareWarning.tsx @@ -15,8 +15,8 @@ limitations under the License. */ import classNames from 'classnames'; -import React from 'react'; -import { BeaconIdentifier, Room } from 'matrix-js-sdk/src/matrix'; +import React, { useEffect } from 'react'; +import { Beacon, BeaconIdentifier, Room } from 'matrix-js-sdk/src/matrix'; import { useEventEmitterState } from '../../../hooks/useEventEmitter'; import { _t } from '../../../languageHandler'; @@ -56,11 +56,30 @@ const getLabel = (hasStoppingErrors: boolean, hasLocationErrors: boolean): strin return _t('An error occurred while stopping your live location'); } if (hasLocationErrors) { - return _t('An error occured whilst sharing your live location'); + return _t('An error occurred whilst sharing your live location'); } return _t('You are sharing your live location'); }; +const useLivenessMonitor = (liveBeaconIds: BeaconIdentifier[], beacons: Map): void => { + useEffect(() => { + // chromium sets the minimum timer interval to 1000ms + // for inactive tabs + // refresh beacon monitors when the tab becomes active again + const onPageVisibilityChanged = () => { + if (document.visibilityState === 'visible') { + liveBeaconIds.forEach(identifier => beacons.get(identifier)?.monitorLiveness()); + } + }; + if (liveBeaconIds.length) { + document.addEventListener("visibilitychange", onPageVisibilityChanged); + } + return () => { + document.removeEventListener("visibilitychange", onPageVisibilityChanged); + }; + }, [liveBeaconIds, beacons]); +}; + const LeftPanelLiveShareWarning: React.FC = ({ isMinimized }) => { const isMonitoringLiveLocation = useEventEmitterState( OwnBeaconStore.instance, @@ -91,6 +110,8 @@ const LeftPanelLiveShareWarning: React.FC = ({ isMinimized }) => { const hasLocationPublishErrors = !!beaconIdsWithLocationPublishError.length; const hasStoppingErrors = !!beaconIdsWithStoppingError.length; + useLivenessMonitor(liveBeaconIds, OwnBeaconStore.instance.beacons); + if (!isMonitoringLiveLocation) { return null; } diff --git a/src/components/views/beacon/RoomLiveShareWarning.tsx b/src/components/views/beacon/RoomLiveShareWarning.tsx index 2fe76a10e88..ecd200cf96f 100644 --- a/src/components/views/beacon/RoomLiveShareWarning.tsx +++ b/src/components/views/beacon/RoomLiveShareWarning.tsx @@ -29,7 +29,7 @@ import LiveTimeRemaining from './LiveTimeRemaining'; const getLabel = (hasLocationPublishError: boolean, hasStopSharingError: boolean): string => { if (hasLocationPublishError) { - return _t('An error occured whilst sharing your live location, please try again'); + return _t('An error occurred whilst sharing your live location, please try again'); } if (hasStopSharingError) { return _t('An error occurred while stopping your live location, please try again'); diff --git a/src/components/views/beta/BetaCard.tsx b/src/components/views/beta/BetaCard.tsx index 4639886b701..c2a0e282953 100644 --- a/src/components/views/beta/BetaCard.tsx +++ b/src/components/views/beta/BetaCard.tsx @@ -60,7 +60,6 @@ export const BetaPill = ({ } onClick={onClick} - yOffset={-10} > { _t("Beta") } ; diff --git a/src/components/views/context_menus/DeviceContextMenu.tsx b/src/components/views/context_menus/DeviceContextMenu.tsx new file mode 100644 index 00000000000..04463e81ff0 --- /dev/null +++ b/src/components/views/context_menus/DeviceContextMenu.tsx @@ -0,0 +1,89 @@ +/* +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. +*/ + +import React, { useEffect, useState } from "react"; + +import MediaDeviceHandler, { MediaDeviceKindEnum } from "../../../MediaDeviceHandler"; +import IconizedContextMenu, { IconizedContextMenuOptionList, IconizedContextMenuRadio } from "./IconizedContextMenu"; +import { IProps as IContextMenuProps } from "../../structures/ContextMenu"; +import { _t, _td } from "../../../languageHandler"; + +const SECTION_NAMES: Record = { + [MediaDeviceKindEnum.AudioInput]: _td("Input devices"), + [MediaDeviceKindEnum.AudioOutput]: _td("Output devices"), + [MediaDeviceKindEnum.VideoInput]: _td("Cameras"), +}; + +interface IDeviceContextMenuDeviceProps { + label: string; + selected: boolean; + onClick: () => void; +} + +const DeviceContextMenuDevice: React.FC = ({ label, selected, onClick }) => { + return ; +}; + +interface IDeviceContextMenuSectionProps { + deviceKind: MediaDeviceKindEnum; +} + +const DeviceContextMenuSection: React.FC = ({ deviceKind }) => { + const [devices, setDevices] = useState([]); + const [selectedDevice, setSelectedDevice] = useState(MediaDeviceHandler.getDevice(deviceKind)); + + useEffect(() => { + const getDevices = async () => { + return setDevices((await MediaDeviceHandler.getDevices())[deviceKind]); + }; + getDevices(); + }, [deviceKind]); + + const onDeviceClick = (deviceId: string): void => { + MediaDeviceHandler.instance.setDevice(deviceId, deviceKind); + setSelectedDevice(deviceId); + }; + + return + { devices.map(({ label, deviceId }) => { + return onDeviceClick(deviceId)} + />; + }) } + ; +}; + +interface IProps extends IContextMenuProps { + deviceKinds: MediaDeviceKind[]; +} + +const DeviceContextMenu: React.FC = ({ deviceKinds, ...props }) => { + return + { deviceKinds.map((kind) => { + return ; + }) } + ; +}; + +export default DeviceContextMenu; diff --git a/src/components/views/context_menus/IconizedContextMenu.tsx b/src/components/views/context_menus/IconizedContextMenu.tsx index 2c6bdb3776b..9b7896790ef 100644 --- a/src/components/views/context_menus/IconizedContextMenu.tsx +++ b/src/components/views/context_menus/IconizedContextMenu.tsx @@ -33,6 +33,7 @@ interface IProps extends IContextMenuProps { interface IOptionListProps { first?: boolean; red?: boolean; + label?: string; className?: string; } @@ -126,13 +127,20 @@ export const IconizedContextMenuOption: React.FC = ({ ; }; -export const IconizedContextMenuOptionList: React.FC = ({ first, red, className, children }) => { +export const IconizedContextMenuOptionList: React.FC = ({ + first, + red, + className, + label, + children, +}) => { const classes = classNames("mx_IconizedContextMenu_optionList", className, { mx_IconizedContextMenu_optionList_notFirst: !first, mx_IconizedContextMenu_optionList_red: red, }); return
+ { label &&
{ label }
} { children }
; }; diff --git a/src/components/views/context_menus/MessageContextMenu.tsx b/src/components/views/context_menus/MessageContextMenu.tsx index 917091ece83..86e9d9cc306 100644 --- a/src/components/views/context_menus/MessageContextMenu.tsx +++ b/src/components/views/context_menus/MessageContextMenu.tsx @@ -37,12 +37,11 @@ import { Action } from "../../../dispatcher/actions"; import { RoomPermalinkCreator } from '../../../utils/permalinks/Permalinks'; import { ButtonEvent } from '../elements/AccessibleButton'; import { copyPlaintext, getSelectedText } from '../../../utils/strings'; -import ContextMenu, { toRightOf } from '../../structures/ContextMenu'; +import ContextMenu, { toRightOf, IPosition, ChevronFace } from '../../structures/ContextMenu'; import ReactionPicker from '../emojipicker/ReactionPicker'; import ViewSource from '../../structures/ViewSource'; import { createRedactEventDialog } from '../dialogs/ConfirmRedactDialog'; import ShareDialog from '../dialogs/ShareDialog'; -import { IPosition, ChevronFace } from '../../structures/ContextMenu'; import RoomContext, { TimelineRenderingType } from '../../../contexts/RoomContext'; import { ComposerInsertPayload } from "../../../dispatcher/payloads/ComposerInsertPayload"; import EndPollDialog from '../dialogs/EndPollDialog'; @@ -71,8 +70,8 @@ interface IProps extends IPosition { rightClick?: boolean; // The Relations model from the JS SDK for reactions to `mxEvent` reactions?: Relations; - // A permalink to the event - showPermalink?: boolean; + // A permalink to this event or an href of an anchor element the user has clicked + link?: string; getRelationsForEvent?: GetRelationsForEvent; } @@ -228,7 +227,7 @@ export default class MessageContextMenu extends React.Component this.closeMenu(); }; - private onPermalinkClick = (e: React.MouseEvent): void => { + private onShareClick = (e: React.MouseEvent): void => { e.preventDefault(); Modal.createTrackedDialog('share room message dialog', '', ShareDialog, { target: this.props.mxEvent, @@ -237,9 +236,9 @@ export default class MessageContextMenu extends React.Component this.closeMenu(); }; - private onCopyPermalinkClick = (e: ButtonEvent): void => { + private onCopyLinkClick = (e: ButtonEvent): void => { e.preventDefault(); // So that we don't open the permalink - copyPlaintext(this.getPermalink()); + copyPlaintext(this.props.link); this.closeMenu(); }; @@ -296,11 +295,6 @@ export default class MessageContextMenu extends React.Component }); } - private getPermalink(): string { - if (!this.props.permalinkCreator) return; - return this.props.permalinkCreator.forEvent(this.props.mxEvent.getId()); - } - private getUnsentReactions(): MatrixEvent[] { return this.getReactions(e => e.status === EventStatus.NOT_SENT); } @@ -319,11 +313,11 @@ export default class MessageContextMenu extends React.Component public render(): JSX.Element { const cli = MatrixClientPeg.get(); const me = cli.getUserId(); - const { mxEvent, rightClick, showPermalink, eventTileOps, reactions, collapseReplyChain } = this.props; + const { mxEvent, rightClick, link, eventTileOps, reactions, collapseReplyChain } = this.props; const eventStatus = mxEvent.status; const unsentReactionsCount = this.getUnsentReactions().length; const contentActionable = isContentActionable(mxEvent); - const permalink = this.getPermalink(); + const permalink = this.props.permalinkCreator?.forEvent(this.props.mxEvent.getId()); // status is SENT before remote-echo, null after const isSent = !eventStatus || eventStatus === EventStatus.SENT; const { timelineRenderingType, canReact, canSendMessages } = this.context; @@ -421,17 +415,13 @@ export default class MessageContextMenu extends React.Component if (permalink) { permalinkButton = ( ); } + let copyLinkButton: JSX.Element; + if (link) { + copyLinkButton = ( + + ); + } + let copyButton: JSX.Element; if (rightClick && getSelectedText()) { copyButton = ( @@ -567,10 +577,11 @@ export default class MessageContextMenu extends React.Component } let nativeItemsList: JSX.Element; - if (copyButton) { + if (copyButton || copyLinkButton) { nativeItemsList = ( { copyButton } + { copyLinkButton } ); } diff --git a/src/components/views/context_menus/SpaceContextMenu.tsx b/src/components/views/context_menus/SpaceContextMenu.tsx index d9286c618b4..5d045900453 100644 --- a/src/components/views/context_menus/SpaceContextMenu.tsx +++ b/src/components/views/context_menus/SpaceContextMenu.tsx @@ -16,7 +16,7 @@ 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 { EventType, RoomType } from "matrix-js-sdk/src/@types/event"; import { IProps as IContextMenuProps } from "../../structures/ContextMenu"; import IconizedContextMenu, { IconizedContextMenuOption, IconizedContextMenuOptionList } from "./IconizedContextMenu"; @@ -136,6 +136,7 @@ const SpaceContextMenu = ({ space, hideHeader, onFinished, ...props }: IProps) = const hasPermissionToAddSpaceChild = space.currentState.maySendStateEvent(EventType.SpaceChild, userId); const canAddRooms = hasPermissionToAddSpaceChild && shouldShowComponent(UIComponent.CreateRooms); + const canAddVideoRooms = canAddRooms && SettingsStore.getValue("feature_video_rooms"); const canAddSubSpaces = hasPermissionToAddSpaceChild && shouldShowComponent(UIComponent.CreateSpaces); let newRoomSection: JSX.Element; @@ -149,6 +150,14 @@ const SpaceContextMenu = ({ space, hideHeader, onFinished, ...props }: IProps) = onFinished(); }; + const onNewVideoRoomClick = (ev: ButtonEvent) => { + ev.preventDefault(); + ev.stopPropagation(); + + showCreateNewRoom(space, RoomType.ElementVideo); + onFinished(); + }; + const onNewSubspaceClick = (ev: ButtonEvent) => { ev.preventDefault(); ev.stopPropagation(); @@ -169,6 +178,14 @@ const SpaceContextMenu = ({ space, hideHeader, onFinished, ...props }: IProps) = onClick={onNewRoomClick} /> } + { canAddVideoRooms && + + } { canAddSubSpaces && { // align the context menu's icons with the icon which opened the context menu - const left = elementRect.left + window.pageXOffset + elementRect.width; - const top = elementRect.bottom + window.pageYOffset; + const left = elementRect.left + window.scrollX + elementRect.width; + const top = elementRect.bottom + window.scrollY; const chevronFace = ChevronFace.None; return { left, top, chevronFace }; }; diff --git a/src/components/views/context_menus/WidgetContextMenu.tsx b/src/components/views/context_menus/WidgetContextMenu.tsx index 1aeeccc724b..b6a46bd2aca 100644 --- a/src/components/views/context_menus/WidgetContextMenu.tsx +++ b/src/components/views/context_menus/WidgetContextMenu.tsx @@ -110,7 +110,8 @@ const WidgetContextMenu: React.FC = ({ } let snapshotButton; - if (widgetMessaging?.hasCapability(MatrixCapabilities.Screenshots)) { + const screenshotsEnabled = SettingsStore.getValue("enableWidgetScreenshots"); + if (screenshotsEnabled && widgetMessaging?.hasCapability(MatrixCapabilities.Screenshots)) { const onSnapshotClick = () => { widgetMessaging?.takeScreenshot().then(data => { dis.dispatch({ diff --git a/src/components/views/dialogs/BugReportDialog.tsx b/src/components/views/dialogs/BugReportDialog.tsx index eb70e44560c..a56427a2ab1 100644 --- a/src/components/views/dialogs/BugReportDialog.tsx +++ b/src/components/views/dialogs/BugReportDialog.tsx @@ -30,6 +30,8 @@ import Field from '../elements/Field'; import Spinner from "../elements/Spinner"; import DialogButtons from "../elements/DialogButtons"; import { sendSentryReport } from "../../../sentry"; +import defaultDispatcher from '../../../dispatcher/dispatcher'; +import { Action } from '../../../dispatcher/actions'; interface IProps { onFinished: (success: boolean) => void; @@ -65,6 +67,16 @@ export default class BugReportDialog extends React.Component { downloadProgress: null, }; this.unmounted = false; + + // Get all of the extra info dumped to the console when someone is about + // to send debug logs. Since this is a fire and forget action, we do + // this when the bug report dialog is opened instead of when we submit + // logs because we have no signal to know when all of the various + // components have finished logging. Someone could potentially send logs + // before we fully dump everything but it's probably unlikely. + defaultDispatcher.dispatch({ + action: Action.DumpDebugLogs, + }); } public componentWillUnmount() { diff --git a/src/components/views/dialogs/BulkRedactDialog.tsx b/src/components/views/dialogs/BulkRedactDialog.tsx index 86c0f4033f1..3d8e6967f18 100644 --- a/src/components/views/dialogs/BulkRedactDialog.tsx +++ b/src/components/views/dialogs/BulkRedactDialog.tsx @@ -25,7 +25,7 @@ import { EventType } from "matrix-js-sdk/src/@types/event"; import { _t } from '../../../languageHandler'; import dis from "../../../dispatcher/dispatcher"; import { Action } from "../../../dispatcher/actions"; -import { IDialogProps } from "../dialogs/IDialogProps"; +import { IDialogProps } from "./IDialogProps"; import BaseDialog from "../dialogs/BaseDialog"; import InfoDialog from "../dialogs/InfoDialog"; import DialogButtons from "../elements/DialogButtons"; diff --git a/src/components/views/dialogs/DevtoolsDialog.tsx b/src/components/views/dialogs/DevtoolsDialog.tsx index 14fb69b1563..fb2f7113dd8 100644 --- a/src/components/views/dialogs/DevtoolsDialog.tsx +++ b/src/components/views/dialogs/DevtoolsDialog.tsx @@ -101,6 +101,7 @@ const DevtoolsDialog: React.FC = ({ roomId, onFinished }) => {

{ _t("Options") }

+ ; } diff --git a/src/components/views/dialogs/ExportDialog.tsx b/src/components/views/dialogs/ExportDialog.tsx index 6881c1c52d8..1a8d1020cd6 100644 --- a/src/components/views/dialogs/ExportDialog.tsx +++ b/src/components/views/dialogs/ExportDialog.tsx @@ -200,7 +200,7 @@ const ExportDialog: React.FC = ({ room, onFinished }) => { }, { key: "number", test: ({ value }) => { - const parsedSize = parseInt(value as string, 10); + const parsedSize = parseInt(value, 10); return validateNumberInRange(1, 2000)(parsedSize); }, invalid: () => { @@ -238,7 +238,7 @@ const ExportDialog: React.FC = ({ room, onFinished }) => { }, { key: "number", test: ({ value }) => { - const parsedSize = parseInt(value as string, 10); + const parsedSize = parseInt(value, 10); return validateNumberInRange(1, 10 ** 8)(parsedSize); }, invalid: () => { @@ -263,7 +263,7 @@ const ExportDialog: React.FC = ({ room, onFinished }) => { else onFinished(false); }; - const confirmCanel = async () => { + const confirmCancel = async () => { await exporter?.cancelExport(); setExportCancelled(true); setExporting(false); @@ -346,7 +346,7 @@ const ExportDialog: React.FC = ({ room, onFinished }) => { hasCancel={true} cancelButton={_t("Continue")} onCancel={() => setCancelWarning(false)} - onPrimaryButtonClick={confirmCanel} + onPrimaryButtonClick={confirmCancel} /> ); diff --git a/src/components/views/dialogs/ForwardDialog.tsx b/src/components/views/dialogs/ForwardDialog.tsx index 3c482bf0438..7b01e721692 100644 --- a/src/components/views/dialogs/ForwardDialog.tsx +++ b/src/components/views/dialogs/ForwardDialog.tsx @@ -21,6 +21,8 @@ import { Room } from "matrix-js-sdk/src/models/room"; import { MatrixClient } from "matrix-js-sdk/src/client"; import { RoomMember } from "matrix-js-sdk/src/models/room-member"; import { EventType } from "matrix-js-sdk/src/@types/event"; +import { ILocationContent, LocationAssetType, M_TIMESTAMP } from "matrix-js-sdk/src/@types/location"; +import { makeLocationContent } from "matrix-js-sdk/src/content-helpers"; import { _t } from "../../../languageHandler"; import dis from "../../../dispatcher/dispatcher"; @@ -47,6 +49,8 @@ import { Action } from "../../../dispatcher/actions"; import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; import { ButtonEvent } from "../elements/AccessibleButton"; import { roomContextDetailsText } from "../../../utils/i18n-helpers"; +import { isLocationEvent } from "../../../utils/EventUtils"; +import { isSelfLocation, locationEventGeoUri } from "../../../utils/location"; const AVATAR_SIZE = 30; @@ -131,8 +135,7 @@ const Entry: React.FC = ({ room, type, content, matrixClient: cli, @@ -147,7 +150,6 @@ const Entry: React.FC = ({ room, type, content, matrixClient: cli, onClick={send} disabled={disabled} title={title} - yOffset={-20} alignment={Alignment.Top} >
{ _t("Send") }
@@ -156,6 +158,34 @@ const Entry: React.FC = ({ room, type, content, matrixClient: cli, ; }; +const getStrippedEventContent = (event: MatrixEvent): IContent => { + const { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + "m.relates_to": _, // strip relations - in future we will attach a relation pointing at the original event + // We're taking a shallow copy here to avoid https://github.com/vector-im/element-web/issues/10924 + ...content + } = event.getContent(); + + // self location shares should have their description removed + // and become 'pin' share type + if (isLocationEvent(event) && isSelfLocation(content as ILocationContent)) { + const timestamp = M_TIMESTAMP.findIn(content); + const geoUri = locationEventGeoUri(event); + return { + ...content, + ...makeLocationContent( + undefined, // text + geoUri, + timestamp || Date.now(), + undefined, // description + LocationAssetType.Pin, + ), + }; + } + + return content; +}; + const ForwardDialog: React.FC = ({ matrixClient: cli, event, permalinkCreator, onFinished }) => { const userId = cli.getUserId(); const [profileInfo, setProfileInfo] = useState({}); @@ -163,12 +193,7 @@ const ForwardDialog: React.FC = ({ matrixClient: cli, event, permalinkCr cli.getProfileInfo(userId).then(info => setProfileInfo(info)); }, [cli, userId]); - const { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - "m.relates_to": _, // strip relations - in future we will attach a relation pointing at the original event - // We're taking a shallow copy here to avoid https://github.com/vector-im/element-web/issues/10924 - ...content - } = event.getContent(); + const content = getStrippedEventContent(event); // For the message preview we fake the sender as ourselves const mockEvent = new MatrixEvent({ @@ -262,6 +287,7 @@ const ForwardDialog: React.FC = ({ matrixClient: cli, event, permalinkCr { rooms.length > 0 ? (
rooms.slice(start, end).map(room => diff --git a/src/components/views/dialogs/KeySignatureUploadFailedDialog.tsx b/src/components/views/dialogs/KeySignatureUploadFailedDialog.tsx index 381c96dc660..72ce6bd3ba1 100644 --- a/src/components/views/dialogs/KeySignatureUploadFailedDialog.tsx +++ b/src/components/views/dialogs/KeySignatureUploadFailedDialog.tsx @@ -29,7 +29,7 @@ interface IProps extends IDialogProps { error: string; }>>; source: string; - continuation: () => void; + continuation: () => Promise; } const KeySignatureUploadFailedDialog: React.FC = ({ diff --git a/src/components/views/dialogs/MessageEditHistoryDialog.tsx b/src/components/views/dialogs/MessageEditHistoryDialog.tsx index 2dedfb52937..0f51530a128 100644 --- a/src/components/views/dialogs/MessageEditHistoryDialog.tsx +++ b/src/components/views/dialogs/MessageEditHistoryDialog.tsx @@ -138,7 +138,8 @@ export default class MessageEditHistoryDialog extends React.PureComponent)); + /> + )); lastEvent = e; }); return nodes; diff --git a/src/components/views/dialogs/ShareDialog.tsx b/src/components/views/dialogs/ShareDialog.tsx index cb06d09d802..f07964650f3 100644 --- a/src/components/views/dialogs/ShareDialog.tsx +++ b/src/components/views/dialogs/ShareDialog.tsx @@ -52,7 +52,7 @@ const socials = [ }, { name: 'Reddit', img: require("../../../../res/img/social/reddit.png"), - url: (url) => `http://www.reddit.com/submit?url=${url}`, + url: (url) => `https://www.reddit.com/submit?url=${url}`, }, { name: 'email', img: require("../../../../res/img/social/email-1.png"), diff --git a/src/components/views/dialogs/SpotlightDialog.tsx b/src/components/views/dialogs/SpotlightDialog.tsx index f579e262875..f5efc0b8dc8 100644 --- a/src/components/views/dialogs/SpotlightDialog.tsx +++ b/src/components/views/dialogs/SpotlightDialog.tsx @@ -74,6 +74,7 @@ import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts"; import { PosthogAnalytics } from "../../../PosthogAnalytics"; import { getCachedRoomIDForAlias } from "../../../RoomAliasCache"; import { roomContextDetailsText, spaceContextDetailsText } from "../../../utils/i18n-helpers"; +import { RecentAlgorithm } from "../../../stores/room-list/algorithms/tag-sorting/RecentAlgorithm"; const MAX_RECENT_SEARCHES = 10; const SECTION_LIMIT = 50; // only show 50 results per section for performance reasons @@ -210,6 +211,8 @@ type Result = IRoomResult | IResult; const isRoomResult = (result: any): result is IRoomResult => !!result?.room; +const recentAlgorithm = new RecentAlgorithm(); + export const useWebSearchMetrics = (numResults: number, queryLength: number, viaSpotlight: boolean): void => { useEffect(() => { if (!queryLength) return; @@ -280,6 +283,7 @@ const SpotlightDialog: React.FC = ({ initialText = "", onFinished }) => const results: [Result[], Result[], Result[]] = [[], [], []]; + // Group results in their respective sections possibleResults.forEach(entry => { if (isRoomResult(entry)) { if (!entry.room.normalizedName.includes(normalizedQuery) && @@ -295,8 +299,25 @@ const SpotlightDialog: React.FC = ({ initialText = "", onFinished }) => results[entry.section].push(entry); }); + // Sort results by most recent activity + + const myUserId = cli.getUserId(); + for (const resultArray of results) { + resultArray.sort((a: Result, b: Result) => { + // This is not a room result, it should appear at the bottom of + // the list + if (!(a as IRoomResult).room) return 1; + if (!(b as IRoomResult).room) return -1; + + const roomA = (a as IRoomResult).room; + const roomB = (b as IRoomResult).room; + + return recentAlgorithm.getLastTs(roomB, myUserId) - recentAlgorithm.getLastTs(roomA, myUserId); + }); + } + return results; - }, [possibleResults, trimmedQuery]); + }, [possibleResults, trimmedQuery, cli]); const numResults = trimmedQuery ? people.length + rooms.length + spaces.length : 0; useWebSearchMetrics(numResults, query.length, true); @@ -367,16 +388,16 @@ const SpotlightDialog: React.FC = ({ initialText = "", onFinished }) => ); } - const otherResult = (result as IResult); + // IResult case return ( ); }; diff --git a/src/components/views/dialogs/TabbedIntegrationManagerDialog.tsx b/src/components/views/dialogs/TabbedIntegrationManagerDialog.tsx deleted file mode 100644 index 5a5d6e38229..00000000000 --- a/src/components/views/dialogs/TabbedIntegrationManagerDialog.tsx +++ /dev/null @@ -1,176 +0,0 @@ -/* -Copyright 2019 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import React from 'react'; -import { Room } from "matrix-js-sdk/src/models/room"; -import classNames from 'classnames'; -import { logger } from "matrix-js-sdk/src/logger"; - -import { IntegrationManagers } from "../../../integrations/IntegrationManagers"; -import { dialogTermsInteractionCallback, TermsNotSignedError } from "../../../Terms"; -import * as ScalarMessaging from "../../../ScalarMessaging"; -import { IntegrationManagerInstance } from "../../../integrations/IntegrationManagerInstance"; -import ScalarAuthClient from "../../../ScalarAuthClient"; -import AccessibleButton from "../elements/AccessibleButton"; -import IntegrationManager from "../settings/IntegrationManager"; -import { IDialogProps } from "./IDialogProps"; - -interface IProps extends IDialogProps { - /** - * Optional room where the integration manager should be open to - */ - room?: Room; - - /** - * Optional screen to open on the integration manager - */ - screen?: string; - - /** - * Optional integration ID to open in the integration manager - */ - integrationId?: string; -} - -interface IState { - managers: IntegrationManagerInstance[]; - busy: boolean; - currentIndex: number; - currentConnected: boolean; - currentLoading: boolean; - currentScalarClient: ScalarAuthClient; -} - -export default class TabbedIntegrationManagerDialog extends React.Component { - constructor(props: IProps) { - super(props); - - this.state = { - managers: IntegrationManagers.sharedInstance().getOrderedManagers(), - busy: true, - currentIndex: 0, - currentConnected: false, - currentLoading: true, - currentScalarClient: null, - }; - } - - public componentDidMount(): void { - this.openManager(0, true); - } - - private openManager = async (i: number, force = false): Promise => { - if (i === this.state.currentIndex && !force) return; - - const manager = this.state.managers[i]; - const client = manager.getScalarClient(); - this.setState({ - busy: true, - currentIndex: i, - currentLoading: true, - currentConnected: false, - currentScalarClient: client, - }); - - ScalarMessaging.setOpenManagerUrl(manager.uiUrl); - - client.setTermsInteractionCallback((policyInfo, agreedUrls) => { - // To avoid visual glitching of two modals stacking briefly, we customise the - // terms dialog sizing when it will appear for the integration manager so that - // it gets the same basic size as the IM's own modal. - return dialogTermsInteractionCallback( - policyInfo, agreedUrls, 'mx_TermsDialog_forIntegrationManager', - ); - }); - - try { - await client.connect(); - if (!client.hasCredentials()) { - this.setState({ - busy: false, - currentLoading: false, - currentConnected: false, - }); - } else { - this.setState({ - busy: false, - currentLoading: false, - currentConnected: true, - }); - } - } catch (e) { - if (e instanceof TermsNotSignedError) { - return; - } - - logger.error(e); - this.setState({ - busy: false, - currentLoading: false, - currentConnected: false, - }); - } - }; - - private renderTabs(): JSX.Element[] { - return this.state.managers.map((m, i) => { - const classes = classNames({ - 'mx_TabbedIntegrationManagerDialog_tab': true, - 'mx_TabbedIntegrationManagerDialog_currentTab': this.state.currentIndex === i, - }); - return ( - this.openManager(i)} - key={`tab_${i}`} - disabled={this.state.busy} - > - { m.name } - - ); - }); - } - - public renderTab(): JSX.Element { - let uiUrl = null; - if (this.state.currentScalarClient) { - uiUrl = this.state.currentScalarClient.getScalarInterfaceUrlForRoom( - this.props.room, - this.props.screen, - this.props.integrationId, - ); - } - return {/* no-op */}} - />; - } - - public render(): JSX.Element { - return ( -
-
- { this.renderTabs() } -
-
- { this.renderTab() } -
-
- ); - } -} diff --git a/src/components/views/dialogs/security/CreateCrossSigningDialog.tsx b/src/components/views/dialogs/security/CreateCrossSigningDialog.tsx index ca5b1db9fbc..4b1928c3a73 100644 --- a/src/components/views/dialogs/security/CreateCrossSigningDialog.tsx +++ b/src/components/views/dialogs/security/CreateCrossSigningDialog.tsx @@ -93,7 +93,7 @@ export default class CreateCrossSigningDialog extends React.PureComponent void): Promise => { if (this.state.canUploadKeysWithPasswordOnly && this.state.accountPassword) { - await makeRequest({ + makeRequest({ type: 'm.login.password', identifier: { type: 'm.id.user', @@ -106,7 +106,7 @@ export default class CreateCrossSigningDialog extends React.PureComponent { const action = getKeyBindingsManager().getAccessibilityAction(e); diff --git a/src/components/views/elements/AccessibleTooltipButton.tsx b/src/components/views/elements/AccessibleTooltipButton.tsx index f0be8ba12d8..0f52879cc8d 100644 --- a/src/components/views/elements/AccessibleTooltipButton.tsx +++ b/src/components/views/elements/AccessibleTooltipButton.tsx @@ -15,7 +15,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { SyntheticEvent } from 'react'; +import React, { SyntheticEvent, FocusEvent } from 'react'; import AccessibleButton from "./AccessibleButton"; import Tooltip, { Alignment } from './Tooltip'; @@ -26,8 +26,8 @@ interface IProps extends React.ComponentProps { label?: string; tooltipClassName?: string; forceHide?: boolean; - yOffset?: number; alignment?: Alignment; + onHover?: (hovering: boolean) => void; onHideTooltip?(ev: SyntheticEvent): void; } @@ -52,6 +52,7 @@ export default class AccessibleTooltipButton extends React.PureComponent { + if (this.props.onHover) this.props.onHover(true); if (this.props.forceHide) return; this.setState({ hover: true, @@ -59,21 +60,27 @@ export default class AccessibleTooltipButton extends React.PureComponent { + if (this.props.onHover) this.props.onHover(false); this.setState({ hover: false, }); this.props.onHideTooltip?.(ev); }; + private onFocus = (ev: FocusEvent) => { + // We only show the tooltip if focus arrived here from some other + // element, to avoid leaving tooltips hanging around when a modal closes + if (ev.relatedTarget) this.showTooltip(); + }; + render() { // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { title, tooltip, children, tooltipClassName, forceHide, yOffset, alignment, onHideTooltip, + const { title, tooltip, children, tooltipClassName, forceHide, alignment, onHideTooltip, ...props } = this.props; const tip = this.state.hover && ; return ( @@ -81,7 +88,7 @@ export default class AccessibleTooltipButton extends React.PureComponent diff --git a/src/components/views/elements/AppTile.tsx b/src/components/views/elements/AppTile.tsx index 1eba26a3d4e..f202a1e5705 100644 --- a/src/components/views/elements/AppTile.tsx +++ b/src/components/views/elements/AppTile.tsx @@ -57,7 +57,7 @@ interface IProps { // which bypasses permission prompts as it was added explicitly by that user room?: Room; threadId?: string | null; - // Specifying 'fullWidth' as true will render the app tile to fill the width of the app drawer continer. + // Specifying 'fullWidth' as true will render the app tile to fill the width of the app drawer container. // This should be set to true when there is only one widget in the app drawer, otherwise it should be false. fullWidth?: boolean; // Optional. If set, renders a smaller view of the widget @@ -288,7 +288,7 @@ export default class AppTile extends React.Component { private setupSgListeners() { this.sgWidget.on("preparing", this.onWidgetPreparing); this.sgWidget.on("ready", this.onWidgetReady); - // emits when the capabilites have been setup or changed + // emits when the capabilities have been set up or changed this.sgWidget.on("capabilitiesNotified", this.onWidgetCapabilitiesNotified); } @@ -543,7 +543,7 @@ export default class AppTile extends React.Component { const sandboxFlags = "allow-forms allow-popups allow-popups-to-escape-sandbox " + "allow-same-origin allow-scripts allow-presentation allow-downloads"; - // Additional iframe feature pemissions + // Additional iframe feature permissions // (see - https://sites.google.com/a/chromium.org/dev/Home/chromium-security/deprecating-permissions-in-cross-origin-iframes and https://wicg.github.io/feature-policy/) const iframeFeatures = "microphone; camera; encrypted-media; autoplay; display-capture; clipboard-write;"; diff --git a/src/components/views/elements/AppWarning.tsx b/src/components/views/elements/AppWarning.tsx index 352c5990680..b3dfae99118 100644 --- a/src/components/views/elements/AppWarning.tsx +++ b/src/components/views/elements/AppWarning.tsx @@ -1,3 +1,19 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + import React from 'react'; interface IProps { diff --git a/src/components/views/elements/DialogButtons.tsx b/src/components/views/elements/DialogButtons.tsx index 2a3554e8763..bf018e14f49 100644 --- a/src/components/views/elements/DialogButtons.tsx +++ b/src/components/views/elements/DialogButtons.tsx @@ -100,17 +100,19 @@ export default class DialogButtons extends React.Component { return (
{ additive } - { cancelButton } - { this.props.children } - + + { cancelButton } + { this.props.children } + +
); } diff --git a/src/components/views/elements/ErrorBoundary.tsx b/src/components/views/elements/ErrorBoundary.tsx index 89e351bb797..825ff604c7f 100644 --- a/src/components/views/elements/ErrorBoundary.tsx +++ b/src/components/views/elements/ErrorBoundary.tsx @@ -53,7 +53,7 @@ export default class ErrorBoundary extends React.PureComponent<{}, IState> { // in their own `console.error` invocation. logger.error(error); logger.error( - "The above error occured while React was rendering the following components:", + "The above error occurred while React was rendering the following components:", componentStack, ); } diff --git a/src/components/views/elements/FacePile.tsx b/src/components/views/elements/FacePile.tsx index 566eddbe07e..92a175a1cb4 100644 --- a/src/components/views/elements/FacePile.tsx +++ b/src/components/views/elements/FacePile.tsx @@ -31,10 +31,16 @@ interface IProps extends HTMLAttributes { const FacePile: FC = ({ members, faceSize, overflow, tooltip, children, ...props }) => { const faces = members.map( - tooltip ? - m => : - m => - + tooltip + ? m => + : m => + , ); @@ -45,7 +51,7 @@ const FacePile: FC = ({ members, faceSize, overflow, tooltip, children, return
{ tooltip ? ( - + { pileContents } ) : ( diff --git a/src/components/views/elements/Field.tsx b/src/components/views/elements/Field.tsx index 3942a9eb1d4..8e4b896efd6 100644 --- a/src/components/views/elements/Field.tsx +++ b/src/components/views/elements/Field.tsx @@ -19,7 +19,6 @@ import classNames from 'classnames'; import { debounce } from "lodash"; import { IFieldState, IValidationResult } from "./Validation"; -import { ComponentClass } from "../../../@types/common"; import Tooltip from "./Tooltip"; // Invoke validation from user input (when typing, etc.) at most once every N ms. @@ -83,7 +82,6 @@ export interface IInputProps extends IProps, InputHTMLAttributes; // The element to create. Defaults to "input". element?: "input"; - componentClass?: undefined; // The input's value. This is a controlled component, so the value is required. value: string; } @@ -93,7 +91,6 @@ interface ISelectProps extends IProps, SelectHTMLAttributes { inputRef?: RefObject; // To define options for a select, use element: "select"; - componentClass?: undefined; // The select's value. This is a controlled component, so the value is required. value: string; } @@ -102,7 +99,6 @@ interface ITextareaProps extends IProps, TextareaHTMLAttributes; element: "textarea"; - componentClass?: undefined; // The textarea's value. This is a controlled component, so the value is required. value: string; } @@ -111,8 +107,6 @@ export interface INativeOnChangeInputProps extends IProps, InputHTMLAttributes; element: "input"; - // The custom component to render - componentClass: ComponentClass; // The input's value. This is a controlled component, so the value is required. value: string; } @@ -248,7 +242,7 @@ export default class Field extends React.PureComponent { public render() { /* eslint @typescript-eslint/no-unused-vars: ["error", { "ignoreRestSiblings": true }] */ - const { element, componentClass, inputRef, prefixComponent, postfixComponent, className, onValidate, children, + const { element, inputRef, prefixComponent, postfixComponent, className, onValidate, children, tooltipContent, forceValidity, tooltipClassName, list, validateOnBlur, validateOnChange, validateOnFocus, usePlaceholderAsHint, forceTooltipVisible, ...inputProps } = this.props; @@ -265,7 +259,7 @@ export default class Field extends React.PureComponent { // Appease typescript's inference const inputProps_ = { ...inputProps, ref: this.inputRef, list }; - const fieldInput = React.createElement(this.props.componentClass || this.props.element, inputProps_, children); + const fieldInput = React.createElement(this.props.element, inputProps_, children); let prefixContainer = null; if (prefixComponent) { diff --git a/src/components/views/elements/IRCTimelineProfileResizer.tsx b/src/components/views/elements/IRCTimelineProfileResizer.tsx index c0e37d93d74..61fee35bdd7 100644 --- a/src/components/views/elements/IRCTimelineProfileResizer.tsx +++ b/src/components/views/elements/IRCTimelineProfileResizer.tsx @@ -44,7 +44,7 @@ export default class IRCTimelineProfileResizer extends React.Component this.updateCSSWidth(this.state.width)); } diff --git a/src/components/views/elements/ImageView.tsx b/src/components/views/elements/ImageView.tsx index fd24bc745a9..9bb1b551d59 100644 --- a/src/components/views/elements/ImageView.tsx +++ b/src/components/views/elements/ImageView.tsx @@ -38,6 +38,7 @@ import UIStore from '../../../stores/UIStore'; import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts"; import { getKeyBindingsManager } from "../../../KeyBindingsManager"; +import { presentableTextForFile } from "../../../utils/FileUtils"; // Max scale to keep gaps around the image const MAX_SCALE = 0.95; @@ -534,6 +535,15 @@ export default class ImageView extends React.Component { ); } + let title: JSX.Element; + if (this.props.mxEvent?.getContent()) { + title = ( +
+ { presentableTextForFile(this.props.mxEvent?.getContent(), _t("Image"), true) } +
+ ); + } + return ( { >
{ info } + { title }
{ zoomOutButton } { zoomInButton } diff --git a/src/components/views/elements/InteractiveTooltip.tsx b/src/components/views/elements/InteractiveTooltip.tsx index 62d0c43d06a..ca8ae4c8fd5 100644 --- a/src/components/views/elements/InteractiveTooltip.tsx +++ b/src/components/views/elements/InteractiveTooltip.tsx @@ -352,10 +352,10 @@ export default class InteractiveTooltip extends React.Component const targetRect = this.target.getBoundingClientRect(); if (this.props.direction === Direction.Left) { - const targetLeft = targetRect.left + window.pageXOffset; + const targetLeft = targetRect.left + window.scrollX; return !contentRect || (targetLeft - contentRect.width > MIN_SAFE_DISTANCE_TO_WINDOW_EDGE); } else { - const targetRight = targetRect.right + window.pageXOffset; + const targetRight = targetRect.right + window.scrollX; const spaceOnRight = UIStore.instance.windowWidth - targetRight; return contentRect && (spaceOnRight - contentRect.width < MIN_SAFE_DISTANCE_TO_WINDOW_EDGE); } @@ -366,10 +366,10 @@ export default class InteractiveTooltip extends React.Component const targetRect = this.target.getBoundingClientRect(); if (this.props.direction === Direction.Top) { - const targetTop = targetRect.top + window.pageYOffset; + const targetTop = targetRect.top + window.scrollY; return !contentRect || (targetTop - contentRect.height > MIN_SAFE_DISTANCE_TO_WINDOW_EDGE); } else { - const targetBottom = targetRect.bottom + window.pageYOffset; + const targetBottom = targetRect.bottom + window.scrollY; const spaceBelow = UIStore.instance.windowHeight - targetBottom; return contentRect && (spaceBelow - contentRect.height < MIN_SAFE_DISTANCE_TO_WINDOW_EDGE); } @@ -429,10 +429,10 @@ export default class InteractiveTooltip extends React.Component const targetRect = this.target.getBoundingClientRect(); // The window X and Y offsets are to adjust position when zoomed in to page - const targetLeft = targetRect.left + window.pageXOffset; - const targetRight = targetRect.right + window.pageXOffset; - const targetBottom = targetRect.bottom + window.pageYOffset; - const targetTop = targetRect.top + window.pageYOffset; + const targetLeft = targetRect.left + window.scrollX; + const targetRight = targetRect.right + window.scrollX; + const targetBottom = targetRect.bottom + window.scrollY; + const targetTop = targetRect.top + window.scrollY; // Place the tooltip above the target by default. If we find that the // tooltip content would extend past the safe area towards the window diff --git a/src/components/views/elements/LanguageDropdown.tsx b/src/components/views/elements/LanguageDropdown.tsx index 7d19dbfce18..cf1dfedcce7 100644 --- a/src/components/views/elements/LanguageDropdown.tsx +++ b/src/components/views/elements/LanguageDropdown.tsx @@ -99,7 +99,7 @@ export default class LanguageDropdown extends React.Component { }); // default value here too, otherwise we need to handle null / undefined - // values between mounting and the initial value propgating + // values between mounting and the initial value propagating let language = SettingsStore.getValue("language", null, /*excludeDefault:*/true); let value = null; if (language) { diff --git a/src/components/views/elements/Linkify.tsx b/src/components/views/elements/Linkify.tsx new file mode 100644 index 00000000000..4d75fb79218 --- /dev/null +++ b/src/components/views/elements/Linkify.tsx @@ -0,0 +1,44 @@ +/* +Copyright 2022 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, { useLayoutEffect, useRef } from "react"; + +import { linkifyElement } from "../../../HtmlUtils"; + +interface Props { + as?: string; + children: React.ReactNode; + onClick?: (ev: MouseEvent) => void; +} + +export function Linkify({ + as = "div", + children, + onClick, +}: Props): JSX.Element { + const ref = useRef(); + + useLayoutEffect(() => { + linkifyElement(ref.current); + }, [children]); + + return React.createElement(as, { + children, + ref, + onClick, + }); +} + diff --git a/src/components/views/elements/MiniAvatarUploader.tsx b/src/components/views/elements/MiniAvatarUploader.tsx index 43e66db09c1..c501c92ac9f 100644 --- a/src/components/views/elements/MiniAvatarUploader.tsx +++ b/src/components/views/elements/MiniAvatarUploader.tsx @@ -14,18 +14,18 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { useContext, useRef, useState } from 'react'; -import { EventType } from 'matrix-js-sdk/src/@types/event'; import classNames from 'classnames'; +import { EventType } from 'matrix-js-sdk/src/@types/event'; +import React, { useContext, useRef, useState, MouseEvent } from 'react'; -import AccessibleButton from "./AccessibleButton"; -import Spinner from "./Spinner"; +import Analytics from "../../../Analytics"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; +import RoomContext from "../../../contexts/RoomContext"; import { useTimeout } from "../../../hooks/useTimeout"; -import Analytics from "../../../Analytics"; import { TranslatedString } from '../../../languageHandler'; -import RoomContext from "../../../contexts/RoomContext"; import { chromeFileInputFix } from "../../../utils/BrowserWorkarounds"; +import AccessibleButton from "./AccessibleButton"; +import Spinner from "./Spinner"; export const AVATAR_SIZE = 52; @@ -34,9 +34,13 @@ interface IProps { noAvatarLabel?: TranslatedString; hasAvatarLabel?: TranslatedString; setAvatarUrl(url: string): Promise; + isUserAvatar?: boolean; + onClick?(ev: MouseEvent): void; } -const MiniAvatarUploader: React.FC = ({ hasAvatar, hasAvatarLabel, noAvatarLabel, setAvatarUrl, children }) => { +const MiniAvatarUploader: React.FC = ({ + hasAvatar, hasAvatarLabel, noAvatarLabel, setAvatarUrl, isUserAvatar, children, onClick, +}) => { const cli = useContext(MatrixClientContext); const [busy, setBusy] = useState(false); const [hover, setHover] = useState(false); @@ -54,7 +58,7 @@ const MiniAvatarUploader: React.FC = ({ hasAvatar, hasAvatarLabel, noAva const label = (hasAvatar || busy) ? hasAvatarLabel : noAvatarLabel; const { room } = useContext(RoomContext); - const canSetAvatar = room?.currentState.maySendStateEvent(EventType.RoomAvatar, cli.getUserId()); + const canSetAvatar = isUserAvatar || room?.currentState?.maySendStateEvent(EventType.RoomAvatar, cli.getUserId()); if (!canSetAvatar) return { children }; const visible = !!label && (hover || show); @@ -63,7 +67,10 @@ const MiniAvatarUploader: React.FC = ({ hasAvatar, hasAvatarLabel, noAva type="file" ref={uploadRef} className="mx_MiniAvatarUploader_input" - onClick={chromeFileInputFix} + onClick={(ev) => { + chromeFileInputFix(ev); + onClick?.(ev); + }} onChange={async (ev) => { if (!ev.target.files?.length) return; setBusy(true); diff --git a/src/components/views/elements/NativeOnChangeInput.tsx b/src/components/views/elements/NativeOnChangeInput.tsx deleted file mode 100644 index 0937fd8de9f..00000000000 --- a/src/components/views/elements/NativeOnChangeInput.tsx +++ /dev/null @@ -1,69 +0,0 @@ -/* -Copyright 2022 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import React from 'react'; - -import { useCombinedRefs } from "../../../hooks/useCombinedRefs"; - -interface IProps extends Omit, 'onChange' | 'onInput'> { - onChange?: (event: Event) => void; - onInput?: (event: Event) => void; -} - -/** -* This component restores the native 'onChange' and 'onInput' behavior of -* JavaScript which have important differences for certain types. This is -* necessary because in React, the `onChange` handler behaves like the native -* `oninput` handler and there is no way to tell the difference between an -* `input` vs `change` event. -* -* via https://stackoverflow.com/a/62383569/796832 and -* https://github.com/facebook/react/issues/9657#issuecomment-643970199 -* -* See: -* - https://reactjs.org/docs/dom-elements.html#onchange -* - https://github.com/facebook/react/issues/3964 -* - https://github.com/facebook/react/issues/9657 -* - https://github.com/facebook/react/issues/14857 -* -* Examples: -* -* We use this for the date picker so we can distinguish from -* a final date picker selection (onChange) vs navigating the months in the date -* picker (onInput). -* -* This is also potentially useful for because the native -* events behave in such a way that moving the slider around triggers an onInput -* event and releasing it triggers onChange. -*/ -const NativeOnChangeInput: React.FC = React.forwardRef((props: IProps, ref) => { - const registerCallbacks = (input: HTMLInputElement | null) => { - if (input) { - input.onchange = props.onChange; - input.oninput = props.onInput; - } - }; - - return {}} - onInput={() => {}} - />; -}); - -export default NativeOnChangeInput; diff --git a/src/components/views/elements/Pill.js b/src/components/views/elements/Pill.tsx similarity index 71% rename from src/components/views/elements/Pill.js rename to src/components/views/elements/Pill.tsx index 7d5a9973c7f..f344f894569 100644 --- a/src/components/views/elements/Pill.js +++ b/src/components/views/elements/Pill.tsx @@ -13,67 +13,82 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ + import React from 'react'; import classNames from 'classnames'; import { Room } from 'matrix-js-sdk/src/models/room'; import { RoomMember } from 'matrix-js-sdk/src/models/room-member'; -import PropTypes from 'prop-types'; import { logger } from "matrix-js-sdk/src/logger"; +import { MatrixClient } from 'matrix-js-sdk/src/client'; import dis from '../../../dispatcher/dispatcher'; import { MatrixClientPeg } from '../../../MatrixClientPeg'; import { getPrimaryPermalinkEntity, parsePermalink } from "../../../utils/permalinks/Permalinks"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import { Action } from "../../../dispatcher/actions"; -import Tooltip from './Tooltip'; -import RoomAvatar from "../avatars/RoomAvatar"; -import MemberAvatar from "../avatars/MemberAvatar"; +import Tooltip, { Alignment } from './Tooltip'; +import RoomAvatar from '../avatars/RoomAvatar'; +import MemberAvatar from '../avatars/MemberAvatar'; + +export enum PillType { + UserMention = 'TYPE_USER_MENTION', + RoomMention = 'TYPE_ROOM_MENTION', + AtRoomMention = 'TYPE_AT_ROOM_MENTION', // '@room' mention +} + +interface IProps { + // The Type of this Pill. If url is given, this is auto-detected. + type?: PillType; + // The URL to pillify (no validation is done) + url?: string; + // Whether the pill is in a message + inMessage?: boolean; + // The room in which this pill is being rendered + room?: Room; + // Whether to include an avatar in the pill + shouldShowPillAvatar?: boolean; +} + +interface IState { + // ID/alias of the room/user + resourceId: string; + // Type of pill + pillType: string; + // The member related to the user pill + member?: RoomMember; + // The room related to the room pill + room?: Room; + // Is the user hovering the pill + hover: boolean; +} -class Pill extends React.Component { - static roomNotifPos(text) { +export default class Pill extends React.Component { + private unmounted = true; + private matrixClient: MatrixClient; + + public static roomNotifPos(text: string): number { return text.indexOf("@room"); } - static roomNotifLen() { + public static roomNotifLen(): number { return "@room".length; } - static TYPE_USER_MENTION = 'TYPE_USER_MENTION'; - static TYPE_ROOM_MENTION = 'TYPE_ROOM_MENTION'; - static TYPE_AT_ROOM_MENTION = 'TYPE_AT_ROOM_MENTION'; // '@room' mention - - static propTypes = { - // The Type of this Pill. If url is given, this is auto-detected. - type: PropTypes.string, - // The URL to pillify (no validation is done) - url: PropTypes.string, - // Whether the pill is in a message - inMessage: PropTypes.bool, - // The room in which this pill is being rendered - room: PropTypes.instanceOf(Room), - // Whether to include an avatar in the pill - shouldShowPillAvatar: PropTypes.bool, - // Whether to render this pill as if it were highlit by a selection - isSelected: PropTypes.bool, - }; - - state = { - // ID/alias of the room/user - resourceId: null, - // Type of pill - pillType: null, + constructor(props: IProps) { + super(props); - // The member related to the user pill - member: null, - // The room related to the room pill - room: null, - // Is the user hovering the pill - hover: false, - }; + this.state = { + resourceId: null, + pillType: null, + member: null, + room: null, + hover: false, + }; + } // TODO: [REACT-WARNING] Replace with appropriate lifecycle event - // eslint-disable-next-line camelcase - async UNSAFE_componentWillReceiveProps(nextProps) { + // eslint-disable-next-line camelcase, @typescript-eslint/naming-convention + public async UNSAFE_componentWillReceiveProps(nextProps: IProps): Promise { let resourceId; let prefix; @@ -89,28 +104,28 @@ class Pill extends React.Component { } const pillType = this.props.type || { - '@': Pill.TYPE_USER_MENTION, - '#': Pill.TYPE_ROOM_MENTION, - '!': Pill.TYPE_ROOM_MENTION, + '@': PillType.UserMention, + '#': PillType.RoomMention, + '!': PillType.RoomMention, }[prefix]; let member; let room; switch (pillType) { - case Pill.TYPE_AT_ROOM_MENTION: { + case PillType.AtRoomMention: { room = nextProps.room; } break; - case Pill.TYPE_USER_MENTION: { + case PillType.UserMention: { const localMember = nextProps.room ? nextProps.room.getMember(resourceId) : undefined; member = localMember; if (!localMember) { member = new RoomMember(null, resourceId); this.doProfileLookup(resourceId, member); } - break; } - case Pill.TYPE_ROOM_MENTION: { + break; + case PillType.RoomMention: { const localRoom = resourceId[0] === '#' ? MatrixClientPeg.get().getRooms().find((r) => { return r.getCanonicalAlias() === resourceId || @@ -122,39 +137,39 @@ class Pill extends React.Component { // a room avatar and name. // this.doRoomProfileLookup(resourceId, member); } - break; } + break; } this.setState({ resourceId, pillType, member, room }); } - componentDidMount() { - this._unmounted = false; - this._matrixClient = MatrixClientPeg.get(); + public componentDidMount(): void { + this.unmounted = false; + this.matrixClient = MatrixClientPeg.get(); // eslint-disable-next-line new-cap this.UNSAFE_componentWillReceiveProps(this.props); // HACK: We shouldn't be calling lifecycle functions ourselves. } - componentWillUnmount() { - this._unmounted = true; + public componentWillUnmount(): void { + this.unmounted = true; } - onMouseOver = () => { + private onMouseOver = (): void => { this.setState({ hover: true, }); }; - onMouseLeave = () => { + private onMouseLeave = (): void => { this.setState({ hover: false, }); }; - doProfileLookup(userId, member) { + private doProfileLookup(userId: string, member): void { MatrixClientPeg.get().getProfileInfo(userId).then((resp) => { - if (this._unmounted) { + if (this.unmounted) { return; } member.name = resp.displayname; @@ -173,7 +188,7 @@ class Pill extends React.Component { }); } - onUserPillClicked = (e) => { + private onUserPillClicked = (e): void => { e.preventDefault(); dis.dispatch({ action: Action.ViewUser, @@ -181,7 +196,7 @@ class Pill extends React.Component { }); }; - render() { + public render(): JSX.Element { const resource = this.state.resourceId; let avatar = null; @@ -191,7 +206,7 @@ class Pill extends React.Component { let href = this.props.url; let onClick; switch (this.state.pillType) { - case Pill.TYPE_AT_ROOM_MENTION: { + case PillType.AtRoomMention: { const room = this.props.room; if (room) { linkText = "@room"; @@ -200,9 +215,9 @@ class Pill extends React.Component { } pillClass = 'mx_AtRoomPill'; } - break; } - case Pill.TYPE_USER_MENTION: { + break; + case PillType.UserMention: { // If this user is not a member of this room, default to the empty member const member = this.state.member; if (member) { @@ -210,15 +225,15 @@ class Pill extends React.Component { member.rawDisplayName = member.rawDisplayName || ''; linkText = member.rawDisplayName; if (this.props.shouldShowPillAvatar) { - avatar =
diff --git a/src/components/views/rooms/AppsDrawer.tsx b/src/components/views/rooms/AppsDrawer.tsx index 22afea24902..601cc9ee346 100644 --- a/src/components/views/rooms/AppsDrawer.tsx +++ b/src/components/views/rooms/AppsDrawer.tsx @@ -49,7 +49,7 @@ interface IState { // @ts-ignore - TS wants a string key, but we know better apps: {[id: Container]: IApp[]}; resizingVertical: boolean; // true when changing the height of the apps drawer - resizingHorizontal: boolean; // true when chagning the distribution of the width between widgets + resizingHorizontal: boolean; // true when changing the distribution of the width between widgets resizing: boolean; } @@ -259,7 +259,7 @@ export default class AppsDrawer extends React.Component { mx_AppsDrawer_2apps: apps.length === 2, mx_AppsDrawer_3apps: apps.length === 3, }); - const appConatiners = + const appContainers =
{ apps.map((app, i) => { if (i < 1) return app; @@ -272,7 +272,7 @@ export default class AppsDrawer extends React.Component { let drawer; if (widgetIsMaxmised) { - drawer = appConatiners; + drawer = appContainers; } else { drawer = { handleWrapperClass="mx_AppsContainer_resizerHandleContainer" className="mx_AppsContainer_resizer" resizeNotifier={this.props.resizeNotifier}> - { appConatiners } + { appContainers } ; } diff --git a/src/components/views/rooms/AuxPanel.tsx b/src/components/views/rooms/AuxPanel.tsx index 59567a6011a..18f77f342ef 100644 --- a/src/components/views/rooms/AuxPanel.tsx +++ b/src/components/views/rooms/AuxPanel.tsx @@ -116,7 +116,7 @@ export default class AuxPanel extends React.Component { const severity = ev.getContent().severity || "normal"; const stateKey = ev.getStateKey(); - // We want a non-empty title but can accept falsey values (e.g. + // We want a non-empty title but can accept falsy values (e.g. // zero) if (title && value !== undefined) { counters.push({ diff --git a/src/components/views/rooms/BasicMessageComposer.tsx b/src/components/views/rooms/BasicMessageComposer.tsx index e93119643fb..667d5a42a4e 100644 --- a/src/components/views/rooms/BasicMessageComposer.tsx +++ b/src/components/views/rooms/BasicMessageComposer.tsx @@ -24,15 +24,16 @@ import { logger } from "matrix-js-sdk/src/logger"; import EditorModel from '../../../editor/model'; import HistoryManager from '../../../editor/history'; import { Caret, setSelection } from '../../../editor/caret'; -import { formatRange, replaceRangeAndMoveCaret, toggleInlineFormat } from '../../../editor/operations'; +import { formatRange, formatRangeAsLink, replaceRangeAndMoveCaret, toggleInlineFormat } + from '../../../editor/operations'; import { getCaretOffsetAndText, getRangeForSelection } from '../../../editor/dom'; import Autocomplete, { generateCompletionDomId } from '../rooms/Autocomplete'; -import { getAutoCompleteCreator, Type } from '../../../editor/parts'; +import { getAutoCompleteCreator, Part, Type } from '../../../editor/parts'; import { parseEvent, parsePlainTextMessage } from '../../../editor/deserialize'; import { renderModel } from '../../../editor/render'; import TypingStore from "../../../stores/TypingStore"; import SettingsStore from "../../../settings/SettingsStore"; -import { Key } from "../../../Keyboard"; +import { IS_MAC, Key } from "../../../Keyboard"; import { EMOTICON_TO_EMOJI } from "../../../emoji"; import { CommandCategories, CommandMap, parseCommandString } from "../../../SlashCommands"; import Range from "../../../editor/range"; @@ -45,13 +46,12 @@ import { ICompletion } from "../../../autocomplete/Autocompleter"; import { getKeyBindingsManager } from '../../../KeyBindingsManager'; import { ALTERNATE_KEY_NAME, KeyBindingAction } from '../../../accessibility/KeyboardShortcuts'; import { _t } from "../../../languageHandler"; +import { linkify } from '../../../linkify-matrix'; // matches emoticons which follow the start of a line or whitespace const REGEX_EMOTICON_WHITESPACE = new RegExp('(?:^|\\s)(' + EMOTICON_REGEX.source + ')\\s|:^$'); export const REGEX_EMOTICON = new RegExp('(?:^|\\s)(' + EMOTICON_REGEX.source + ')$'); -const IS_MAC = navigator.platform.indexOf("Mac") !== -1; - const SURROUND_WITH_CHARACTERS = ["\"", "_", "`", "'", "*", "~", "$"]; const SURROUND_WITH_DOUBLE_CHARACTERS = new Map([ ["(", ")"], @@ -92,7 +92,7 @@ function selectionEquals(a: Partial, b: Selection): boolean { interface IProps { model: EditorModel; room: Room; - threadId: string; + threadId?: string; placeholder?: string; label?: string; initialCaret?: DocumentOffset; @@ -333,26 +333,32 @@ export default class BasicMessageEditor extends React.Component private onPaste = (event: ClipboardEvent): boolean => { event.preventDefault(); // we always handle the paste ourselves - if (this.props.onPaste && this.props.onPaste(event, this.props.model)) { + if (this.props.onPaste?.(event, this.props.model)) { // to prevent double handling, allow props.onPaste to skip internal onPaste return true; } const { model } = this.props; const { partCreator } = model; + const plainText = event.clipboardData.getData("text/plain"); const partsText = event.clipboardData.getData("application/x-element-composer"); - let parts; + + let parts: Part[]; if (partsText) { const serializedTextParts = JSON.parse(partsText); - const deserializedParts = serializedTextParts.map(p => partCreator.deserializePart(p)); - parts = deserializedParts; + parts = serializedTextParts.map(p => partCreator.deserializePart(p)); } else { - const text = event.clipboardData.getData("text/plain"); - parts = parsePlainTextMessage(text, partCreator, { shouldEscape: false }); + parts = parsePlainTextMessage(plainText, partCreator, { shouldEscape: false }); } + this.modifiedFlag = true; const range = getRangeForSelection(this.editorRef.current, model, document.getSelection()); - replaceRangeAndMoveCaret(range, parts); + + if (plainText && range.length > 0 && linkify.test(plainText)) { + formatRangeAsLink(range, plainText); + } else { + replaceRangeAndMoveCaret(range, parts); + } }; private onInput = (event: Partial): void => { diff --git a/src/components/views/rooms/CollapsibleButton.tsx b/src/components/views/rooms/CollapsibleButton.tsx index b9e9f083d0a..d89d277073f 100644 --- a/src/components/views/rooms/CollapsibleButton.tsx +++ b/src/components/views/rooms/CollapsibleButton.tsx @@ -20,27 +20,27 @@ import classNames from 'classnames'; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import { MenuItem } from "../../structures/ContextMenu"; import { OverflowMenuContext } from './MessageComposerButtons'; +import { IconizedContextMenuOption } from '../context_menus/IconizedContextMenu'; interface ICollapsibleButtonProps extends ComponentProps { title: string; + iconClassName: string; } -export const CollapsibleButton = ({ title, children, className, ...props }: ICollapsibleButtonProps) => { +export const CollapsibleButton = ({ title, children, className, iconClassName, ...props }: ICollapsibleButtonProps) => { const inOverflowMenu = !!useContext(OverflowMenuContext); if (inOverflowMenu) { - return - { title } - { children } - ; + iconClassName={iconClassName} + label={title} + />; } return { children } ; diff --git a/src/components/views/rooms/EventTile.tsx b/src/components/views/rooms/EventTile.tsx index a801c06a83b..def039fb74f 100644 --- a/src/components/views/rooms/EventTile.tsx +++ b/src/components/views/rooms/EventTile.tsx @@ -222,7 +222,7 @@ interface IProps { // whether or not to display thread info showThreadInfo?: boolean; - // if specified and `true`, the message his behing + // if specified and `true`, the message is being // hidden for moderation from other users but is // displayed to the current user either because they're // the author or they are a moderator @@ -244,7 +244,7 @@ interface IState { // Position of the context menu contextMenu?: { position: Pick; - showPermalink?: boolean; + link?: string; }; isQuoteExpanded?: boolean; @@ -411,15 +411,14 @@ export class UnwrappedEventTile extends React.Component { room?.on(ThreadEvent.New, this.onNewThread); } - private setupNotificationListener = (thread: Thread): void => { - const room = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId()); - const notifications = RoomNotificationStateStore.instance.getThreadsRoomState(room); + private setupNotificationListener(thread: Thread): void { + const notifications = RoomNotificationStateStore.instance.getThreadsRoomState(thread.room); this.threadState = notifications.getThreadRoomState(thread); this.threadState.on(NotificationStateEvents.Update, this.onThreadStateUpdate); this.onThreadStateUpdate(); - }; + } private onThreadStateUpdate = (): void => { let threadNotification = null; @@ -859,26 +858,27 @@ export class UnwrappedEventTile extends React.Component { }; private onTimestampContextMenu = (ev: React.MouseEvent): void => { - this.showContextMenu(ev, true); + this.showContextMenu(ev, this.props.permalinkCreator?.forEvent(this.props.mxEvent.getId())); }; - private showContextMenu(ev: React.MouseEvent, showPermalink?: boolean): void { + private showContextMenu(ev: React.MouseEvent, permalink?: string): void { + const clickTarget = ev.target as HTMLElement; + // Return if message right-click context menu isn't enabled if (!SettingsStore.getValue("feature_message_right_click_context_menu")) return; - // Return if we're in a browser and click either an a tag or we have - // selected text, as in those cases we want to use the native browser - // menu - const clickTarget = ev.target as HTMLElement; - if ( - !PlatformPeg.get().allowOverridingNativeContextMenus() && - (clickTarget.tagName === "a" || clickTarget.closest("a") || getSelectedText()) - ) return; + // Try to find an anchor element + const anchorElement = (clickTarget instanceof HTMLAnchorElement) ? clickTarget : clickTarget.closest("a"); // There is no way to copy non-PNG images into clipboard, so we can't // have our own handling for copying images, so we leave it to the // Electron layer (webcontents-handler.ts) - if (ev.target instanceof HTMLImageElement) return; + if (clickTarget instanceof HTMLImageElement) return; + + // Return if we're in a browser and click either an a tag or we have + // selected text, as in those cases we want to use the native browser + // menu + if (!PlatformPeg.get().allowOverridingNativeContextMenus() && (getSelectedText() || anchorElement)) return; // We don't want to show the menu when editing a message if (this.props.editState) return; @@ -892,7 +892,7 @@ export class UnwrappedEventTile extends React.Component { top: ev.clientY, bottom: ev.clientY, }, - showPermalink: showPermalink, + link: anchorElement?.href || permalink, }, actionBarFocused: true, }); @@ -941,7 +941,7 @@ export class UnwrappedEventTile extends React.Component { onFinished={this.onCloseMenu} rightClick={true} reactions={this.state.reactions} - showPermalink={this.state.contextMenu.showPermalink} + link={this.state.contextMenu.link} /> ); } @@ -990,7 +990,8 @@ export class UnwrappedEventTile extends React.Component { const isRedacted = isMessageEvent(this.props.mxEvent) && this.props.isRedacted; const isEncryptionFailure = this.props.mxEvent.isDecryptionFailure(); - const isOwnEvent = this.props.mxEvent?.sender?.userId === MatrixClientPeg.get().getUserId(); + // Use `getSender()` because searched events might not have a proper `sender`. + const isOwnEvent = this.props.mxEvent?.getSender() === MatrixClientPeg.get().getUserId(); const scBubbleEnabled = this.props.layout === Layout.Bubble && this.context.timelineRenderingType !== TimelineRenderingType.Notification && @@ -1059,6 +1060,11 @@ export class UnwrappedEventTile extends React.Component { } else if (this.context.timelineRenderingType === TimelineRenderingType.Notification) { avatarSize = 24; needsSenderProfile = true; + } else if (isInfoMessage) { + // a small avatar, with no sender profile, for + // joins/parts/etc + avatarSize = 14; + needsSenderProfile = false; } else if (this.context.timelineRenderingType === TimelineRenderingType.ThreadsList || (this.context.timelineRenderingType === TimelineRenderingType.Thread && !this.props.continuation) ) { @@ -1067,11 +1073,6 @@ export class UnwrappedEventTile extends React.Component { } else if (eventType === EventType.RoomCreate || isBubbleMessage) { avatarSize = 0; needsSenderProfile = false; - } else if (isInfoMessage) { - // a small avatar, with no sender profile, for - // joins/parts/etc - avatarSize = 14; - needsSenderProfile = false; } else if (this.props.layout == Layout.IRC) { avatarSize = 14; needsSenderProfile = true; @@ -1327,6 +1328,7 @@ export class UnwrappedEventTile extends React.Component { "data-has-reply": !!replyChain, "data-layout": this.props.layout, "data-self": isOwnEvent, + "data-event-id": this.props.mxEvent.getId(), "onMouseEnter": () => this.setState({ hover: true }), "onMouseLeave": () => this.setState({ hover: false }), }, [ @@ -1527,6 +1529,7 @@ export class UnwrappedEventTile extends React.Component { "data-scroll-tokens": scrollToken, "data-layout": this.props.layout, "data-self": isOwnEvent, + "data-event-id": this.props.mxEvent.getId(), "data-has-reply": !!replyChain, "onMouseEnter": () => this.setState({ hover: true }), "onMouseLeave": () => this.setState({ hover: false }), @@ -1579,6 +1582,7 @@ export class UnwrappedEventTile extends React.Component { "data-scroll-tokens": scrollToken, "data-layout": this.props.layout, "data-self": isOwnEvent, + "data-event-id": this.props.mxEvent.getId(), "data-has-reply": !!replyChain, "onMouseEnter": () => this.setState({ hover: true }), "onMouseLeave": () => this.setState({ hover: false }), diff --git a/src/components/views/rooms/MemberTile.tsx b/src/components/views/rooms/MemberTile.tsx index b652771a43e..f292ec3b589 100644 --- a/src/components/views/rooms/MemberTile.tsx +++ b/src/components/views/rooms/MemberTile.tsx @@ -20,12 +20,10 @@ import { RoomMember } from "matrix-js-sdk/src/models/room-member"; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { EventType } from "matrix-js-sdk/src/@types/event"; import { DeviceInfo } from "matrix-js-sdk/src/crypto/deviceinfo"; -import { UserEvent } from "matrix-js-sdk/src/models/user"; import { CryptoEvent } from "matrix-js-sdk/src/crypto"; import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state"; import { UserTrustLevel } from 'matrix-js-sdk/src/crypto/CrossSigning'; -import SettingsStore from "../../../settings/SettingsStore"; import dis from "../../../dispatcher/dispatcher"; import { _t } from '../../../languageHandler'; import { MatrixClientPeg } from "../../../MatrixClientPeg"; @@ -41,7 +39,6 @@ interface IProps { } interface IState { - statusMessage: string; isRoomEncrypted: boolean; e2eStatus: string; } @@ -58,7 +55,6 @@ export default class MemberTile extends React.Component { super(props); this.state = { - statusMessage: this.getStatusMessage(), isRoomEncrypted: false, e2eStatus: null, }; @@ -67,13 +63,6 @@ export default class MemberTile extends React.Component { componentDidMount() { const cli = MatrixClientPeg.get(); - if (SettingsStore.getValue("feature_custom_status")) { - const { user } = this.props.member; - if (user) { - user.on(UserEvent._UnstableStatusMessage, this.onStatusMessageCommitted); - } - } - const { roomId } = this.props.member; if (roomId) { const isRoomEncrypted = cli.isRoomEncrypted(roomId); @@ -94,11 +83,6 @@ export default class MemberTile extends React.Component { componentWillUnmount() { const cli = MatrixClientPeg.get(); - const { user } = this.props.member; - if (user) { - user.removeListener(UserEvent._UnstableStatusMessage, this.onStatusMessageCommitted); - } - if (cli) { cli.removeListener(RoomStateEvent.Events, this.onRoomStateEvents); cli.removeListener(CryptoEvent.UserTrustStatusChanged, this.onUserTrustStatusChanged); @@ -158,21 +142,6 @@ export default class MemberTile extends React.Component { }); } - private getStatusMessage(): string { - const { user } = this.props.member; - if (!user) { - return ""; - } - return user.unstable_statusMessage; - } - - private onStatusMessageCommitted = (): void => { - // The `User` object has observed a status message change. - this.setState({ - statusMessage: this.getStatusMessage(), - }); - }; - shouldComponentUpdate(nextProps: IProps, nextState: IState): boolean { if ( this.memberLastModifiedTime === undefined || @@ -222,11 +191,6 @@ export default class MemberTile extends React.Component { const name = this.getDisplayName(); const presenceState = member.user ? member.user.presence : null; - let statusMessage = null; - if (member.user && SettingsStore.getValue("feature_custom_status")) { - statusMessage = this.state.statusMessage; - } - const av = (