diff --git a/.eslintrc.js b/.eslintrc.js index f3e38f94c3c..bcf20ab5a62 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -115,6 +115,8 @@ module.exports = { "@typescript-eslint/no-explicit-any": "off", // We'd rather not do this but we do "@typescript-eslint/ban-ts-comment": "off", + // We're okay with assertion errors when we ask for them + "@typescript-eslint/no-non-null-assertion": "off", }, }, // temporary override for offending icon require files diff --git a/.github/workflows/element-build-and-test.yaml b/.github/workflows/element-build-and-test.yaml index a1047094158..9ed06bd8ad9 100644 --- a/.github/workflows/element-build-and-test.yaml +++ b/.github/workflows/element-build-and-test.yaml @@ -71,6 +71,7 @@ jobs: # to run the tests, so use chrome. browser: chrome start: npx serve -p 8080 webapp + wait-on: 'http://localhost:8080' record: true command-prefix: 'yarn percy exec --' env: @@ -83,6 +84,8 @@ jobs: 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 }} + # make Node's os.tmpdir() return something where we actually have permissions + TMPDIR: ${{ runner.temp }} - name: Upload Artifact if: failure() diff --git a/.github/workflows/i18n_check.yml b/.github/workflows/i18n_check.yml new file mode 100644 index 00000000000..2acfb126850 --- /dev/null +++ b/.github/workflows/i18n_check.yml @@ -0,0 +1,40 @@ +name: i18n Check +on: + workflow_call: { } +jobs: + check: + runs-on: ubuntu-latest + permissions: + pull-requests: read + steps: + - uses: actions/checkout@v3 + + - name: "Get modified files" + id: changed_files + if: github.event_name == 'pull_request' && github.event.pull_request.user.login != 'RiotTranslateBot' + uses: tj-actions/changed-files@v19 + with: + files: | + src/i18n/strings/* + files_ignore: | + src/i18n/strings/en_EN.json + + - name: "Assert only en_EN was modified" + if: | + github.event_name == 'pull_request' && + github.event.pull_request.user.login != 'RiotTranslateBot' && + steps.changed_files.outputs.any_modified == 'true' + run: | + echo "Only translation files modified by `yarn i18n` can be committed - other translation files will confuse weblate in unrecoverable ways." + exit 1 + + - uses: actions/setup-node@v3 + with: + cache: 'yarn' + + # Does not need branch matching as only analyses this layer + - name: Install Deps + run: "yarn install --pure-lockfile" + + - name: i18n Check + run: "yarn run diff-i18n" diff --git a/.github/workflows/pull_request.yaml b/.github/workflows/pull_request.yaml index 8d115062ea6..d861ea054e6 100644 --- a/.github/workflows/pull_request.yaml +++ b/.github/workflows/pull_request.yaml @@ -2,6 +2,7 @@ name: Pull Request on: pull_request_target: types: [ opened, edited, labeled, unlabeled, synchronize ] +concurrency: ${{ github.workflow }}-${{ github.event.pull_request.head.ref }} jobs: changelog: name: Preview Changelog diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml index 95b06bab6b5..11660e68ba4 100644 --- a/.github/workflows/sonarqube.yml +++ b/.github/workflows/sonarqube.yml @@ -5,7 +5,7 @@ on: types: - completed concurrency: - group: ${{ github.workflow }}-${{ github.ref }} + group: ${{ github.workflow }}-${{ github.event.workflow_run.head_branch }} cancel-in-progress: true jobs: prdetails: diff --git a/.github/workflows/static_analysis.yaml b/.github/workflows/static_analysis.yaml index 266f7c728ad..82a99190a43 100644 --- a/.github/workflows/static_analysis.yaml +++ b/.github/workflows/static_analysis.yaml @@ -39,41 +39,7 @@ jobs: i18n_lint: name: "i18n Check" - runs-on: ubuntu-latest - permissions: - pull-requests: read - steps: - - uses: actions/checkout@v2 - - - name: "Get modified files" - id: changed_files - if: github.event_name == 'pull_request' && github.actor != 'RiotTranslateBot' - uses: tj-actions/changed-files@v19 - with: - files: | - src/i18n/strings/* - files_ignore: | - src/i18n/strings/en_EN.json - - - name: "Assert only en_EN was modified" - 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 - - - uses: actions/setup-node@v3 - with: - cache: 'yarn' - - # Does not need branch matching as only analyses this layer - - name: Install Deps - run: "yarn install" - - - name: i18n Check - run: "yarn run diff-i18n" + uses: matrix-org/matrix-react-sdk/.github/workflows/i18n_check.yml@develop js_lint: name: "ESLint" diff --git a/.gitignore b/.gitignore index 1c4eb114e7d..024057643b7 100644 --- a/.gitignore +++ b/.gitignore @@ -27,5 +27,4 @@ package-lock.json /cypress/synapselogs # These could have files in them but don't currently # Cypress will still auto-create them though... -/cypress/fixtures /cypress/performance diff --git a/CHANGELOG.md b/CHANGELOG.md index d6ad5a02863..3474847522e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,57 @@ +Changes in [3.46.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.46.0) (2022-06-07) +===================================================================================================== + +## ✨ Features + * Configure custom home.html via `.well-known/matrix/client["io.element.embedded_pages"]["home_url"]` for all your element-web/desktop users ([\#7790](https://github.com/matrix-org/matrix-react-sdk/pull/7790)). Contributed by @johannes-krude. + * Live location sharing - open location in OpenStreetMap ([\#8695](https://github.com/matrix-org/matrix-react-sdk/pull/8695)). Contributed by @kerryarchibald. + * Show a dialog when Jitsi encounters an error ([\#8701](https://github.com/matrix-org/matrix-react-sdk/pull/8701)). Fixes vector-im/element-web#22284. + * Add support for setting the `avatar_url` of widgets by integration managers. ([\#8550](https://github.com/matrix-org/matrix-react-sdk/pull/8550)). Contributed by @Fox32. + * Add an option to ignore (block) a user when reporting their events ([\#8471](https://github.com/matrix-org/matrix-react-sdk/pull/8471)). + * Add the option to disable hardware acceleration ([\#8655](https://github.com/matrix-org/matrix-react-sdk/pull/8655)). Contributed by @novocaine. + * Slightly better presentation of read receipts to screen reader users ([\#8662](https://github.com/matrix-org/matrix-react-sdk/pull/8662)). Fixes vector-im/element-web#22293. Contributed by @pvagner. + * Add jump to related event context menu item ([\#6775](https://github.com/matrix-org/matrix-react-sdk/pull/6775)). Fixes vector-im/element-web#19883. + * Add public room directory hook ([\#8626](https://github.com/matrix-org/matrix-react-sdk/pull/8626)). + +## 🐛 Bug Fixes + * Remove inline margin from UTD error message inside a reply tile on ThreadView ([\#8708](https://github.com/matrix-org/matrix-react-sdk/pull/8708)). Fixes vector-im/element-web#22376. Contributed by @luixxiul. + * Move unread notification dots of the threads list to the expected position ([\#8700](https://github.com/matrix-org/matrix-react-sdk/pull/8700)). Fixes vector-im/element-web#22350. Contributed by @luixxiul. + * Prevent overflow of grid items on a bubble with UTD generally ([\#8697](https://github.com/matrix-org/matrix-react-sdk/pull/8697)). Contributed by @luixxiul. + * Create 'Unable To Decrypt' grid layout for hidden events on a bubble layout ([\#8704](https://github.com/matrix-org/matrix-react-sdk/pull/8704)). Fixes vector-im/element-web#22365. Contributed by @luixxiul. + * Fix - AccessibleButton does not set disabled attribute ([\#8682](https://github.com/matrix-org/matrix-react-sdk/pull/8682)). Contributed by @kerryarchibald. + * Fix font not resetting when logging out ([\#8670](https://github.com/matrix-org/matrix-react-sdk/pull/8670)). Fixes vector-im/element-web#17228. + * Fix local aliases section of room settings not working for some homeservers (ie ([\#8698](https://github.com/matrix-org/matrix-react-sdk/pull/8698)). Fixes vector-im/element-web#22337. + * Align EventTile_line with display name on message bubble ([\#8692](https://github.com/matrix-org/matrix-react-sdk/pull/8692)). Fixes vector-im/element-web#22343. Contributed by @luixxiul. + * Convert references to direct chat -> direct message ([\#8694](https://github.com/matrix-org/matrix-react-sdk/pull/8694)). Contributed by @novocaine. + * Improve combining diacritics for U+20D0 to U+20F0 in Chrome ([\#8687](https://github.com/matrix-org/matrix-react-sdk/pull/8687)). + * Make the empty thread panel fill BaseCard ([\#8690](https://github.com/matrix-org/matrix-react-sdk/pull/8690)). Fixes vector-im/element-web#22338. Contributed by @luixxiul. + * Fix edge case around composer handling gendered facepalm emoji ([\#8686](https://github.com/matrix-org/matrix-react-sdk/pull/8686)). + * Fix a grid blowout due to nowrap displayName on a bubble with UTD ([\#8688](https://github.com/matrix-org/matrix-react-sdk/pull/8688)). Fixes vector-im/element-web#21914. Contributed by @luixxiul. + * Apply the same max-width to image tile on the thread timeline as message bubble ([\#8669](https://github.com/matrix-org/matrix-react-sdk/pull/8669)). Fixes vector-im/element-web#22313. Contributed by @luixxiul. + * Fix dropdown button size for picture-in-picture CallView ([\#8680](https://github.com/matrix-org/matrix-react-sdk/pull/8680)). Fixes vector-im/element-web#22316. Contributed by @luixxiul. + * Live location sharing - fix square border for image-less avatar (PSF-1052) ([\#8679](https://github.com/matrix-org/matrix-react-sdk/pull/8679)). Contributed by @kerryarchibald. + * Stop connecting to a video room if the widget messaging disappears ([\#8660](https://github.com/matrix-org/matrix-react-sdk/pull/8660)). + * Fix file button and audio player overflowing from message bubble ([\#8666](https://github.com/matrix-org/matrix-react-sdk/pull/8666)). Fixes vector-im/element-web#22308. Contributed by @luixxiul. + * Don't show broken composer format bar when selection is whitespace ([\#8673](https://github.com/matrix-org/matrix-react-sdk/pull/8673)). Fixes vector-im/element-web#10788. + * Fix media upload http 413 handling ([\#8674](https://github.com/matrix-org/matrix-react-sdk/pull/8674)). + * Fix emoji picker for editing thread responses ([\#8671](https://github.com/matrix-org/matrix-react-sdk/pull/8671)). Fixes matrix-org/element-web-rageshakes#13129. + * Map attribution while sharing live location is now visible ([\#8621](https://github.com/matrix-org/matrix-react-sdk/pull/8621)). Fixes vector-im/element-web#22236. Contributed by @weeman1337. + * Fix info tile overlapping the time stamp on TimelineCard ([\#8639](https://github.com/matrix-org/matrix-react-sdk/pull/8639)). Fixes vector-im/element-web#22256. Contributed by @luixxiul. + * Fix position of wide images on IRC / modern layout ([\#8667](https://github.com/matrix-org/matrix-react-sdk/pull/8667)). Fixes vector-im/element-web#22309. Contributed by @luixxiul. + * Fix other user's displayName being wrapped on the bubble message layout ([\#8456](https://github.com/matrix-org/matrix-react-sdk/pull/8456)). Fixes vector-im/element-web#22004. Contributed by @luixxiul. + * Set spacing declarations to elements in mx_EventTile_mediaLine ([\#8665](https://github.com/matrix-org/matrix-react-sdk/pull/8665)). Fixes vector-im/element-web#22307. Contributed by @luixxiul. + * Fix wide image overflowing from the thumbnail container ([\#8663](https://github.com/matrix-org/matrix-react-sdk/pull/8663)). Fixes vector-im/element-web#22303. Contributed by @luixxiul. + * Fix styles of "Show all" link button on ReactionsRow ([\#8658](https://github.com/matrix-org/matrix-react-sdk/pull/8658)). Fixes vector-im/element-web#22300. Contributed by @luixxiul. + * Automatically log in after registration ([\#8654](https://github.com/matrix-org/matrix-react-sdk/pull/8654)). Fixes vector-im/element-web#19305. Contributed by @justjanne. + * Fix offline status in window title not working reliably ([\#8656](https://github.com/matrix-org/matrix-react-sdk/pull/8656)). + * Align input area with event body's first letter in a thread on IRC/modern layout ([\#8636](https://github.com/matrix-org/matrix-react-sdk/pull/8636)). Fixes vector-im/element-web#22252. Contributed by @luixxiul. + * Fix crash on null idp for SSO buttons ([\#8650](https://github.com/matrix-org/matrix-react-sdk/pull/8650)). Contributed by @hughns. + * Don't open the regular browser or our context menu on right-clicking the `Options` button in the message action bar ([\#8648](https://github.com/matrix-org/matrix-react-sdk/pull/8648)). Fixes vector-im/element-web#22279. + * Show notifications even when Element is focused ([\#8590](https://github.com/matrix-org/matrix-react-sdk/pull/8590)). Contributed by @sumnerevans. + * Remove padding from the buttons on edit message composer of a event tile on a thread ([\#8632](https://github.com/matrix-org/matrix-react-sdk/pull/8632)). Contributed by @luixxiul. + * ensure metaspace changes correctly notify listeners ([\#8611](https://github.com/matrix-org/matrix-react-sdk/pull/8611)). Fixes vector-im/element-web#21006. Contributed by @justjanne. + * Hide image banner on stickers, they have a tooltip already ([\#8641](https://github.com/matrix-org/matrix-react-sdk/pull/8641)). Fixes vector-im/element-web#22244. + * Adjust EditMessageComposer style declarations ([\#8631](https://github.com/matrix-org/matrix-react-sdk/pull/8631)). Fixes vector-im/element-web#22231. Contributed by @luixxiul. + Changes in [3.45.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.45.0) (2022-05-24) ===================================================================================================== diff --git a/README.md b/README.md index 1602476c153..77ff075cd4c 100644 --- a/README.md +++ b/README.md @@ -20,17 +20,18 @@ a 'skin'. A skin provides: * The containing application * Zero or more 'modules' containing non-UI functionality -As of Aug 2018, the only skin that exists is [`vector-im/element-web`](https://github.com/vector-im/element-web/); it and +As of Aug 2018, the only skin that exists is +[`vector-im/element-web`](https://github.com/vector-im/element-web/); it and `matrix-org/matrix-react-sdk` should effectively be considered as a single project (for instance, matrix-react-sdk bugs are currently filed against vector-im/element-web rather than this project). Translation Status -================== +------------------ [![Translation status](https://translate.element.io/widgets/element-web/-/multi-auto.svg)](https://translate.element.io/engage/element-web/?utm_source=widget) Developer Guide -=============== +--------------- Platform Targets: * Chrome, Firefox and Safari. @@ -49,21 +50,25 @@ Please follow the Matrix JS/React code style as per: https://github.com/matrix-org/matrix-react-sdk/blob/master/code_style.md Code should be committed as follows: - * All new components: https://github.com/matrix-org/matrix-react-sdk/tree/master/src/components - * Element-specific components: https://github.com/vector-im/element-web/tree/master/src/components - * In practice, `matrix-react-sdk` is still evolving so fast that the maintenance - burden of customising and overriding these components for Element can seriously - impede development. So right now, there should be very few (if any) customisations for Element. + * All new components: + https://github.com/matrix-org/matrix-react-sdk/tree/master/src/components + * Element-specific components: + https://github.com/vector-im/element-web/tree/master/src/components + * In practice, `matrix-react-sdk` is still evolving so fast that the + maintenance burden of customising and overriding these components for + Element can seriously impede development. So right now, there should be + very few (if any) customisations for Element. * CSS: https://github.com/matrix-org/matrix-react-sdk/tree/master/res/css - * Theme specific CSS & resources: https://github.com/matrix-org/matrix-react-sdk/tree/master/res/themes + * Theme specific CSS & resources: + https://github.com/matrix-org/matrix-react-sdk/tree/master/res/themes React components in matrix-react-sdk come in two different flavours: 'structures' and 'views'. Structures are stateful components which handle the more complicated business logic of the app, delegating their actual presentation rendering to stateless 'view' components. For instance, the RoomView component -that orchestrates the act of visualising the contents of a given Matrix chat room -tracks lots of state for its child components which it passes into them for visual -rendering via props. +that orchestrates the act of visualising the contents of a given Matrix chat +room tracks lots of state for its child components which it passes into them for +visual rendering via props. Good separation between the components is maintained by adopting various best practices that anyone working with the SDK needs to be aware of and uphold: @@ -80,18 +85,19 @@ practices that anyone working with the SDK needs to be aware of and uphold: * Per-view CSS is optional - it could choose to inherit all its styling from the context of the rest of the app, although this is unusual for any but - * Theme specific CSS & resources: https://github.com/matrix-org/matrix-react-sdk/tree/master/res/themes - structural components (lacking presentation logic) and the simplest view - components. + * Theme specific CSS & resources: + https://github.com/matrix-org/matrix-react-sdk/tree/master/res/themes + structural components (lacking presentation logic) and the simplest view + components. * The view MUST *only* refer to the CSS rules defined in its own CSS file. 'Stealing' styling information from other components (including parents) is not cool, as it breaks the independence of the components. - * CSS classes are named with an app-specific name-spacing prefix to try to avoid - CSS collisions. The base skin shipped by Matrix.org with the matrix-react-sdk - uses the naming prefix "mx_". A company called Yoyodyne Inc might use a - prefix like "yy_" for its app-specific classes. + * CSS classes are named with an app-specific name-spacing prefix to try to + avoid CSS collisions. The base skin shipped by Matrix.org with the + matrix-react-sdk uses the naming prefix "mx_". A company called Yoyodyne + Inc might use a prefix like "yy_" for its app-specific classes. * CSS classes use upper camel case when they describe React components - e.g. .mx_MessageTile is the selector for the CSS applied to a MessageTile view. @@ -128,13 +134,13 @@ the distinction between 'structural' and 'view' components, so we backed away from it. Github Issues -============= +------------- All issues should be filed under https://github.com/vector-im/element-web/issues for now. Development -=========== +----------- Ensure you have the latest LTS version of Node.js installed. @@ -143,9 +149,10 @@ guide](https://classic.yarnpkg.com/docs/install) if you do not have it already. This project has not yet been migrated to Yarn 2, so please ensure `yarn --version` shows a version from the 1.x series. -`matrix-react-sdk` depends on [`matrix-js-sdk`](https://github.com/matrix-org/matrix-js-sdk). To make use of changes in the -latter and to ensure tests run against the develop branch of `matrix-js-sdk`, -you should set up `matrix-js-sdk`: +`matrix-react-sdk` depends on +[`matrix-js-sdk`](https://github.com/matrix-org/matrix-js-sdk). To make use of +changes in the latter and to ensure tests run against the develop branch of +`matrix-js-sdk`, you should set up `matrix-js-sdk`: ```bash git clone https://github.com/matrix-org/matrix-js-sdk @@ -168,8 +175,7 @@ yarn install See the [help for `yarn link`](https://classic.yarnpkg.com/docs/cli/link) for more details about this. -Running tests -============= +### Running tests Ensure you've followed the above development instructions and then: @@ -177,7 +183,32 @@ Ensure you've followed the above development instructions and then: yarn test ``` -## End-to-End tests +### Running lint -Make sure you've got your Element development server running (by doing `yarn start` in element-web), and then in this project, run `yarn run e2etests`. -See [`test/end-to-end-tests/README.md`](https://github.com/matrix-org/matrix-react-sdk/blob/develop/test/end-to-end-tests/README.md) for more information. +To check your code complies with the project style, ensure you've followed the +above development instructions and then: + +```bash +yarn lint +``` + +### Dependency problems + +If you see errors (particularly "Cannot find module") running the lint or test +commands, and `yarn install` doesn't fix them, it may be because +yarn is not fetching git dependencies eagerly enough. + +Try running this: + +```bash +yarn cache clean && yarn install --force +``` + +Now the yarn commands should work as normal. + +### End-to-End tests + +Make sure you've got your Element development server running (by doing `yarn +start` in element-web), and then in this project, run `yarn run e2etests`. See +[`test/end-to-end-tests/README.md`](https://github.com/matrix-org/matrix-react-sdk/blob/develop/test/end-to-end-tests/README.md) +for more information. diff --git a/cypress/fixtures/riot.png b/cypress/fixtures/riot.png new file mode 100644 index 00000000000..ee42954c782 Binary files /dev/null and b/cypress/fixtures/riot.png differ diff --git a/cypress/global.d.ts b/cypress/global.d.ts index efbb255b081..a3e91a2b44c 100644 --- a/cypress/global.d.ts +++ b/cypress/global.d.ts @@ -16,7 +16,9 @@ limitations under the License. import "matrix-js-sdk/src/@types/global"; import type { MatrixClient, ClientEvent } from "matrix-js-sdk/src/client"; +import type { MatrixScheduler, MemoryCryptoStore, MemoryStore, RoomStateEvent } from "matrix-js-sdk/src/matrix"; import type { RoomMemberEvent } from "matrix-js-sdk/src/models/room-member"; +import type { WebStorageSessionStore } from "matrix-js-sdk/src/store/session/webstorage"; import type { MatrixDispatcher } from "../src/dispatcher/dispatcher"; import type PerformanceMonitor from "../src/performance"; @@ -35,6 +37,11 @@ declare global { MatrixClient: typeof MatrixClient; ClientEvent: typeof ClientEvent; RoomMemberEvent: typeof RoomMemberEvent; + RoomStateEvent: typeof RoomStateEvent; + MatrixScheduler: typeof MatrixScheduler; + MemoryStore: typeof MemoryStore; + MemoryCryptoStore: typeof MemoryCryptoStore; + WebStorageSessionStore: typeof WebStorageSessionStore; }; } } diff --git a/cypress/integration/2-login/login.spec.ts b/cypress/integration/2-login/login.spec.ts index 521eb66f25a..85d2866e498 100644 --- a/cypress/integration/2-login/login.spec.ts +++ b/cypress/integration/2-login/login.spec.ts @@ -59,4 +59,45 @@ describe("Login", () => { cy.stopMeasuring("from-submit-to-home"); }); }); + + describe("logout", () => { + beforeEach(() => { + cy.initTestUser(synapse, "Erin"); + }); + + it("should go to login page on logout", () => { + cy.get('[aria-label="User menu"]').click(); + + // give a change for the outstanding requests queue to settle before logging out + cy.wait(500); + + cy.get(".mx_UserMenu_contextMenu").within(() => { + cy.get(".mx_UserMenu_iconSignOut").click(); + }); + + cy.url().should("contain", "/#/login"); + }); + + it("should respect logout_redirect_url", () => { + cy.tweakConfig({ + // We redirect to decoder-ring because it's a predictable page that isn't Element itself. + // We could use example.org, matrix.org, or something else, however this puts dependency of external + // infrastructure on our tests. In the same vein, we don't really want to figure out how to ship a + // `test-landing.html` page when running with an uncontrolled Element (via `yarn start`). + // Using the decoder-ring is just as fine, and we can search for strategic names. + logout_redirect_url: "/decoder-ring/", + }); + + cy.get('[aria-label="User menu"]').click(); + + // give a change for the outstanding requests queue to settle before logging out + cy.wait(500); + + cy.get(".mx_UserMenu_contextMenu").within(() => { + cy.get(".mx_UserMenu_iconSignOut").click(); + }); + + cy.url().should("contains", "decoder-ring"); + }); + }); }); diff --git a/cypress/integration/3-user-menu/user-menu.spec.ts b/cypress/integration/3-user-menu/user-menu.spec.ts index 671fd4eacf3..40b4a0cd74c 100644 --- a/cypress/integration/3-user-menu/user-menu.spec.ts +++ b/cypress/integration/3-user-menu/user-menu.spec.ts @@ -39,7 +39,7 @@ describe("User Menu", () => { it("should contain our name & userId", () => { cy.get('[aria-label="User menu"]').click(); - cy.get(".mx_ContextualMenu").within(() => { + cy.get(".mx_UserMenu_contextMenu").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/5-threads/threads.spec.ts b/cypress/integration/5-threads/threads.spec.ts index 43b0058bb11..226e63576d8 100644 --- a/cypress/integration/5-threads/threads.spec.ts +++ b/cypress/integration/5-threads/threads.spec.ts @@ -87,8 +87,8 @@ describe("Threads", () => { 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"); + cy.get(".mx_RoomView_body .mx_EventTile").contains(".mx_EventTile[data-scroll-tokens]", "Hello Mr. Bot") + .invoke("attr", "data-scroll-tokens").as("threadId"); // Bot starts thread cy.get("@threadId").then(threadId => { @@ -111,7 +111,7 @@ describe("Threads", () => { 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") + cy.get(".mx_ThreadView .mx_EventTile").contains(".mx_EventTile_line", "Hello there") .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"); @@ -119,7 +119,7 @@ describe("Threads", () => { }); // User redacts their prior response - cy.get(".mx_ThreadView .mx_EventTile").contains("Test").closest(".mx_EventTile_line") + cy.get(".mx_ThreadView .mx_EventTile").contains(".mx_EventTile_line", "Test") .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(); @@ -166,7 +166,7 @@ describe("Threads", () => { 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(".mx_ThreadView .mx_EventTile_last").contains(".mx_EventTile_line", "Great!").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}"); }); diff --git a/cypress/integration/6-spaces/spaces.spec.ts b/cypress/integration/6-spaces/spaces.spec.ts new file mode 100644 index 00000000000..e5c03229bf2 --- /dev/null +++ b/cypress/integration/6-spaces/spaces.spec.ts @@ -0,0 +1,244 @@ +/* +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 { MatrixClient } from "matrix-js-sdk/src/client"; +import type { ICreateRoomOpts } from "matrix-js-sdk/src/@types/requests"; +import { SynapseInstance } from "../../plugins/synapsedocker"; +import Chainable = Cypress.Chainable; +import { UserCredentials } from "../../support/login"; + +function openSpaceCreateMenu(): Chainable { + cy.get(".mx_SpaceButton_new").click(); + return cy.get(".mx_SpaceCreateMenu_wrapper .mx_ContextualMenu"); +} + +function getSpacePanelButton(spaceName: string): Chainable { + return cy.get(`.mx_SpaceButton[aria-label="${spaceName}"]`); +} + +function openSpaceContextMenu(spaceName: string): Chainable { + getSpacePanelButton(spaceName).rightclick(); + return cy.get(".mx_SpacePanel_contextMenu"); +} + +function spaceCreateOptions(spaceName: string): ICreateRoomOpts { + return { + creation_content: { + type: "m.space", + }, + initial_state: [{ + type: "m.room.name", + content: { + name: spaceName, + }, + }], + }; +} + +function spaceChildInitialState(roomId: string): ICreateRoomOpts["initial_state"]["0"] { + return { + type: "m.space.child", + state_key: roomId, + content: { + via: [roomId.split(":")[1]], + }, + }; +} + +describe("Spaces", () => { + let synapse: SynapseInstance; + let user: UserCredentials; + + beforeEach(() => { + cy.startSynapse("default").then(data => { + synapse = data; + + cy.initTestUser(synapse, "Sue").then(_user => { + user = _user; + cy.mockClipboard(); + }); + }); + }); + + afterEach(() => { + cy.stopSynapse(synapse); + }); + + it("should allow user to create public space", () => { + openSpaceCreateMenu().within(() => { + cy.get(".mx_SpaceCreateMenuType_public").click(); + cy.get('.mx_SpaceBasicSettings_avatarContainer input[type="file"]') + .selectFile("cypress/fixtures/riot.png", { force: true }); + cy.get('input[label="Name"]').type("Let's have a Riot"); + cy.get('input[label="Address"]').should("have.value", "lets-have-a-riot"); + cy.get('textarea[label="Description"]').type("This is a space to reminisce Riot.im!"); + cy.get(".mx_AccessibleButton").contains("Create").click(); + }); + + // Create the default General & Random rooms, as well as a custom "Jokes" room + cy.get('input[label="Room name"][value="General"]').should("exist"); + cy.get('input[label="Room name"][value="Random"]').should("exist"); + cy.get('input[placeholder="Support"]').type("Jokes"); + cy.get(".mx_AccessibleButton").contains("Continue").click(); + + // Copy matrix.to link + cy.get(".mx_SpacePublicShare_shareButton").focus().realClick(); + cy.getClipboardText().should("eq", "https://matrix.to/#/#lets-have-a-riot:localhost"); + + // Go to space home + cy.get(".mx_AccessibleButton").contains("Go to my first room").click(); + + // Assert rooms exist in the room list + cy.get(".mx_RoomTile").contains("General").should("exist"); + cy.get(".mx_RoomTile").contains("Random").should("exist"); + cy.get(".mx_RoomTile").contains("Jokes").should("exist"); + }); + + it("should allow user to create private space", () => { + openSpaceCreateMenu().within(() => { + cy.get(".mx_SpaceCreateMenuType_private").click(); + cy.get('.mx_SpaceBasicSettings_avatarContainer input[type="file"]') + .selectFile("cypress/fixtures/riot.png", { force: true }); + cy.get('input[label="Name"]').type("This is not a Riot"); + cy.get('input[label="Address"]').should("not.exist"); + cy.get('textarea[label="Description"]').type("This is a private space of mourning Riot.im..."); + cy.get(".mx_AccessibleButton").contains("Create").click(); + }); + + cy.get(".mx_SpaceRoomView_privateScope_meAndMyTeammatesButton").click(); + + // Create the default General & Random rooms, as well as a custom "Projects" room + cy.get('input[label="Room name"][value="General"]').should("exist"); + cy.get('input[label="Room name"][value="Random"]').should("exist"); + cy.get('input[placeholder="Support"]').type("Projects"); + cy.get(".mx_AccessibleButton").contains("Continue").click(); + + cy.get(".mx_SpaceRoomView").should("contain", "Invite your teammates"); + cy.get(".mx_AccessibleButton").contains("Skip for now").click(); + + // Assert rooms exist in the room list + cy.get(".mx_RoomTile").contains("General").should("exist"); + cy.get(".mx_RoomTile").contains("Random").should("exist"); + cy.get(".mx_RoomTile").contains("Projects").should("exist"); + + // Assert rooms exist in the space explorer + cy.get(".mx_SpaceHierarchy_roomTile").contains("General").should("exist"); + cy.get(".mx_SpaceHierarchy_roomTile").contains("Random").should("exist"); + cy.get(".mx_SpaceHierarchy_roomTile").contains("Projects").should("exist"); + }); + + it("should allow user to create just-me space", () => { + cy.createRoom({ + name: "Sample Room", + }); + + openSpaceCreateMenu().within(() => { + cy.get(".mx_SpaceCreateMenuType_private").click(); + cy.get('.mx_SpaceBasicSettings_avatarContainer input[type="file"]') + .selectFile("cypress/fixtures/riot.png", { force: true }); + cy.get('input[label="Address"]').should("not.exist"); + cy.get('textarea[label="Description"]').type("This is a personal space to mourn Riot.im..."); + cy.get('input[label="Name"]').type("This is my Riot{enter}"); + }); + + cy.get(".mx_SpaceRoomView_privateScope_justMeButton").click(); + + cy.get(".mx_AddExistingToSpace_entry").click(); + cy.get(".mx_AccessibleButton").contains("Add").click(); + + cy.get(".mx_RoomTile").contains("Sample Room").should("exist"); + cy.get(".mx_SpaceHierarchy_roomTile").contains("Sample Room").should("exist"); + }); + + it("should allow user to invite another to a space", () => { + let bot: MatrixClient; + cy.getBot(synapse, "BotBob").then(_bot => { + bot = _bot; + }); + + cy.createSpace({ + visibility: "public" as any, + room_alias_name: "space", + }).as("spaceId"); + + openSpaceContextMenu("#space:localhost").within(() => { + cy.get('.mx_SpacePanel_contextMenu_inviteButton[aria-label="Invite"]').click(); + }); + + cy.get(".mx_SpacePublicShare").within(() => { + // Copy link first + cy.get(".mx_SpacePublicShare_shareButton").focus().realClick(); + cy.getClipboardText().should("eq", "https://matrix.to/#/#space:localhost"); + // Start Matrix invite flow + cy.get(".mx_SpacePublicShare_inviteButton").click(); + }); + + cy.get(".mx_InviteDialog_other").within(() => { + cy.get('input[type="text"]').type(bot.getUserId()); + cy.get(".mx_AccessibleButton").contains("Invite").click(); + }); + + cy.get(".mx_InviteDialog_other").should("not.exist"); + }); + + it("should show space invites at the top of the space panel", () => { + cy.createSpace({ + name: "My Space", + }); + getSpacePanelButton("My Space").should("exist"); + + cy.getBot(synapse, "BotBob").then({ timeout: 10000 }, async bot => { + const { room_id: roomId } = await bot.createRoom(spaceCreateOptions("Space Space")); + await bot.invite(roomId, user.userId); + }); + // Assert that `Space Space` is above `My Space` due to it being an invite + getSpacePanelButton("Space Space").should("exist") + .parent().next().find('.mx_SpaceButton[aria-label="My Space"]').should("exist"); + }); + + it("should include rooms in space home", () => { + cy.createRoom({ + name: "Music", + }).as("roomId1"); + cy.createRoom({ + name: "Gaming", + }).as("roomId2"); + + const spaceName = "Spacey Mc. Space Space"; + cy.all([ + cy.get("@roomId1"), + cy.get("@roomId2"), + ]).then(([roomId1, roomId2]) => { + cy.createSpace({ + name: spaceName, + initial_state: [ + spaceChildInitialState(roomId1), + spaceChildInitialState(roomId2), + ], + }).as("spaceId"); + }); + + cy.get("@spaceId").then(() => { + getSpacePanelButton(spaceName).dblclick(); // Open space home + }); + cy.get(".mx_SpaceRoomView .mx_SpaceHierarchy_list").within(() => { + cy.get(".mx_SpaceHierarchy_roomTile").contains("Music").should("exist"); + cy.get(".mx_SpaceHierarchy_roomTile").contains("Gaming").should("exist"); + }); + }); +}); diff --git a/cypress/integration/7-crypto/crypto.spec.ts b/cypress/integration/7-crypto/crypto.spec.ts new file mode 100644 index 00000000000..2446e3bd2bb --- /dev/null +++ b/cypress/integration/7-crypto/crypto.spec.ts @@ -0,0 +1,82 @@ +/* +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 { MatrixClient } from "matrix-js-sdk/src/matrix"; +import { SynapseInstance } from "../../plugins/synapsedocker"; + +function waitForEncryption(cli: MatrixClient, roomId: string, win: Cypress.AUTWindow, resolve: () => void) { + cli.crypto.cryptoStore.getEndToEndRooms(null, (result) => { + if (result[roomId]) { + resolve(); + } else { + cli.once(win.matrixcs.RoomStateEvent.Update, () => waitForEncryption(cli, roomId, win, resolve)); + } + }); +} + +describe("Cryptography", () => { + beforeEach(() => { + cy.startSynapse("default").as('synapse').then( + synapse => cy.initTestUser(synapse, "Alice"), + ); + }); + + afterEach(() => { + cy.get('@synapse').then(synapse => cy.stopSynapse(synapse)); + }); + + it("should receive and decrypt encrypted messages", () => { + cy.get('@synapse').then(synapse => cy.getBot(synapse, "Beatrice").as('bot')); + + cy.createRoom({ + initial_state: [ + { + type: "m.room.encryption", + state_key: '', + content: { + algorithm: "m.megolm.v1.aes-sha2", + }, + }, + ], + }).as('roomId'); + + cy.all([ + cy.get('@bot'), + cy.get('@roomId'), + cy.window(), + ]).then(([bot, roomId, win]) => { + cy.inviteUser(roomId, bot.getUserId()); + cy.visit("/#/room/" + roomId); + cy.wrap( + new Promise(resolve => + waitForEncryption(bot, roomId, win, resolve), + ).then(() => bot.sendMessage(roomId, { + body: "Top secret message", + msgtype: "m.text", + })), + ); + }); + + cy.get(".mx_RoomView_body .mx_cryptoEvent").should("contain", "Encryption enabled"); + + cy.get(".mx_EventTile_body") + .contains("Top secret message") + .closest(".mx_EventTile_line") + .should("not.have.descendants", ".mx_EventTile_e2eIcon_warning"); + }); +}); diff --git a/cypress/integration/8-update/update.spec.ts b/cypress/integration/8-update/update.spec.ts new file mode 100644 index 00000000000..75977763cc9 --- /dev/null +++ b/cypress/integration/8-update/update.spec.ts @@ -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. +*/ + +/// + +import { SynapseInstance } from "../../plugins/synapsedocker"; + +describe("Update", () => { + let synapse: SynapseInstance; + + beforeEach(() => { + cy.startSynapse("default").then(data => { + synapse = data; + }); + }); + + afterEach(() => { + cy.stopSynapse(synapse); + }); + + it("should navigate to ?updated=$VERSION if realises it is immediately out of date on load", () => { + const NEW_VERSION = "some-new-version"; + + cy.intercept("/version*", { + statusCode: 200, + body: NEW_VERSION, + headers: { + "Content-Type": "test/plain", + }, + }).as("version"); + + cy.initTestUser(synapse, "Ursa"); + + cy.wait("@version"); + cy.url().should("contain", "updated=" + NEW_VERSION).then(href => { + const url = new URL(href); + expect(url.searchParams.get("updated")).to.equal(NEW_VERSION); + }); + }); +}); diff --git a/cypress/plugins/synapsedocker/index.ts b/cypress/plugins/synapsedocker/index.ts index 292c74ee670..7108ade904a 100644 --- a/cypress/plugins/synapsedocker/index.ts +++ b/cypress/plugins/synapsedocker/index.ts @@ -66,9 +66,6 @@ async function cfgDirFromTemplate(template: string): Promise { } const tempDir = await fse.mkdtemp(path.join(os.tmpdir(), 'react-sdk-synapsedocker-')); - // change permissions on the temp directory so the docker container can see its contents - await fse.chmod(tempDir, 0o777); - // copy the contents of the template dir, omitting homeserver.yaml as we'll template that console.log(`Copy ${templateDir} -> ${tempDir}`); await fse.copy(templateDir, tempDir, { filter: f => path.basename(f) !== 'homeserver.yaml' }); @@ -113,6 +110,7 @@ async function synapseStart(template: string): Promise { console.log(`Starting synapse with config dir ${synCfg.configDir}...`); const containerName = `react-sdk-cypress-synapse-${crypto.randomBytes(4).toString("hex")}`; + const userInfo = os.userInfo(); const synapseId = await new Promise((resolve, reject) => { childProcess.execFile('docker', [ @@ -121,6 +119,8 @@ async function synapseStart(template: string): Promise { "-d", "-v", `${synCfg.configDir}:/data`, "-p", `${synCfg.port}:8008/tcp`, + // We run the docker container as our uid:gid otherwise cleaning it up its media_store can be difficult + "-u", `${userInfo.uid}:${userInfo.gid}`, "matrixdotorg/synapse:develop", "run", ], (err, stdout) => { @@ -129,8 +129,6 @@ async function synapseStart(template: string): Promise { }); }); - synapses.set(synapseId, { synapseId, ...synCfg }); - console.log(`Started synapse with id ${synapseId} on port ${synCfg.port}.`); // Await Synapse healthcheck @@ -150,7 +148,9 @@ async function synapseStart(template: string): Promise { }); }); - return synapses.get(synapseId); + const synapse: SynapseInstance = { synapseId, ...synCfg }; + synapses.set(synapseId, synapse); + return synapse; } async function synapseStop(id: string): Promise { diff --git a/cypress/support/app.ts b/cypress/support/app.ts new file mode 100644 index 00000000000..21321e2b56f --- /dev/null +++ b/cypress/support/app.ts @@ -0,0 +1,43 @@ +/* +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; +import AUTWindow = Cypress.AUTWindow; + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Cypress { + interface Chainable { + /** + * Applies tweaks to the config read from config.json + */ + tweakConfig(tweaks: Record): Chainable; + } + } +} + +Cypress.Commands.add("tweakConfig", (tweaks: Record): Chainable => { + return cy.window().then(win => { + // note: we can't *set* the object because the window version is effectively a pointer. + for (const [k, v] of Object.entries(tweaks)) { + // @ts-ignore - for some reason it's not picking up on global.d.ts types. + win.mxReactSdkConfig[k] = v; + } + }); +}); diff --git a/cypress/support/bot.ts b/cypress/support/bot.ts index a2488c0081e..e3bdf49d312 100644 --- a/cypress/support/bot.ts +++ b/cypress/support/bot.ts @@ -20,6 +20,7 @@ import request from "browser-request"; import type { MatrixClient } from "matrix-js-sdk/src/client"; import { SynapseInstance } from "../plugins/synapsedocker"; +import { MockStorage } from "./storage"; import Chainable = Cypress.Chainable; declare global { @@ -47,6 +48,10 @@ Cypress.Commands.add("getBot", (synapse: SynapseInstance, displayName?: string): deviceId: credentials.deviceId, accessToken: credentials.accessToken, request, + store: new win.matrixcs.MemoryStore(), + scheduler: new win.matrixcs.MatrixScheduler(), + cryptoStore: new win.matrixcs.MemoryCryptoStore(), + sessionStore: new win.matrixcs.WebStorageSessionStore(new MockStorage()), }); cli.on(win.matrixcs.RoomMemberEvent.Membership, (event, member) => { @@ -55,9 +60,12 @@ Cypress.Commands.add("getBot", (synapse: SynapseInstance, displayName?: string): } }); - cli.startClient(); - - return cli; + return cy.wrap( + cli.initCrypto() + .then(() => cli.setGlobalErrorOnUnknownDevices(false)) + .then(() => cli.startClient()) + .then(() => cli), + ); }); }); }); diff --git a/cypress/support/client.ts b/cypress/support/client.ts index 682f3ee426c..6a6a3932711 100644 --- a/cypress/support/client.ts +++ b/cypress/support/client.ts @@ -35,6 +35,12 @@ declare global { * @return the ID of the newly created room */ createRoom(options: ICreateRoomOpts): Chainable; + /** + * Create a space with given options. + * @param options the options to apply when creating the space + * @return the ID of the newly created space (room) + */ + createSpace(options: ICreateRoomOpts): Chainable; /** * Invites the given user to the given room. * @param roomId the id of the room to invite to @@ -71,6 +77,15 @@ Cypress.Commands.add("createRoom", (options: ICreateRoomOpts): Chainable }); }); +Cypress.Commands.add("createSpace", (options: ICreateRoomOpts): Chainable => { + return cy.createRoom({ + ...options, + creation_content: { + "type": "m.space", + }, + }); +}); + 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/clipboard.ts b/cypress/support/clipboard.ts new file mode 100644 index 00000000000..5e80ed8361d --- /dev/null +++ b/cypress/support/clipboard.ts @@ -0,0 +1,57 @@ +/* +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; + +// Mock the clipboard, as only Electron gives the app permission to the clipboard API by default +// Virtual clipboard +let copyText: string; + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Cypress { + interface Chainable { + /** + * Mock the clipboard on the current window, ready for calling `getClipboardText`. + * Irreversible, refresh the window to restore mock. + */ + mockClipboard(): Chainable; + /** + * Read text from the mocked clipboard. + * @return {string} the clipboard text + */ + getClipboardText(): Chainable; + } + } +} + +Cypress.Commands.add("mockClipboard", () => { + cy.window({ log: false }).then(win => { + win.navigator.clipboard.writeText = (text) => { + copyText = text; + return Promise.resolve(); + }; + }); +}); + +Cypress.Commands.add("getClipboardText", (): Chainable => { + return cy.wrap(copyText); +}); + +// Needed to make this file a module +export { }; diff --git a/cypress/support/index.ts b/cypress/support/index.ts index dd8e5cab991..06d6efc252b 100644 --- a/cypress/support/index.ts +++ b/cypress/support/index.ts @@ -17,6 +17,7 @@ limitations under the License. /// import "@percy/cypress"; +import "cypress-real-events"; import "./performance"; import "./synapse"; @@ -24,3 +25,6 @@ import "./login"; import "./client"; import "./settings"; import "./bot"; +import "./clipboard"; +import "./util"; +import "./app"; diff --git a/cypress/support/settings.ts b/cypress/support/settings.ts index 11f48c2db26..4be44e27117 100644 --- a/cypress/support/settings.ts +++ b/cypress/support/settings.ts @@ -16,7 +16,6 @@ limitations under the License. /// -import "./client"; // XXX: without an (any) import here, types break down import Chainable = Cypress.Chainable; declare global { @@ -99,3 +98,6 @@ Cypress.Commands.add("leaveBeta", (name: string): Chainable> return cy.get(".mx_BetaCard_buttons").contains("Leave the beta").click(); }); }); + +// Needed to make this file a module +export { }; diff --git a/cypress/support/storage.ts b/cypress/support/storage.ts new file mode 100644 index 00000000000..d93e1188351 --- /dev/null +++ b/cypress/support/storage.ts @@ -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. +*/ + +export class MockStorage implements Storage { + private data: Record = {}; + private keys: string[] = []; + public length = 0; + + constructor() {} + + public setItem(k: string, v: string) { + this.data[k] = v; + this.recalc(); + } + + public getItem(k: string): string | null { + return this.data[k] || null; + } + + public removeItem(k: string) { + delete this.data[k]; + this.recalc(); + } + + public clear() { + this.data = {}; + this.recalc(); + } + + public key(index: number): string { + return this.keys[index]; + } + + private recalc() { + const keys = []; + for (const k in this.data) { + if (!this.data.hasOwnProperty(k)) { + continue; + } + keys.push(k); + } + this.keys = keys; + this.length = keys.length; + } +} diff --git a/cypress/support/util.ts b/cypress/support/util.ts new file mode 100644 index 00000000000..e8f48b4bcc1 --- /dev/null +++ b/cypress/support/util.ts @@ -0,0 +1,82 @@ +/* +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. +*/ + +/// + +// @see https://github.com/cypress-io/cypress/issues/915#issuecomment-475862672 +// Modified due to changes to `cy.queue` https://github.com/cypress-io/cypress/pull/17448 +// Note: this DOES NOT run Promises in parallel like `Promise.all` due to the nature +// of Cypress promise-like objects and command queue. This only makes it convenient to use the same +// API but runs the commands sequentially. + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Cypress { + type ChainableValue = T extends Cypress.Chainable ? V : T; + + interface cy { + all( + commands: T + ): Cypress.Chainable<{ [P in keyof T]: ChainableValue }>; + queue: any; + } + + interface Chainable { + chainerId: string; + } + } +} + +const chainStart = Symbol("chainStart"); + +/** + * @description Returns a single Chainable that resolves when all of the Chainables pass. + * @param {Cypress.Chainable[]} commands - List of Cypress.Chainable to resolve. + * @returns {Cypress.Chainable} Cypress when all Chainables are resolved. + */ +cy.all = function all(commands): Cypress.Chainable { + const chain = cy.wrap(null, { log: false }); + const stopCommand = Cypress._.find(cy.queue.get(), { + attributes: { chainerId: chain.chainerId }, + }); + const startCommand = Cypress._.find(cy.queue.get(), { + attributes: { chainerId: commands[0].chainerId }, + }); + const p = chain.then(() => { + return cy.wrap( + // @see https://lodash.com/docs/4.17.15#lodash + Cypress._(commands) + .map(cmd => { + return cmd[chainStart] + ? cmd[chainStart].attributes + : Cypress._.find(cy.queue.get(), { + attributes: { chainerId: cmd.chainerId }, + }).attributes; + }) + .concat(stopCommand.attributes) + .slice(1) + .map(cmd => { + return cmd.prev.get("subject"); + }) + .value(), + ); + }); + p[chainStart] = startCommand; + return p; +}; + +// Needed to make this file a module +export { }; diff --git a/package.json b/package.json index 2745cf91fef..d092ba88d9b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-react-sdk", - "version": "3.45.0", + "version": "3.46.0", "description": "SDK for matrix.org using React", "author": "matrix.org", "repository": { @@ -91,7 +91,7 @@ "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": "18.0.0", + "matrix-js-sdk": "18.1.0", "matrix-widget-api": "^0.1.0-beta.18", "minimist": "^1.2.5", "opus-recorder": "^8.0.3", @@ -170,6 +170,7 @@ "blob-polyfill": "^6.0.20211015", "chokidar": "^3.5.1", "cypress": "^9.6.1", + "cypress-real-events": "^1.7.0", "enzyme": "^3.11.0", "enzyme-to-json": "^3.6.2", "eslint": "8.9.0", @@ -188,7 +189,7 @@ "jest-mock": "^27.5.1", "jest-raw-loader": "^1.0.1", "jest-sonar-reporter": "^2.0.0", - "matrix-mock-request": "^1.2.3", + "matrix-mock-request": "^2.0.0", "matrix-react-test-utils": "^0.2.3", "matrix-web-i18n": "^1.2.0", "raw-loader": "^4.0.2", diff --git a/release.sh b/release.sh index 4742f00deab..8725c2b6131 100755 --- a/release.sh +++ b/release.sh @@ -2,7 +2,7 @@ # # Script to perform a release of matrix-react-sdk. # -# Requires githib-changelog-generator; to install, do +# Requires githib-changelog-generator; to install, do # pip install git+https://github.com/matrix-org/github-changelog-generator.git set -e @@ -37,7 +37,7 @@ do fi done -./node_modules/matrix-js-sdk/release.sh -z "$@" +./node_modules/matrix-js-sdk/release.sh "$@" release="${1#v}" prerelease=0 diff --git a/res/css/_components.scss b/res/css/_components.scss index 1b1b87b39d4..5d1a51e0b55 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -14,11 +14,13 @@ @import "./components/views/beacon/_LiveTimeRemaining.scss"; @import "./components/views/beacon/_OwnBeaconStatus.scss"; @import "./components/views/beacon/_RoomLiveShareWarning.scss"; +@import "./components/views/beacon/_ShareLatestLocation.scss"; @import "./components/views/beacon/_StyledLiveBeaconIcon.scss"; @import "./components/views/location/_EnableLiveShare.scss"; @import "./components/views/location/_LiveDurationDropdown.scss"; @import "./components/views/location/_LocationShareMenu.scss"; @import "./components/views/location/_MapError.scss"; +@import "./components/views/location/_MapFallback.scss"; @import "./components/views/location/_Marker.scss"; @import "./components/views/location/_ShareDialogButtons.scss"; @import "./components/views/location/_ShareType.scss"; @@ -133,7 +135,6 @@ @import "./views/dialogs/_UploadConfirmDialog.scss"; @import "./views/dialogs/_UserSettingsDialog.scss"; @import "./views/dialogs/_WidgetCapabilitiesPromptDialog.scss"; -@import "./views/dialogs/_WidgetOpenIDPermissionsDialog.scss"; @import "./views/dialogs/security/_AccessSecretStorageDialog.scss"; @import "./views/dialogs/security/_CreateCrossSigningDialog.scss"; @import "./views/dialogs/security/_CreateKeyBackupDialog.scss"; @@ -161,6 +162,7 @@ @import "./views/elements/_InlineSpinner.scss"; @import "./views/elements/_InteractiveTooltip.scss"; @import "./views/elements/_InviteReason.scss"; +@import "./views/elements/_LabelledCheckbox.scss"; @import "./views/elements/_ManageIntegsButton.scss"; @import "./views/elements/_MiniAvatarUploader.scss"; @import "./views/elements/_Pill.scss"; diff --git a/res/css/components/views/beacon/_BeaconListItem.scss b/res/css/components/views/beacon/_BeaconListItem.scss index dd99192cf56..00f8bcbe5b6 100644 --- a/res/css/components/views/beacon/_BeaconListItem.scss +++ b/res/css/components/views/beacon/_BeaconListItem.scss @@ -36,6 +36,7 @@ limitations under the License. margin-right: $spacing-8; border: 2px solid $location-live-color; + border-radius: 50%; } .mx_BeaconListItem_info { diff --git a/res/css/components/views/beacon/_BeaconStatusTooltip.scss b/res/css/components/views/beacon/_BeaconStatusTooltip.scss index 07b3a43cc01..d6ed72e4552 100644 --- a/res/css/components/views/beacon/_BeaconStatusTooltip.scss +++ b/res/css/components/views/beacon/_BeaconStatusTooltip.scss @@ -21,11 +21,6 @@ limitations under the License. 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 { diff --git a/res/css/components/views/beacon/_BeaconViewDialog.scss b/res/css/components/views/beacon/_BeaconViewDialog.scss index 6ad1a2a6139..0fff7210533 100644 --- a/res/css/components/views/beacon/_BeaconViewDialog.scss +++ b/res/css/components/views/beacon/_BeaconViewDialog.scss @@ -59,23 +59,6 @@ limitations under the License. border-radius: 8px; } -.mx_BeaconViewDialog_mapFallback { - box-sizing: border-box; - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - - background: url('$(res)/img/location/map.svg'); - background-size: cover; -} - -.mx_BeaconViewDialog_mapFallbackIcon { - width: 65px; - margin-bottom: $spacing-16; - color: $quaternary-content; -} - .mx_BeaconViewDialog_mapFallbackMessage { color: $secondary-content; margin-bottom: $spacing-16; diff --git a/res/css/components/views/beacon/_DialogOwnBeaconStatus.scss b/res/css/components/views/beacon/_DialogOwnBeaconStatus.scss index 791e276f050..1ed370919e9 100644 --- a/res/css/components/views/beacon/_DialogOwnBeaconStatus.scss +++ b/res/css/components/views/beacon/_DialogOwnBeaconStatus.scss @@ -46,6 +46,7 @@ limitations under the License. box-sizing: border-box; border: 2px solid $location-live-color; + border-radius: 50%; margin: $spacing-8 0 $spacing-8 0; } diff --git a/res/css/views/dialogs/_WidgetOpenIDPermissionsDialog.scss b/res/css/components/views/beacon/_ShareLatestLocation.scss similarity index 63% rename from res/css/views/dialogs/_WidgetOpenIDPermissionsDialog.scss rename to res/css/components/views/beacon/_ShareLatestLocation.scss index a419c105a9a..5d037fdbd55 100644 --- a/res/css/views/dialogs/_WidgetOpenIDPermissionsDialog.scss +++ b/res/css/components/views/beacon/_ShareLatestLocation.scss @@ -1,5 +1,5 @@ /* -Copyright 2019 Travis Ralston +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. @@ -14,15 +14,15 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_WidgetOpenIDPermissionsDialog .mx_SettingsFlag { - .mx_ToggleSwitch { - display: inline-block; - vertical-align: middle; - margin-right: 8px; - } +.mx_ShareLatestLocation_icon { + height: 13px; + width: 13px; + color: $secondary-content; +} - .mx_SettingsFlag_label { - display: inline-block; - vertical-align: middle; +.mx_ShareLatestLocation_copy { + // override copyable text style to make compact + .mx_CopyableText_copyButton { + margin-left: $spacing-8 !important; } } diff --git a/res/css/components/views/beacon/_StyledLiveBeaconIcon.scss b/res/css/components/views/beacon/_StyledLiveBeaconIcon.scss index 9096c3c71f4..5f9a1920e8b 100644 --- a/res/css/components/views/beacon/_StyledLiveBeaconIcon.scss +++ b/res/css/components/views/beacon/_StyledLiveBeaconIcon.scss @@ -23,7 +23,7 @@ limitations under the License. border-radius: 50%; background-color: $location-live-color; - border-color: $location-live-secondary-color; + border-color: $location-live-color; padding: 2px; // colors icon color: white; diff --git a/res/css/components/views/location/_MapFallback.scss b/res/css/components/views/location/_MapFallback.scss new file mode 100644 index 00000000000..b40e98c6922 --- /dev/null +++ b/res/css/components/views/location/_MapFallback.scss @@ -0,0 +1,45 @@ +/* +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_MapFallback { + box-sizing: border-box; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + position: relative; + z-index: 0; + + background-color: $system; +} + +.mx_MapFallback_bg { + position: absolute; + top: 0; + left: 0; + min-height: 100%; + min-width: 100%; + color: $quinary-content; + z-index: -1; + + pointer-events: none; +} + +.mx_MapFallback_icon { + width: 65px; + margin-bottom: $spacing-16; + color: $quaternary-content; +} diff --git a/res/css/components/views/location/_ShareType.scss b/res/css/components/views/location/_ShareType.scss index 458be106eb0..6b39f1a80a4 100644 --- a/res/css/components/views/location/_ShareType.scss +++ b/res/css/components/views/location/_ShareType.scss @@ -63,14 +63,6 @@ limitations under the License. &:hover, &:focus { border-color: $accent; } - - // this style is only during active development - // when lab is enabled but feature not fully implemented - // pin drop option will be disabled - &.mx_AccessibleButton_disabled { - pointer-events: none; - opacity: 0.4; - } } .mx_ShareType_option-icon { diff --git a/res/css/components/views/messages/_MBeaconBody.scss b/res/css/components/views/messages/_MBeaconBody.scss index dc63d6676db..5654f14a057 100644 --- a/res/css/components/views/messages/_MBeaconBody.scss +++ b/res/css/components/views/messages/_MBeaconBody.scss @@ -26,30 +26,17 @@ limitations under the License. .mx_MBeaconBody_map { height: 100%; width: 100%; - z-index: 0; // keeps the entire map under the message action bar + z-index: 0; // keeps the entire map under the message action bars - &:not(.mx_MBeaconBody_mapFallback) { - cursor: pointer; - } + cursor: pointer; } .mx_MBeaconBody_mapFallback { - box-sizing: border-box; - display: flex; - justify-content: center; - align-items: center; - // pushes spinner/icon up // to appear more centered with the footer padding-bottom: 50px; - background: url('$(res)/img/location/map.svg'); - background-size: cover; -} - -.mx_MBeaconBody_mapFallbackIcon { - width: 65px; - color: $quaternary-content; + cursor: default; } .mx_MBeaconBody_chin { diff --git a/res/css/views/context_menus/_MessageContextMenu.scss b/res/css/views/context_menus/_MessageContextMenu.scss index b92ce10d355..90a1c58ee36 100644 --- a/res/css/views/context_menus/_MessageContextMenu.scss +++ b/res/css/views/context_menus/_MessageContextMenu.scss @@ -109,4 +109,8 @@ limitations under the License. .mx_MessageContextMenu_iconViewInRoom::before { mask-image: url('$(res)/img/element-icons/view-in-room.svg'); } + + .mx_MessageContextMenu_jumpToEvent::before { + mask-image: url('$(res)/img/element-icons/child-relationship.svg'); + } } diff --git a/res/css/views/dialogs/_WidgetCapabilitiesPromptDialog.scss b/res/css/views/dialogs/_WidgetCapabilitiesPromptDialog.scss index 8786defed38..c559467c9de 100644 --- a/res/css/views/dialogs/_WidgetCapabilitiesPromptDialog.scss +++ b/res/css/views/dialogs/_WidgetCapabilitiesPromptDialog.scss @@ -46,10 +46,6 @@ limitations under the License. font-size: $font-12px; .mx_ToggleSwitch { - display: inline-block; - vertical-align: middle; - margin-right: 8px; - // downsize the switch + ball width: $font-32px; height: $font-15px; @@ -64,10 +60,5 @@ limitations under the License. border-radius: $font-15px; } } - - .mx_SettingsFlag_label { - display: inline-block; - vertical-align: middle; - } } } diff --git a/res/css/views/elements/_LabelledCheckbox.scss b/res/css/views/elements/_LabelledCheckbox.scss new file mode 100644 index 00000000000..c97b1b341a1 --- /dev/null +++ b/res/css/views/elements/_LabelledCheckbox.scss @@ -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. +*/ + +.mx_LabelledCheckbox { + display: flex; + flex-direction: row; + + .mx_Checkbox { + margin-top: 3px; // visually align with label text + } + + .mx_LabelledCheckbox_labels { + flex: 1; + + .mx_LabelledCheckbox_label { + vertical-align: middle; + } + + .mx_LabelledCheckbox_byline { + display: block; + padding-top: $spacing-4; + color: $muted-fg-color; + font-size: $font-11px; + } + } +} diff --git a/res/css/views/elements/_SettingsFlag.scss b/res/css/views/elements/_SettingsFlag.scss index c6f4cf6ec5c..6d941e94eb5 100644 --- a/res/css/views/elements/_SettingsFlag.scss +++ b/res/css/views/elements/_SettingsFlag.scss @@ -24,6 +24,19 @@ limitations under the License. .mx_ToggleSwitch { flex: 0 0 auto; } + + &.mx_SettingsFlag_toggleInFront { + .mx_ToggleSwitch { + display: inline-block; + vertical-align: middle; + margin-right: 8px; + } + + .mx_SettingsFlag_label { + display: inline-block; + vertical-align: middle; + } + } } .mx_SettingsFlag_label { diff --git a/res/css/views/messages/_DisambiguatedProfile.scss b/res/css/views/messages/_DisambiguatedProfile.scss index caef2fa4ad3..cb605726dd9 100644 --- a/res/css/views/messages/_DisambiguatedProfile.scss +++ b/res/css/views/messages/_DisambiguatedProfile.scss @@ -18,15 +18,18 @@ limitations under the License. .mx_DisambiguatedProfile { overflow: hidden; text-overflow: ellipsis; + white-space: nowrap; + cursor: pointer; .mx_DisambiguatedProfile_displayName { font-weight: 600; + margin-inline-end: 0; } .mx_DisambiguatedProfile_mxid { font-weight: 600; font-size: 1.1rem; - margin-left: 5px; + margin-inline-start: 5px; opacity: 0.5; // Match mx_TextualEvent color: $primary-content; } diff --git a/res/css/views/messages/_MImageBody.scss b/res/css/views/messages/_MImageBody.scss index 0cbcbd46582..7cd34cba612 100644 --- a/res/css/views/messages/_MImageBody.scss +++ b/res/css/views/messages/_MImageBody.scss @@ -60,12 +60,6 @@ $timeline-image-border-radius: $border-radius-8px; // Necessary for the border radius to apply correctly to the placeholder overflow: hidden; contain: paint; - - min-height: $font-44px; - min-width: $font-44px; - display: flex; - justify-content: center; - align-items: center; } .mx_MImageBody_thumbnail { diff --git a/res/css/views/messages/_MImageReplyBody.scss b/res/css/views/messages/_MImageReplyBody.scss index 2bdf571f0d1..3207443d65b 100644 --- a/res/css/views/messages/_MImageReplyBody.scss +++ b/res/css/views/messages/_MImageReplyBody.scss @@ -20,10 +20,6 @@ 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/_ReactionsRow.scss b/res/css/views/messages/_ReactionsRow.scss index d4695888dfc..591abce1435 100644 --- a/res/css/views/messages/_ReactionsRow.scss +++ b/res/css/views/messages/_ReactionsRow.scss @@ -57,15 +57,16 @@ limitations under the License. } .mx_ReactionsRow_showAll { - @mixin ButtonResetDefault; - text-decoration: none; - font-size: $font-12px; - line-height: $font-20px; - margin-left: 4px; - vertical-align: middle; color: $tertiary-content; - &:hover { - color: $primary-content; + &.mx_AccessibleButton_kind_link_inline { + font-size: $font-12px; + line-height: $font-20px; + margin-inline-start: $spacing-4; + vertical-align: middle; + + &:hover { + color: $primary-content; + } } } diff --git a/res/css/views/right_panel/_BaseCard.scss b/res/css/views/right_panel/_BaseCard.scss index a615f5a8814..894138add20 100644 --- a/res/css/views/right_panel/_BaseCard.scss +++ b/res/css/views/right_panel/_BaseCard.scss @@ -16,6 +16,7 @@ limitations under the License. .mx_BaseCard { --BaseCard_EventTile_line-padding-block: 2px; + --BaseCard_EventTile-spacing-horizontal: 36px; padding: 0 8px; overflow: hidden; @@ -24,7 +25,9 @@ limitations under the License. flex: 1; .mx_BaseCard_header { - margin: 4px 0; + --BaseCard_header_button-margin: $spacing-12; + + margin: $spacing-4 0; > h2 { margin: 0 44px; @@ -35,12 +38,13 @@ limitations under the License. white-space: nowrap; } - .mx_BaseCard_back, .mx_BaseCard_close { + .mx_BaseCard_back, + .mx_BaseCard_close { position: absolute; background-color: rgba(141, 151, 165, 0.2); height: 20px; width: 20px; - margin: 12px; + margin: var(--BaseCard_header_button-margin); top: 0; border-radius: $border-radius-10px; diff --git a/res/css/views/right_panel/_ThreadPanel.scss b/res/css/views/right_panel/_ThreadPanel.scss index bab7c2e608c..ddc30fa8d9e 100644 --- a/res/css/views/right_panel/_ThreadPanel.scss +++ b/res/css/views/right_panel/_ThreadPanel.scss @@ -15,32 +15,38 @@ limitations under the License. */ .mx_ThreadPanel { + --ThreadPanel_header-button-size: 24px; + display: flex; flex-direction: column; height: 100px; overflow: visible; .mx_BaseCard_header { - margin-bottom: 12px; + margin-bottom: $spacing-12; .mx_BaseCard_close, .mx_BaseCard_back { - width: 24px; - height: 24px; + width: var(--ThreadPanel_header-button-size); + height: var(--ThreadPanel_header-button-size); } .mx_BaseCard_back { - left: -4px; + margin-inline-start: calc(var(--BaseCard_header_button-margin) - 4px); } .mx_BaseCard_close { - right: -4px; + margin-inline-end: calc(var(--BaseCard_header_button-margin) - 4px); } } .mx_BaseCard_back ~ .mx_ThreadPanel__header { width: calc(100% - 60px); - margin-left: 30px; + margin-inline-start: var(--ThreadPanel_header-button-size); + + span { + margin-inline-start: 6px; + } } .mx_ThreadPanel__header { @@ -68,42 +74,16 @@ limitations under the License. } .mx_MessageActionBar_maskButton { - --size: 24px; - width: var(--size); - height: var(--size); + width: var(--ThreadPanel_header-button-size); + height: var(--ThreadPanel_header-button-size); &::after { - mask-size: var(--size); + mask-size: var(--ThreadPanel_header-button-size); mask-image: url("$(res)/img/element-icons/message/overflow-large.svg"); } } } - .mx_ThreadPanel_button { - width: 20px; - height: 20px; - margin-top: -3px; - margin-bottom: auto; - position: relative; - - &::before { - top: 2px; - left: 2px; - content: ''; - width: 16px; - height: 16px; - position: absolute; - mask-position: center; - mask-size: contain; - mask-repeat: no-repeat; - background: $primary-content; - } - - &.mx_ThreadPanel_OptionsButton::before { - mask-image: url('$(res)/img/element-icons/context-menu.svg'); - } - } - .mx_AutoHideScrollbar, .mx_RoomView_messagePanelSpinner { background-color: $background; @@ -139,13 +119,29 @@ limitations under the License. } } - &.mx_ThreadView .mx_ThreadView_timelinePanelWrapper { - position: relative; - min-height: 0; // don't displace the composer - flex-grow: 1; + &.mx_ThreadView { + max-height: 100%; + + // Inside a thread timeline only + .mx_GenericEventListSummary { + &:not([data-layout=bubble]) > .mx_EventTile_line { + padding-inline-start: var(--ThreadView_group_spacing-start); // align summary text with message text + padding-inline-end: var(--ThreadView_group_spacing-end); // align summary text with message text + } + } + + .mx_ThreadView_timelinePanelWrapper { + position: relative; + min-height: 0; // don't displace the composer + flex-grow: 1; - .mx_FileDropTarget { - border-radius: 8px; + .mx_FileDropTarget { + border-radius: 8px; + } + } + + .mx_MessageComposer_sendMessage { + margin-right: 0; } } @@ -166,22 +162,6 @@ limitations under the License. // Account for scrollbar when hovering padding-top: 0; - .mx_ThreadSummary { - position: relative; - padding-right: 11px; - - &::after { - content: ''; - display: block; - position: absolute; - left: 0; - bottom: -16px; - height: 1px; - width: 100%; - border-bottom: 1px solid $message-action-bar-border-color; - } - } - .mx_DateSeparator { display: none; } @@ -191,16 +171,6 @@ limitations under the License. } } - .mx_GenericEventListSummary { - &[data-layout=bubble] > .mx_EventTile_line { - padding-left: 30px !important; // Override main timeline styling - align summary text with message text - } - - &:not([data-layout=bubble]) > .mx_EventTile_line { - padding-inline-start: var(--ThreadView_group_spacing-start); // align summary text with message text - } - } - .mx_MessageComposer { background-color: $background; border-radius: 8px; @@ -346,6 +316,8 @@ limitations under the License. bottom: 0; left: 0; padding: 20px; + box-sizing: border-box; // Include padding and border + width: 100%; h2 { color: $primary-content; diff --git a/res/css/views/right_panel/_TimelineCard.scss b/res/css/views/right_panel/_TimelineCard.scss index 6b4c0d23610..3bc71040811 100644 --- a/res/css/views/right_panel/_TimelineCard.scss +++ b/res/css/views/right_panel/_TimelineCard.scss @@ -43,8 +43,8 @@ limitations under the License. } .mx_NewRoomIntro { - margin-inline-start: 36px; // TODO: Use a variable - margin-inline-end: 36px; // TODO: Use a variable + margin-inline-start: var(--BaseCard_EventTile-spacing-horizontal); + margin-inline-end: var(--BaseCard_EventTile-spacing-horizontal); } .mx_EventTile_content { @@ -52,13 +52,9 @@ limitations under the License. } .mx_EventTile:not([data-layout="bubble"]) { - $left-gutter: 36px; - + &.mx_EventTile_info .mx_EventTile_line, .mx_EventTile_line { - 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); + padding: var(--BaseCard_EventTile_line-padding-block) var(--BaseCard_EventTile-spacing-horizontal); .mx_EventTile_e2eIcon { inset-inline-start: 8px; @@ -68,7 +64,7 @@ limitations under the License. .mx_DisambiguatedProfile, .mx_ReactionsRow, .mx_ThreadSummary { - margin-inline-start: $left-gutter; + margin-inline-start: var(--BaseCard_EventTile-spacing-horizontal); } .mx_ReactionsRow { @@ -100,16 +96,31 @@ limitations under the License. } &.mx_EventTile_info { - .mx_EventTile_line { - padding-left: $left-gutter; - } - .mx_EventTile_avatar { left: 18px; } } } + .mx_EventTile, + .mx_GenericEventListSummary { + .mx_ThreadSummary { + position: relative; + padding-right: 11px; + + &::after { + content: ''; + display: block; + position: absolute; + left: 0; + bottom: -16px; + height: 1px; + width: 100%; + border-bottom: 1px solid $message-action-bar-border-color; + } + } + } + .mx_CallEvent_wrapper { justify-content: center; margin: auto 5px; @@ -118,9 +129,12 @@ limitations under the License. } } - .mx_GenericEventListSummary:not([data-layout=bubble]) .mx_EventTile_line, - .mx_GenericEventListSummary:not([data-layout=bubble]) > .mx_GenericEventListSummary_unstyledList > .mx_EventTile_info .mx_EventTile_avatar ~ .mx_EventTile_line { - padding-left: 36px; + .mx_GenericEventListSummary:not([data-layout=bubble]) { + .mx_EventTile_line, + > .mx_GenericEventListSummary_unstyledList > .mx_EventTile_info .mx_EventTile_avatar ~ .mx_EventTile_line { + padding-inline-start: var(--BaseCard_EventTile-spacing-horizontal); + padding-inline-end: var(--BaseCard_EventTile-spacing-horizontal); + } } .mx_ReadReceiptGroup { diff --git a/res/css/views/right_panel/_UserInfo.scss b/res/css/views/right_panel/_UserInfo.scss index 3e6c31059af..12e93395f5b 100644 --- a/res/css/views/right_panel/_UserInfo.scss +++ b/res/css/views/right_panel/_UserInfo.scss @@ -30,7 +30,7 @@ limitations under the License. top: 0; border-radius: $border-radius-4px; background-color: $dark-panel-bg-color; - margin: 9px; + margin: 9px; // TODO: Use a variable z-index: 1; // render on top of the right panel div { @@ -47,11 +47,24 @@ limitations under the License. h2 { font-size: $font-18px; font-weight: 600; - margin: 18px 0 0 0; + margin: 18px 0 0 0; // TODO: Use a variable } .mx_UserInfo_container { - padding: 8px 16px; + padding: $spacing-8 $spacing-16; + + &:not(.mx_UserInfo_separator) { + padding-top: $spacing-16; + padding-bottom: 0; + + > :not(h3) { + margin-inline-start: $spacing-8; + display: flex; + flex-flow: column; + align-items: flex-start; + row-gap: $spacing-8; + } + } .mx_UserInfo_container_verifyButton { margin-top: $spacing-8; @@ -65,7 +78,7 @@ limitations under the License. .mx_UserInfo_memberDetailsContainer { padding-top: 0; padding-bottom: 0; - margin-bottom: 8px; + margin-bottom: $spacing-8; } .mx_RoomTile_titleContainer { @@ -81,52 +94,54 @@ limitations under the License. } .mx_UserInfo_avatar { - margin: 24px 32px 0 32px; - } - - .mx_UserInfo_avatar > div { - max-width: 30vh; - margin: 0 auto; - transition: 0.5s; - } - - .mx_UserInfo_avatar > div > div { - /* use padding-top instead of height to make this element square, - as the % in padding is a % of the width (including margin, - that's why we had to put the margin to center on a parent div), - and not a % of the parent height. */ - padding-top: 100%; - position: relative; - } - - .mx_UserInfo_avatar > div > div * { - border-radius: 100%; - position: absolute; - top: 0; - left: 0; - width: 100% !important; - height: 100% !important; - } - - .mx_UserInfo_avatar .mx_BaseAvatar_initial { - z-index: 1; - display: flex; - align-items: center; - justify-content: center; - - // override the calculated sizes so that the letter isn't HUGE - font-size: 6rem !important; - width: 100% !important; - transition: font-size 0.5s; - } - - .mx_UserInfo_avatar .mx_BaseAvatar { - .mx_BaseAvatar_initial + .mx_BaseAvatar_image { - cursor: default; - } + margin: $spacing-24 $spacing-32 0 $spacing-32; + + .mx_UserInfo_avatar_transition { + max-width: 30vh; + margin: 0 auto; + transition: 0.5s; + + .mx_UserInfo_avatar_transition_child { + /* use padding-top instead of height to make this element square, + as the % in padding is a % of the width (including margin, + that's why we had to put the margin to center on a parent div), + and not a % of the parent height. */ + padding-top: 100%; + position: relative; + + .mx_BaseAvatar, + .mx_BaseAvatar_initial, + .mx_BaseAvatar_image { + border-radius: 100%; + position: absolute; + top: 0; + left: 0; + width: 100% !important; + height: 100% !important; + } - &.mx_BaseAvatar_image { - cursor: zoom-in; + .mx_BaseAvatar { + &.mx_BaseAvatar_image { + cursor: zoom-in; + } + + .mx_BaseAvatar_initial { + z-index: 1; + display: flex; + align-items: center; + justify-content: center; + + // override the calculated sizes so that the letter isn't HUGE + font-size: 6rem !important; + width: 100% !important; + transition: font-size 0.5s; + + & + .mx_BaseAvatar_image { + cursor: default; + } + } + } + } } } @@ -135,11 +150,11 @@ limitations under the License. color: $tertiary-content; font-weight: 600; font-size: $font-12px; - margin: 4px 0; + margin: $spacing-4 0; } p { - margin: 5px 0; + margin: 5px 0; // TODO: Use a variable } .mx_UserInfo_profile { @@ -165,34 +180,36 @@ limitations under the License. } .mx_E2EIcon { - margin-top: 3px; // visual vertical centering to the top line of text - margin-right: 4px; // margin from displyname + margin-top: 3px; // visual vertical centering to the top line of text. TODO: Use a variable + margin-inline-end: $spacing-4; // margin from displayName min-width: 18px; // convince flexbox to not collapse it } } .mx_UserInfo_profileStatus { - margin-top: 12px; + margin-top: $spacing-12; } } - .mx_UserInfo_memberDetails .mx_UserInfo_profileField { - display: flex; - justify-content: center; - align-items: center; - - margin: 6px 0; - - .mx_UserInfo_roleDescription { + .mx_UserInfo_memberDetails { + .mx_UserInfo_profileField { display: flex; justify-content: center; align-items: center; - // try to make it the same height as the dropdown - margin: 11px 0 12px 0; - } - .mx_Field { - margin: 0; + margin: 6px 0; // TODO: Use a variable + + .mx_UserInfo_roleDescription { + display: flex; + justify-content: center; + align-items: center; + // try to make it the same height as the dropdown + margin: 11px 0 12px 0; + } + + .mx_Field { + margin: 0; + } } } @@ -224,19 +241,6 @@ limitations under the License. flex: 1 1 0; } - .mx_UserInfo_container:not(.mx_UserInfo_separator) { - padding-top: 16px; - padding-bottom: 0; - - > :not(h3) { - margin-inline-start: $spacing-8; - display: flex; - flex-flow: column; - align-items: flex-start; - row-gap: $spacing-8; - } - } - .mx_UserInfo_devices { .mx_UserInfo_device { display: flex; @@ -272,17 +276,24 @@ limitations under the License. .mx_UserInfo_expand { column-gap: 5px; // cf: mx_UserInfo_device_name margin-bottom: 11px; + align-items: initial; // Cancel the default property } } -} -.mx_UserInfo.mx_UserInfo_smallAvatar { - .mx_UserInfo_avatar > div { - max-width: 72px; - margin: 0 auto; - } + &.mx_UserInfo_smallAvatar { + .mx_UserInfo_avatar { + .mx_UserInfo_avatar_transition { + max-width: 72px; + margin: 0 auto; + } - .mx_UserInfo_avatar .mx_BaseAvatar_initial { - font-size: 40px !important; // override the other override because here the avatar is smaller + .mx_UserInfo_avatar_transition_child { + .mx_BaseAvatar { + .mx_BaseAvatar_initial { + font-size: 40px !important; // override the other override because here the avatar is smaller + } + } + } + } } } diff --git a/res/css/views/rooms/_EditMessageComposer.scss b/res/css/views/rooms/_EditMessageComposer.scss index f4c15dac5ef..b639ec22de4 100644 --- a/res/css/views/rooms/_EditMessageComposer.scss +++ b/res/css/views/rooms/_EditMessageComposer.scss @@ -18,12 +18,11 @@ limitations under the License. .mx_EditMessageComposer { display: flex; flex-direction: column; + max-width: 100%; // disable overflow + width: auto; gap: 5px; padding: 3px; - // Make sure the formatting bar is visible - overflow: visible !important; // override mx_EventTile_content - .mx_BasicMessageComposer_input { border-radius: $border-radius-4px; border: solid 1px $primary-hairline-color; @@ -38,12 +37,15 @@ limitations under the License. .mx_EditMessageComposer_buttons { display: flex; - flex-direction: row; + flex-flow: row wrap-reverse; // display "Save" over "Cancel" justify-content: flex-end; gap: 5px; + margin-inline-start: auto; .mx_AccessibleButton { - padding: 5px 40px; + flex: 1; + box-sizing: border-box; + min-width: 100px; // magic number to align the edge of the button with the input area } } } diff --git a/res/css/views/rooms/_EventBubbleTile.scss b/res/css/views/rooms/_EventBubbleTile.scss index bbc4d3c53fa..75071366fac 100644 --- a/res/css/views/rooms/_EventBubbleTile.scss +++ b/res/css/views/rooms/_EventBubbleTile.scss @@ -111,10 +111,29 @@ limitations under the License. .mx_DisambiguatedProfile, .mx_EventTile_line { + --EventBubbleTile_line-max-width: 70%; + width: fit-content; - max-width: 70%; - // fixed line height to prevent emoji from being taller than text - line-height: $font-18px; + max-width: var(--EventBubbleTile_line-max-width); // Align message bubble and displayName + line-height: $font-18px; // fixed line height to prevent emoji from being taller than text + } + + // other users profile on bubble layout + > .mx_DisambiguatedProfile { + white-space: normal; // display mxid + + .mx_DisambiguatedProfile_displayName { + white-space: nowrap; // truncate long display names + margin-inline-end: 5px; + + // For RTL displayName + unicode-bidi: embed; + direction: ltr; + } + + .mx_DisambiguatedProfile_mxid { + margin-inline-start: 0; // Align mxid with truncated displayName inside mx_EventTile[data-layout=bubble] + } } // inside mx_RoomView_MessageList, outside of mx_ReplyTile @@ -138,9 +157,12 @@ limitations under the License. padding-right: 48px; } - .mx_MImageBody_thumbnail_container { - min-height: calc(1.8rem + var(--gutterSize) + var(--gutterSize)); - min-width: calc(1.8rem + var(--gutterSize) + var(--gutterSize)); + .mx_MImageBody { + .mx_MImageBody_thumbnail_container { + justify-content: center; + min-height: calc(1.8rem + var(--gutterSize) + var(--gutterSize)); + min-width: calc(1.8rem + var(--gutterSize) + var(--gutterSize)); + } } .mx_CallEvent { @@ -243,10 +265,12 @@ limitations under the License. } .mx_EventTile_line { + --EventBubbleTile_line-margin-inline-end: -12px; + position: relative; display: flex; gap: 5px; - margin: 0 -12px 0 -9px; + margin: 0 var(--EventBubbleTile_line-margin-inline-end) 0 -9px; border-top-left-radius: var(--cornerRadius); border-top-right-radius: var(--cornerRadius); @@ -261,29 +285,37 @@ limitations under the License. z-index: 3; // above media and location share maps } - &.mx_EventTile_mediaLine .mx_MVoiceMessageBody { - // allow the event to be collapsed, this causes the waveform to get cropped - min-width: 0; - } + &.mx_EventTile_mediaLine { + // TODO: Use a common class name instead + .mx_MFileBody, + .mx_MAudioBody { + max-width: 100%; // avoid overflow + } - // we put the timestamps for media (other than stickers) atop the media - // for images we also apply a linear gradient and change the timestamp colour to aid readability - &.mx_EventTile_mediaLine.mx_EventTile_image { - .mx_MessageTimestamp { - color: #ffffff; // regardless of theme, always visible on the below gradient + .mx_MVoiceMessageBody { + // allow the event to be collapsed, this causes the waveform to get cropped + min-width: 0; } - // linear gradient to make the timestamp more visible - .mx_MImageBody::before { - content: ""; - position: absolute; - background: linear-gradient(180deg, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0.2) 100%); - z-index: 1; - top: 0; - bottom: 0; - left: 0; - right: 0; - pointer-events: none; + // we put the timestamps for media (other than stickers) atop the media + // for images we also apply a linear gradient and change the timestamp colour to aid readability + &.mx_EventTile_image { + .mx_MessageTimestamp { + color: #ffffff; // regardless of theme, always visible on the below gradient + } + + // linear gradient to make the timestamp more visible + .mx_MImageBody::before { + content: ""; + position: absolute; + background: linear-gradient(180deg, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0.2) 100%); + z-index: 1; + top: 0; + bottom: 0; + left: 0; + right: 0; + pointer-events: none; + } } } @@ -454,25 +486,54 @@ limitations under the License. margin-left: -9px; } - /* Special layout scenario for "Unable To Decrypt (UTD)" events */ - &.mx_EventTile_bad > .mx_EventTile_line { - display: grid; - grid-template: - "reply reply" auto - "shield body" auto - "shield link" auto - / auto 1fr; - .mx_EventTile_e2eIcon { - grid-area: shield; - } - .mx_UnknownBody { - grid-area: body; - } - .mx_EventTile_keyRequestInfo { - grid-area: link; + &.mx_EventTile_bad { + /* Special layout scenario for "Unable To Decrypt (UTD)" events */ + .mx_EventTile_line { + display: grid; + grid-template: + "reply reply" auto + "shield body" auto + "shield link" auto + / auto 1fr; + + .mx_UnknownBody, + .mx_EventTile_keyRequestInfo, + .mx_ReplyChain_wrapper, + .mx_ViewSourceEvent { + min-width: 0; // Prevent a grid blowout + } + + .mx_EventTile_e2eIcon { + grid-area: shield; + } + + .mx_UnknownBody { + grid-area: body; + } + + .mx_EventTile_keyRequestInfo { + grid-area: link; + } + + .mx_ReplyChain_wrapper { + grid-area: reply; + } } - .mx_ReplyChain_wrapper { - grid-area: reply; + + &.mx_EventTile_info { + // "Unable To Decrypt" layout for hidden events + .mx_EventTile_line { + gap: 0 9px; // 9px: margin value of E2E icon + align-items: center; + grid-template: + "shield source" auto + "shield link" auto + / auto 1fr; + + .mx_ViewSourceEvent { + grid-area: source; + } + } } } @@ -606,44 +667,44 @@ limitations under the License. content: ""; clear: both; } -} -.mx_GenericEventListSummary[data-expanded=false][data-layout=bubble] { - // Align with left edge of bubble tiles - padding: 0 49px; -} + &[data-expanded=false] { + // Align with left edge of bubble tiles + padding: 0 49px; -// ideally we'd use display=contents here for the layout to all work regardless of the *ELS but -// that breaks ScrollPanel's reliance upon offsetTop so we have to have a bit more finesse. -.mx_GenericEventListSummary[data-expanded=true][data-layout=bubble] { - display: flex; - flex-direction: column; - margin: 0; + // increase margin between ELS and the next Event to not have our user avatar overlap the expand/collapse button + + .mx_EventTile[data-layout=bubble][data-self=true] { + margin-top: 20px; + } + } - .mx_EventTile_info { - padding: 2px 0; - margin-right: 0; + // ideally we'd use display=contents here for the layout to all work regardless of the *ELS but + // that breaks ScrollPanel's reliance upon offsetTop so we have to have a bit more finesse. + &[data-expanded=true] { + display: flex; + flex-direction: column; + margin: 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 - } + .mx_EventTile_info { + padding: 2px 0; + margin-right: 0; - .mx_ReadReceiptGroup { - right: -18px; // match alignment to RRs of chat bubbles - } + .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 + } - &::before { - right: 0; // match alignment of the hover background to that of chat bubbles + .mx_ReadReceiptGroup { + right: -18px; // match alignment to RRs of chat bubbles + } + + &::before { + right: 0; // match alignment of the hover background to that of chat bubbles + } } } } -// increase margin between ELS and the next Event to not have our user avatar overlap the expand/collapse button -.mx_GenericEventListSummary[data-layout=bubble][data-expanded=false] + .mx_EventTile[data-layout=bubble][data-self=true] { - margin-top: 20px; -} - /* events that do not require bubble layout */ .mx_GenericEventListSummary[data-layout=bubble], .mx_EventTile.mx_EventTile_bad[data-layout=bubble] { diff --git a/res/css/views/rooms/_EventTile.scss b/res/css/views/rooms/_EventTile.scss index 11799641638..e7e79ab299b 100644 --- a/res/css/views/rooms/_EventTile.scss +++ b/res/css/views/rooms/_EventTile.scss @@ -51,6 +51,20 @@ $threadInfoLineHeight: calc(2 * $font-12px); // See: _commons.scss mask-image: url('$(res)/img/element-icons/circle-sending.svg'); } + .mx_EventTile_content { + &.mx_EditMessageComposer { + // Make sure the formatting bar is visible + overflow: visible; + } + } + + .mx_MImageBody { + .mx_MImageBody_thumbnail_container { + display: flex; + align-items: center; // on every layout + } + } + &[data-layout=group] { .mx_EventTile_line { line-height: var(--GroupLayout-EventTile-line-height); @@ -65,25 +79,6 @@ $threadInfoLineHeight: calc(2 * $font-12px); // See: _commons.scss font-size: $font-14px; position: relative; - &[data-shape=ThreadsList][data-notification]::before { - content: ""; - position: absolute; - width: 10px; - height: 10px; - border-radius: 50%; - right: -25px; // center it in the gutter (16px margin + 4px padding + half 10px width) - top: 4px; - left: auto; - } - - &[data-shape=ThreadsList][data-notification=total]::before { - background-color: $room-icon-unread-color; - } - - &[data-shape=ThreadsList][data-notification=highlight]::before { - background-color: $alert; - } - .mx_ThreadSummary, .mx_ThreadSummaryIcon { margin-left: 64px; @@ -131,14 +126,9 @@ $threadInfoLineHeight: calc(2 * $font-12px); // See: _commons.scss color: $primary-content; font-size: $font-14px; display: inline-block; - /* anti-zalgo, with overflow hidden */ - overflow: hidden; padding-bottom: 0px; padding-top: 0px; margin: 0px; - /* the next three lines, along with overflow hidden, truncate long display names */ - white-space: nowrap; - text-overflow: ellipsis; max-width: calc(100% - $left-gutter); } @@ -263,6 +253,12 @@ $threadInfoLineHeight: calc(2 * $font-12px); // See: _commons.scss .mx_MImageBody { margin-right: 34px; + + .mx_MImageBody_thumbnail_container { + justify-content: flex-start; + min-height: $font-44px; + min-width: $font-44px; + } } .mx_EventTile_e2eIcon { @@ -294,34 +290,128 @@ $threadInfoLineHeight: calc(2 * $font-12px); // See: _commons.scss padding-left: calc($left-gutter + 20px); // override padding-left $left-gutter } -/* all the overflow-y: hidden; are to trap Zalgos - - but they introduce an implicit overflow-x: auto. - so make that explicitly hidden too to avoid random - horizontal scrollbars occasionally appearing, like in - https://github.com/vector-im/vector-web/issues/1154 */ .mx_EventTile_content { + /* + all the overflow-y: hidden; are to trap Zalgos - + but they introduce an implicit overflow-x: auto. + so make that explicitly hidden too to avoid random + horizontal scrollbars occasionally appearing, like in + https://github.com/vector-im/vector-web/issues/1154 + */ overflow-y: hidden; overflow-x: hidden; margin-right: 34px; + + .mx_EventTile_edited, + .mx_EventTile_pendingModeration { + user-select: none; + font-size: $font-12px; + color: $roomtopic-color; + display: inline-block; + margin-left: 9px; + } + + .mx_EventTile_edited { + cursor: pointer; + } + + .markdown-body { + font-family: inherit !important; + white-space: normal !important; + line-height: inherit !important; + color: inherit; // inherit the colour from the dark or light theme by default (but not for code blocks) + font-size: $font-14px; + + pre, + code { + font-family: $monospace-font-family !important; + background-color: $codeblock-background-color; + } + + code { + white-space: pre-wrap; // don't collapse spaces in inline code blocks + } + + pre { + // have to use overlay rather than auto otherwise Linux and Windows + // Chrome gets very confused about vertical spacing: + // https://github.com/vector-im/vector-web/issues/754 + overflow-x: overlay; + overflow-y: visible; + + &::-webkit-scrollbar-corner { + background: transparent; + } + + code { + white-space: pre; // we want code blocks to be scrollable and not wrap + + > * { + display: inline; + } + } + } + + h1, + h2, + h3, + h4, + h5, + h6 { + font-family: inherit !important; + color: inherit; + } + + /* Make h1 and h2 the same size as h3. */ + h1, + h2 { + font-size: 1.5em; + border-bottom: none !important; // override GFM + } + + a { + color: $accent-alt; + } + + blockquote { + color: unset; + margin: 0 0 16px; + padding: 2px 10px; + border-left: 2px solid $blockquote-bar-color; + line-height: $font-18px; + } + + /* + // actually, removing the Italic TTF provides + // better results seemingly + + // compensate for Nunito italics being terrible + // https://github.com/google/fonts/issues/1726 + em { + transform: skewX(-14deg); + display: inline-block; + } + */ + } } /* Spoiler stuff */ .mx_EventTile_spoiler { cursor: pointer; -} -.mx_EventTile_spoiler_reason { - color: $event-timestamp-color; - font-size: $font-11px; -} + .mx_EventTile_spoiler_reason { + color: $event-timestamp-color; + font-size: $font-11px; + } -.mx_EventTile_spoiler_content { - filter: blur(5px) saturate(0.1) sepia(1); - transition-duration: 0.5s; -} + .mx_EventTile_spoiler_content { + filter: blur(5px) saturate(0.1) sepia(1); + transition-duration: 0.5s; + } -.mx_EventTile_spoiler.visible > .mx_EventTile_spoiler_content { - filter: none; + &.visible > .mx_EventTile_spoiler_content { + filter: none; + } } .mx_RoomView_timeline_rr_enabled { @@ -343,10 +433,6 @@ $threadInfoLineHeight: calc(2 * $font-12px); // See: _commons.scss // on ELS we need the margin to allow interaction with the expand/collapse button which is normally in the RR gutter } -.mx_DisambiguatedProfile { - cursor: pointer; -} - .mx_EventTile_bubbleContainer { display: grid; grid-template-columns: 1fr 100px; @@ -389,23 +475,6 @@ $threadInfoLineHeight: calc(2 * $font-12px); // See: _commons.scss } } -.mx_EventTile_content .mx_EventTile_edited { - user-select: none; - font-size: $font-12px; - color: $roomtopic-color; - display: inline-block; - margin-left: 9px; - cursor: pointer; -} - -.mx_EventTile_content .mx_EventTile_pendingModeration { - user-select: none; - font-size: $font-12px; - color: $roomtopic-color; - display: inline-block; - margin-left: 9px; -} - .mx_EventTile_e2eIcon { position: relative; width: 14px; @@ -430,28 +499,23 @@ $threadInfoLineHeight: calc(2 * $font-12px); // See: _commons.scss } &::before { - mask-repeat: no-repeat; - mask-position: center; mask-size: 80%; } -} -.mx_EventTile_e2eIcon_warning { - &::after { + &.mx_EventTile_e2eIcon_warning, + &.mx_EventTile_e2eIcon_normal { + opacity: 1; + } + + &.mx_EventTile_e2eIcon_warning::after { mask-image: url('$(res)/img/e2e/warning.svg'); background-color: $alert; } - opacity: 1; -} - -.mx_EventTile_e2eIcon_normal { - &::after { + &.mx_EventTile_e2eIcon_normal::after { mask-image: url('$(res)/img/e2e/normal.svg'); background-color: $header-panel-text-primary-color; } - - opacity: 1; } /* Various markdown overrides */ @@ -482,49 +546,6 @@ $threadInfoLineHeight: calc(2 * $font-12px); // See: _commons.scss } } -.mx_EventTile_content .markdown-body { - font-family: inherit !important; - white-space: normal !important; - line-height: inherit !important; - color: inherit; // inherit the colour from the dark or light theme by default (but not for code blocks) - font-size: $font-14px; - - pre, - code { - font-family: $monospace-font-family !important; - background-color: $codeblock-background-color; - } - - // this selector wrongly applies to code blocks too but we will unset it in the next one - code { - white-space: pre-wrap; // don't collapse spaces in inline code blocks - } - - pre code { - white-space: pre; // we want code blocks to be scrollable and not wrap - - >* { - display: inline; - } - } - - pre { - // have to use overlay rather than auto otherwise Linux and Windows - // Chrome gets very confused about vertical spacing: - // https://github.com/vector-im/vector-web/issues/754 - overflow-x: overlay; - overflow-y: visible; - - code { - background-color: transparent; - } - - &::-webkit-scrollbar-corner { - background: transparent; - } - } -} - .mx_EventTile_lineNumbers { float: left; margin: 0 0.5em 0 -1.5em; @@ -537,134 +558,101 @@ $threadInfoLineHeight: calc(2 * $font-12px); // See: _commons.scss } } -.mx_EventTile_collapsedCodeBlock { - max-height: 30vh; -} - .mx_EventTile:hover .mx_EventTile_body pre, .mx_EventTile.focus-visible:focus-within .mx_EventTile_body pre { border: 1px solid $tertiary-content; } -.mx_EventTile_pre_container { - // For correct positioning of _copyButton (See TextualBody) - position: relative; -} - -// Inserted adjacent to
 blocks, (See TextualBody)
 .mx_EventTile_button {
-    position: absolute;
     display: inline-block;
-    visibility: hidden;
     cursor: pointer;
-    top: 8px;
-    right: 8px;
-    width: 19px;
-    height: 19px;
-    background-color: $message-action-bar-fg-color;
-}
-
-.mx_EventTile_buttonBottom {
-    top: 33px;
 }
 
 .mx_EventTile_copyButton {
     mask-image: url($copy-button-url);
 }
 
-.mx_EventTile_collapseButton {
-    mask-size: 75%;
+.mx_EventTile_collapseButton,
+.mx_EventTile_expandButton {
     mask-position: center;
     mask-repeat: no-repeat;
+}
+
+.mx_EventTile_collapseButton {
     mask-image: url("$(res)/img/element-icons/minimise-collapse.svg");
 }
 
 .mx_EventTile_expandButton {
-    mask-size: 75%;
-    mask-position: center;
-    mask-repeat: no-repeat;
     mask-image: url("$(res)/img/element-icons/maximise-expand.svg");
 }
 
-.mx_EventTile_body .mx_EventTile_pre_container:focus-within .mx_EventTile_copyButton,
-.mx_EventTile_body .mx_EventTile_pre_container:hover .mx_EventTile_copyButton,
-.mx_EventTile_body .mx_EventTile_pre_container:focus-within .mx_EventTile_collapseButton,
-.mx_EventTile_body .mx_EventTile_pre_container:hover .mx_EventTile_collapseButton,
-.mx_EventTile_body .mx_EventTile_pre_container:focus-within .mx_EventTile_expandButton,
-.mx_EventTile_body .mx_EventTile_pre_container:hover .mx_EventTile_expandButton {
-    visibility: visible;
-}
-
-.mx_EventTile_content .markdown-body h1,
-.mx_EventTile_content .markdown-body h2,
-.mx_EventTile_content .markdown-body h3,
-.mx_EventTile_content .markdown-body h4,
-.mx_EventTile_content .markdown-body h5,
-.mx_EventTile_content .markdown-body h6 {
-    font-family: inherit !important;
-    color: inherit;
-}
+.mx_EventTile_body .mx_EventTile_pre_container {
+    // For correct positioning of _copyButton (See TextualBody)
+    position: relative;
 
-/* Make h1 and h2 the same size as h3. */
-.mx_EventTile_content .markdown-body h1,
-.mx_EventTile_content .markdown-body h2 {
-    font-size: 1.5em;
-    border-bottom: none !important; // override GFM
-}
+    &:focus-within,
+    &:hover {
+        .mx_EventTile_button {
+            visibility: visible;
+        }
+    }
 
-.mx_EventTile_content .markdown-body a {
-    color: $accent-alt;
-}
+    .mx_EventTile_collapsedCodeBlock {
+        max-height: 30vh;
+    }
 
-.mx_EventTile_content .markdown-body blockquote {
-    color: unset;
-    margin: 0 0 16px;
-    padding: 2px 10px;
-    border-left: 2px solid $blockquote-bar-color;
-    line-height: $font-18px;
-}
+    // Inserted adjacent to 
 blocks, (See TextualBody)
+    .mx_EventTile_button {
+        position: absolute;
+        top: 8px;
+        right: 8px;
+        width: 19px;
+        height: 19px;
+        visibility: hidden;
+        background-color: $message-action-bar-fg-color;
 
-/*
-// actually, removing the Italic TTF provides
-// better results seemingly
+        &.mx_EventTile_buttonBottom {
+            top: 33px;
+        }
 
-// compensate for Nunito italics being terrible
-// https://github.com/google/fonts/issues/1726
-.mx_EventTile_content .markdown-body em {
-    transform: skewX(-14deg);
-    display: inline-block;
+        &.mx_EventTile_collapseButton,
+        &.mx_EventTile_expandButton {
+            mask-size: 75%;
+        }
+    }
 }
-*/
 
 /* end of overrides */
 
 .mx_EventTile_keyRequestInfo {
     font-size: $font-12px;
-}
 
-.mx_EventTile_keyRequestInfo_text {
-    opacity: 0.5;
-}
+    .mx_EventTile_keyRequestInfo_text {
+        opacity: 0.5;
 
-.mx_EventTile_keyRequestInfo_text .mx_AccessibleButton {
-    @mixin ButtonResetDefault;
-    color: $primary-content;
-    text-decoration: underline;
-    cursor: pointer;
+        .mx_AccessibleButton {
+            color: $primary-content;
+            text-decoration: underline;
+
+            &.mx_AccessibleButton_kind_link_inline {
+                padding: 0;
+            }
+        }
+    }
 }
 
 .mx_EventTile_keyRequestInfo_tooltip_contents p {
     text-align: auto;
     margin-left: 3px;
     margin-right: 3px;
-}
 
-.mx_EventTile_keyRequestInfo_tooltip_contents p:first-child {
-    margin-top: 0px;
-}
+    &:first-child {
+        margin-top: 0px;
+    }
 
-.mx_EventTile_keyRequestInfo_tooltip_contents p:last-child {
-    margin-bottom: 0px;
+    &:last-child {
+        margin-bottom: 0px;
+    }
 }
 
 .mx_EventTile_tileError {
@@ -785,12 +773,23 @@ $threadInfoLineHeight: calc(2 * $font-12px); // See: _commons.scss
     &::before {
         content: "";
         position: absolute;
-        top: 0;
-        bottom: 0;
-        left: 0;
-        right: 0;
-        /* enough to cover all sibling elements */
-        z-index: 10;
+        inset: 0;
+    }
+
+    // Display notification dot
+    &[data-notification]::before {
+        width: 8px;
+        height: 8px;
+        border-radius: 50%;
+        inset: 14px 8px auto auto; // 14px: align the dot with the timestamp row
+    }
+
+    &[data-notification=total]::before {
+        background-color: $room-icon-unread-color;
+    }
+
+    &[data-notification=highlight]::before {
+        background-color: $alert;
     }
 
     &:last-child {
@@ -851,15 +850,7 @@ $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%;
-
-    .mx_ThreadView_List {
-        flex: 1;
-        overflow: scroll;
-    }
+    --ThreadView_group_spacing-end: 8px; // same as padding
 
     .mx_EventTile_roomName {
         display: none;
@@ -870,20 +861,21 @@ $threadInfoLineHeight: calc(2 * $font-12px); // See: _commons.scss
         flex-direction: column;
 
         .mx_EventTile_line {
-            padding-top: 2px;
-            padding-bottom: 2px;
-            padding-left: 0;
-            order: 10 !important;
+            padding-top: var(--BaseCard_EventTile_line-padding-block);
+            padding-bottom: var(--BaseCard_EventTile_line-padding-block);
         }
 
-        .mx_MessageTimestamp {
-            font-size: $font-10px;
+        .mx_EventTile_line,
+        .mx_ReactionsRow {
+            padding-inline-start: 0; // Cancel inherited padding value for event message and reactions row
         }
 
         .mx_ReactionsRow {
-            order: 999;
-            padding-left: 0;
-            padding-right: 0;
+            padding-inline-end: 0;
+        }
+
+        .mx_MessageTimestamp {
+            font-size: $font-10px;
         }
 
         &:not([data-layout=bubble]) {
@@ -892,28 +884,31 @@ $threadInfoLineHeight: calc(2 * $font-12px); // See: _commons.scss
             .mx_EventTile_line {
                 padding-top: var(--BaseCard_EventTile_line-padding-block);
                 padding-bottom: var(--BaseCard_EventTile_line-padding-block);
+
+                .mx_EventTile_content {
+                    &.mx_EditMessageComposer {
+                        padding-inline-start: 0; // align start of first letter with that of the event body
+                    }
+                }
             }
         }
-    }
-
-    .mx_EventTile[data-layout=bubble] {
-        margin-left: 36px;
-        margin-right: 36px;
 
-        .mx_EventTile_line.mx_EventTile_mediaLine {
-            padding: 0 !important;
-            max-width: 100%;
+        &[data-layout=bubble] {
+            margin-inline-start: var(--BaseCard_EventTile-spacing-horizontal);
+            margin-inline-end: var(--BaseCard_EventTile-spacing-horizontal);
 
-            .mx_MFileBody {
-                width: 100%;
+            .mx_EventTile_line.mx_EventTile_mediaLine {
+                padding-block: 0;
+                padding-inline-start: 0;
+                max-width: var(--EventBubbleTile_line-max-width);
             }
-        }
 
-        &[data-self=true] {
-            align-items: flex-end;
+            &[data-self=true] {
+                align-items: flex-end;
 
-            .mx_EventTile_line.mx_EventTile_mediaLine {
-                margin: 0 -13px 0 0; // align with normal messages
+                .mx_EventTile_line.mx_EventTile_mediaLine {
+                    margin: 0 var(--EventBubbleTile_line-margin-inline-end) 0 0; // align with normal messages
+                }
             }
         }
     }
@@ -930,7 +925,7 @@ $threadInfoLineHeight: calc(2 * $font-12px); // See: _commons.scss
         .mx_ReplyChain_wrapper,
         .mx_ReactionsRow {
             margin-inline-start: var(--ThreadView_group_spacing-start);
-            margin-right: 8px;
+            margin-inline-end: var(--ThreadView_group_spacing-end);
 
             .mx_EventTile_content,
             .mx_HiddenBody,
@@ -941,9 +936,23 @@ $threadInfoLineHeight: calc(2 * $font-12px); // See: _commons.scss
         }
 
         .mx_ReplyChain_wrapper {
-            .mx_MLocationBody {
-                margin-inline-start: 0;
-                margin-inline-end: 0;
+            .mx_MLocationBody,
+            .mx_UnknownBody { // Error message inside ReplyTile
+                margin-inline: unset;
+            }
+        }
+
+        .mx_EventTile_mediaLine {
+            // such as MImageBody
+            > div,
+            > span {
+                margin-inline-start: var(--ThreadView_group_spacing-start);
+                margin-inline-end: var(--ThreadView_group_spacing-end);
+            }
+
+            // such as MAudioBody and MFileBody
+            > span {
+                display: block; // Apply the margin declarations to span element
             }
         }
 
@@ -970,32 +979,5 @@ $threadInfoLineHeight: calc(2 * $font-12px); // See: _commons.scss
                 }
             }
         }
-
-        .mx_EventTile_mediaLine {
-            padding-inline-start: var(--ThreadView_group_spacing-start);
-        }
-    }
-
-    .mx_EventTile_mediaLine {
-        padding-left: 36px;
-        padding-right: 50px;
-
-        .mx_MImageBody {
-            margin: 0;
-            padding: 0;
-        }
-    }
-
-    .mx_MessageComposer_sendMessage {
-        margin-right: 0;
-    }
-
-    .mx_EditMessageComposer {
-        margin-left: 30px !important; // align start of first letter with that of the event body
-    }
-
-    .mx_EditMessageComposer_buttons {
-        padding-right: 11px; // align with right edge of input
-        margin-right: 0; // align with right edge of background
     }
 }
diff --git a/res/css/views/rooms/_ReplyTile.scss b/res/css/views/rooms/_ReplyTile.scss
index 6bada1e998f..9b54b449941 100644
--- a/res/css/views/rooms/_ReplyTile.scss
+++ b/res/css/views/rooms/_ReplyTile.scss
@@ -112,10 +112,5 @@ limitations under the License.
         padding: 0;
         margin: 0;
         margin-bottom: $font-3px;
-
-        // truncate long display names
-        overflow: hidden;
-        white-space: nowrap;
-        text-overflow: ellipsis;
     }
 }
diff --git a/res/css/views/voip/CallView/_CallViewButtons.scss b/res/css/views/voip/CallView/_CallViewButtons.scss
index 4c375ee2222..91d173bc072 100644
--- a/res/css/views/voip/CallView/_CallViewButtons.scss
+++ b/res/css/views/voip/CallView/_CallViewButtons.scss
@@ -24,6 +24,8 @@ limitations under the License.
 }
 
 .mx_CallViewButtons {
+    --CallViewButtons_dropdownButton-size: 16px;
+
     position: absolute;
     display: flex;
     justify-content: center;
@@ -65,8 +67,8 @@ limitations under the License.
         }
 
         &.mx_CallViewButtons_dropdownButton {
-            width: 16px;
-            height: 16px;
+            width: var(--CallViewButtons_dropdownButton-size);
+            height: var(--CallViewButtons_dropdownButton-size);
 
             position: absolute;
             right: 0;
diff --git a/res/css/views/voip/_CallView.scss b/res/css/views/voip/_CallView.scss
index e31f42727a0..c83e143b1b8 100644
--- a/res/css/views/voip/_CallView.scss
+++ b/res/css/views/voip/_CallView.scss
@@ -165,6 +165,11 @@ border-radius: $border-radius-8px;
                 width: 34px;
                 height: 34px;
 
+                &.mx_CallViewButtons_dropdownButton {
+                    width: var(--CallViewButtons_dropdownButton-size);
+                    height: var(--CallViewButtons_dropdownButton-size);
+                }
+
                 &::before {
                     width: 22px;
                     height: 22px;
diff --git a/res/img/element-icons/child-relationship.svg b/res/img/element-icons/child-relationship.svg
new file mode 100644
index 00000000000..5a848c0d972
--- /dev/null
+++ b/res/img/element-icons/child-relationship.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/res/img/external-link.svg b/res/img/external-link.svg
index 459e790fe31..cae1446a687 100644
--- a/res/img/external-link.svg
+++ b/res/img/external-link.svg
@@ -1,5 +1,5 @@
 
-    
+    
         
     
 
diff --git a/res/img/location/map.svg b/res/img/location/map.svg
index 67be3a35ad4..9719d8f6142 100644
--- a/res/img/location/map.svg
+++ b/res/img/location/map.svg
@@ -1,9 +1,9 @@
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/res/themes/legacy-light/css/_legacy-light.scss b/res/themes/legacy-light/css/_legacy-light.scss
index 3825b9bd987..5c3231f1d80 100644
--- a/res/themes/legacy-light/css/_legacy-light.scss
+++ b/res/themes/legacy-light/css/_legacy-light.scss
@@ -1,16 +1,33 @@
-// XXX: check this?
-/* Nunito lacks combining diacritics, so these will fall through
-   to the next font.  Helevetica's diacritics however do not combine
+/* Nunito and Inter lacks combining diacritics, so these will fall through
+   to the next font. Helevetica's diacritics sometimes do not combine
    nicely (on OSX, at least) and result in a huge horizontal mess.
-   Arial empirically gets it right, hence prioritising Arial here. */
+   Arial empirically gets it right, hence prioritising Arial here.
+   We also include STIXGeneral explicitly to support a wider range
+   of combining diacritics (Chrome fails without it, as per
+   https://bugs.chromium.org/p/chromium/issues/detail?id=1328898) */
 /* We fall through to Twemoji for emoji rather than falling through
    to native Emoji fonts (if any) to ensure cross-browser consistency */
 /* Noto Color Emoji contains digits, in fixed-width, therefore causing
    digits in flowed text to stand out.
    TODO: Consider putting all emoji fonts to the end rather than the front. */
-$font-family: 'Inter', 'Twemoji', 'Apple Color Emoji', 'Segoe UI Emoji', 'Arial', 'Helvetica', sans-serif, 'Noto Color Emoji';
-
-$monospace-font-family: 'Inconsolata', 'Twemoji', 'Apple Color Emoji', 'Segoe UI Emoji', 'Courier', monospace, 'Noto Color Emoji';
+$font-family: 'Inter',
+'Twemoji',
+'Apple Color Emoji',
+'Segoe UI Emoji',
+'STIXGeneral',
+'Arial',
+'Helvetica',
+sans-serif,
+'Noto Color Emoji';
+
+$monospace-font-family: 'Inconsolata',
+'Twemoji',
+'Apple Color Emoji',
+'Segoe UI Emoji',
+'STIXGeneral',
+'Courier',
+monospace,
+'Noto Color Emoji';
 
 // unified palette
 // try to use these colors when possible
diff --git a/res/themes/light/css/_light.scss b/res/themes/light/css/_light.scss
index 1b32d971396..5ba40729baa 100644
--- a/res/themes/light/css/_light.scss
+++ b/res/themes/light/css/_light.scss
@@ -1,8 +1,10 @@
-// XXX: check this?
-/* Nunito lacks combining diacritics, so these will fall through
-   to the next font.  Helevetica's diacritics however do not combine
+/* Nunito and Inter lacks combining diacritics, so these will fall through
+   to the next font. Helevetica's diacritics sometimes do not combine
    nicely (on OSX, at least) and result in a huge horizontal mess.
-   Arial empirically gets it right, hence prioritising Arial here. */
+   Arial empirically gets it right, hence prioritising Arial here.
+   We also include STIXGeneral explicitly to support a wider range
+   of combining diacritics (Chrome fails without it, as per
+   https://bugs.chromium.org/p/chromium/issues/detail?id=1328898) */
 /* We fall through to Twemoji for emoji rather than falling through
    to native Emoji fonts (if any) to ensure cross-browser consistency */
 /* Noto Color Emoji contains digits, in fixed-width, therefore causing
@@ -12,6 +14,7 @@ $font-family: 'Inter',
 'Twemoji',
 'Apple Color Emoji',
 'Segoe UI Emoji',
+'STIXGeneral',
 'Arial',
 'Helvetica',
 sans-serif,
@@ -21,6 +24,7 @@ $monospace-font-family: 'Inconsolata',
 'Twemoji',
 'Apple Color Emoji',
 'Segoe UI Emoji',
+'STIXGeneral',
 'Courier',
 monospace,
 'Noto Color Emoji';
diff --git a/src/Analytics.tsx b/src/Analytics.tsx
index 639950574df..0249c6ad1a2 100644
--- a/src/Analytics.tsx
+++ b/src/Analytics.tsx
@@ -18,6 +18,7 @@ limitations under the License.
 import React from 'react';
 import { logger } from "matrix-js-sdk/src/logger";
 import { Optional } from "matrix-events-sdk";
+import { randomString } from 'matrix-js-sdk/src/randomstring';
 
 import { getCurrentLanguage, _t, _td, IVariables } from './languageHandler';
 import PlatformPeg from './PlatformPeg';
@@ -155,9 +156,9 @@ const LAST_VISIT_TS_KEY = "mx_Riot_Analytics_lvts";
 
 function getUid(): string {
     try {
-        let data = localStorage && localStorage.getItem(UID_KEY);
+        let data = localStorage?.getItem(UID_KEY);
         if (!data && localStorage) {
-            localStorage.setItem(UID_KEY, data = [...Array(16)].map(() => Math.random().toString(16)[2]).join(''));
+            localStorage.setItem(UID_KEY, data = randomString(16));
         }
         return data;
     } catch (e) {
diff --git a/src/BasePlatform.ts b/src/BasePlatform.ts
index 95f34597635..9de1122430b 100644
--- a/src/BasePlatform.ts
+++ b/src/BasePlatform.ts
@@ -70,7 +70,7 @@ export default abstract class BasePlatform {
     protected onAction = (payload: ActionPayload) => {
         switch (payload.action) {
             case 'on_client_not_viable':
-            case 'on_logged_out':
+            case Action.OnLoggedOut:
                 this.setNotificationCount(0);
                 break;
         }
@@ -291,6 +291,18 @@ export default abstract class BasePlatform {
         throw new Error("Unimplemented");
     }
 
+    public supportsTogglingHardwareAcceleration(): boolean {
+        return false;
+    }
+
+    public async getHardwareAccelerationEnabled(): Promise {
+        return true;
+    }
+
+    public async setHardwareAccelerationEnabled(enabled: boolean): Promise {
+        throw new Error("Unimplemented");
+    }
+
     /**
      * Get our platform specific EventIndexManager.
      *
diff --git a/src/ContentMessages.ts b/src/ContentMessages.ts
index 7cb0ad1db9c..f7b12f53e3f 100644
--- a/src/ContentMessages.ts
+++ b/src/ContentMessages.ts
@@ -23,7 +23,7 @@ import encrypt from "matrix-encrypt-attachment";
 import extractPngChunks from "png-chunks-extract";
 import { IAbortablePromise, IImageInfo } from "matrix-js-sdk/src/@types/partials";
 import { logger } from "matrix-js-sdk/src/logger";
-import { IEventRelation, ISendEventResponse, MatrixEvent } from "matrix-js-sdk/src/matrix";
+import { IEventRelation, ISendEventResponse, MatrixError, MatrixEvent } from "matrix-js-sdk/src/matrix";
 import { THREAD_RELATION_TYPE } from "matrix-js-sdk/src/models/thread";
 
 import { IEncryptedFile, IMediaEventInfo } from "./customisations/models/IMediaEventContent";
@@ -64,10 +64,7 @@ interface IMediaConfig {
 interface IContent {
     body: string;
     msgtype: string;
-    info: {
-        size: number;
-        mimetype?: string;
-    };
+    info: IMediaEventInfo;
     file?: string;
     url?: string;
 }
@@ -129,6 +126,9 @@ const IMAGE_THUMBNAIL_MIN_REDUCTION_PERCENT = 0.1; // 10%
 // We don't apply these thresholds to video thumbnails as a poster image is always useful
 // and videos tend to be much larger.
 
+// Image mime types for which to always include a thumbnail for even if it is larger than the input for wider support.
+const ALWAYS_INCLUDE_THUMBNAIL = ["image/avif", "image/webp"];
+
 /**
  * Read the metadata for an image file and create and upload a thumbnail of the image.
  *
@@ -137,7 +137,11 @@ const IMAGE_THUMBNAIL_MIN_REDUCTION_PERCENT = 0.1; // 10%
  * @param {File} imageFile The image to read and thumbnail.
  * @return {Promise} A promise that resolves with the attachment info.
  */
-async function infoForImageFile(matrixClient: MatrixClient, roomId: string, imageFile: File) {
+async function infoForImageFile(
+    matrixClient: MatrixClient,
+    roomId: string,
+    imageFile: File,
+): Promise> {
     let thumbnailType = "image/png";
     if (imageFile.type === "image/jpeg") {
         thumbnailType = "image/jpeg";
@@ -149,7 +153,7 @@ async function infoForImageFile(matrixClient: MatrixClient, roomId: string, imag
     const imageInfo = result.info;
 
     // For lesser supported image types, always include the thumbnail even if it is larger
-    if (!["image/avif", "image/webp"].includes(imageFile.type)) {
+    if (!ALWAYS_INCLUDE_THUMBNAIL.includes(imageFile.type)) {
         // we do all sizing checks here because we still rely on thumbnail generation for making a blurhash from.
         const sizeDifference = imageFile.size - imageInfo.thumbnail_info.size;
         if (
@@ -178,7 +182,7 @@ async function infoForImageFile(matrixClient: MatrixClient, roomId: string, imag
  * @param {File} videoFile The file to load in an video element.
  * @return {Promise} A promise that resolves with the video image element.
  */
-function loadVideoElement(videoFile): Promise {
+function loadVideoElement(videoFile: File): Promise {
     return new Promise((resolve, reject) => {
         // Load the file into an html element
         const video = document.createElement("video");
@@ -224,7 +228,11 @@ function loadVideoElement(videoFile): Promise {
  * @param {File} videoFile The video to read and thumbnail.
  * @return {Promise} A promise that resolves with the attachment info.
  */
-function infoForVideoFile(matrixClient, roomId, videoFile) {
+function infoForVideoFile(
+    matrixClient: MatrixClient,
+    roomId: string,
+    videoFile: File,
+): Promise> {
     const thumbnailType = "image/jpeg";
 
     let videoInfo: Partial;
@@ -449,7 +457,7 @@ export default class ContentMessages {
         });
     }
 
-    public cancelUpload(promise: Promise, matrixClient: MatrixClient): void {
+    public cancelUpload(promise: IAbortablePromise, matrixClient: MatrixClient): void {
         const upload = this.inprogress.find(item => item.promise === promise);
         if (upload) {
             upload.canceled = true;
@@ -466,12 +474,12 @@ export default class ContentMessages {
         replyToEvent: MatrixEvent | undefined,
         promBefore: Promise,
     ) {
-        const content: IContent = {
+        const content: Omit & { info: Partial } = {
             body: file.name || 'Attachment',
             info: {
                 size: file.size,
             },
-            msgtype: "", // set later
+            msgtype: MsgType.File, // set more specifically later
         };
 
         attachRelation(content, relation);
@@ -497,6 +505,7 @@ export default class ContentMessages {
                     Object.assign(content.info, imageInfo);
                     resolve();
                 }, (e) => {
+                    // Failed to thumbnail, fall back to uploading an m.file
                     logger.error(e);
                     content.msgtype = MsgType.File;
                     resolve();
@@ -510,6 +519,8 @@ export default class ContentMessages {
                     Object.assign(content.info, videoInfo);
                     resolve();
                 }, (e) => {
+                    // Failed to thumbnail, fall back to uploading an m.file
+                    logger.error(e);
                     content.msgtype = MsgType.File;
                     resolve();
                 });
@@ -541,8 +552,8 @@ export default class ContentMessages {
             dis.dispatch({ action: Action.UploadProgress, upload });
         }
 
-        let error;
-        return prom.then(function() {
+        let error: MatrixError;
+        return prom.then(() => {
             if (upload.canceled) throw new UploadCanceledError();
             // XXX: upload.promise must be the promise that
             // is returned by uploadFile as it has an abort()
@@ -567,11 +578,11 @@ export default class ContentMessages {
                 });
             }
             return prom;
-        }, function(err) {
+        }, function(err: MatrixError) {
             error = err;
             if (!upload.canceled) {
                 let desc = _t("The file '%(fileName)s' failed to upload.", { fileName: upload.fileName });
-                if (err.http_status === 413) {
+                if (err.httpStatus === 413) {
                     desc = _t(
                         "The file '%(fileName)s' exceeds this homeserver's size limit for uploads",
                         { fileName: upload.fileName },
@@ -593,7 +604,7 @@ export default class ContentMessages {
                 // 413: File was too big or upset the server in some way:
                 // clear the media size limit so we fetch it again next time
                 // we try to upload
-                if (error && error.http_status === 413) {
+                if (error?.httpStatus === 413) {
                     this.mediaConfig = null;
                 }
                 dis.dispatch({ action: Action.UploadFailed, upload, error });
@@ -613,7 +624,7 @@ export default class ContentMessages {
         return true;
     }
 
-    private ensureMediaConfigFetched(matrixClient: MatrixClient) {
+    private ensureMediaConfigFetched(matrixClient: MatrixClient): Promise {
         if (this.mediaConfig !== null) return;
 
         logger.log("[Media Config] Fetching");
diff --git a/src/DeviceListener.ts b/src/DeviceListener.ts
index adba51d1352..cf9af5befc4 100644
--- a/src/DeviceListener.ts
+++ b/src/DeviceListener.ts
@@ -18,6 +18,7 @@ import { MatrixEvent } from "matrix-js-sdk/src/models/event";
 import { logger } from "matrix-js-sdk/src/logger";
 import { CryptoEvent } from "matrix-js-sdk/src/crypto";
 import { ClientEvent, EventType, RoomStateEvent } from "matrix-js-sdk/src/matrix";
+import { SyncState } from "matrix-js-sdk/src/sync";
 
 import { MatrixClientPeg } from './MatrixClientPeg';
 import dis from "./dispatcher/dispatcher";
@@ -58,13 +59,15 @@ export default class DeviceListener {
     private ourDeviceIdsAtStart: Set = null;
     // The set of device IDs we're currently displaying toasts for
     private displayingToastsForDeviceIds = new Set();
+    private running = false;
 
-    static sharedInstance() {
+    public static sharedInstance() {
         if (!window.mxDeviceListener) window.mxDeviceListener = new DeviceListener();
         return window.mxDeviceListener;
     }
 
-    start() {
+    public start() {
+        this.running = true;
         MatrixClientPeg.get().on(CryptoEvent.WillUpdateDevices, this.onWillUpdateDevices);
         MatrixClientPeg.get().on(CryptoEvent.DevicesUpdated, this.onDevicesUpdated);
         MatrixClientPeg.get().on(CryptoEvent.DeviceVerificationChanged, this.onDeviceVerificationChanged);
@@ -77,7 +80,8 @@ export default class DeviceListener {
         this.recheck();
     }
 
-    stop() {
+    public stop() {
+        this.running = false;
         if (MatrixClientPeg.get()) {
             MatrixClientPeg.get().removeListener(CryptoEvent.WillUpdateDevices, this.onWillUpdateDevices);
             MatrixClientPeg.get().removeListener(CryptoEvent.DevicesUpdated, this.onDevicesUpdated);
@@ -109,7 +113,7 @@ export default class DeviceListener {
      *
      * @param {String[]} deviceIds List of device IDs to dismiss notifications for
      */
-    async dismissUnverifiedSessions(deviceIds: Iterable) {
+    public async dismissUnverifiedSessions(deviceIds: Iterable) {
         logger.log("Dismissing unverified sessions: " + Array.from(deviceIds).join(','));
         for (const d of deviceIds) {
             this.dismissed.add(d);
@@ -118,7 +122,7 @@ export default class DeviceListener {
         this.recheck();
     }
 
-    dismissEncryptionSetup() {
+    public dismissEncryptionSetup() {
         this.dismissedThisDeviceToast = true;
         this.recheck();
     }
@@ -179,8 +183,10 @@ export default class DeviceListener {
         }
     };
 
-    private onSync = (state, prevState) => {
-        if (state === 'PREPARED' && prevState === null) this.recheck();
+    private onSync = (state: SyncState, prevState?: SyncState) => {
+        if (state === 'PREPARED' && prevState === null) {
+            this.recheck();
+        }
     };
 
     private onRoomStateEvents = (ev: MatrixEvent) => {
@@ -192,7 +198,7 @@ export default class DeviceListener {
     };
 
     private onAction = ({ action }: ActionPayload) => {
-        if (action !== "on_logged_in") return;
+        if (action !== Action.OnLoggedIn) return;
         this.recheck();
     };
 
@@ -217,6 +223,7 @@ export default class DeviceListener {
     }
 
     private async recheck() {
+        if (!this.running) return; // we have been stopped
         const cli = MatrixClientPeg.get();
 
         if (!(await cli.doesServerSupportUnstableFeature("org.matrix.e2e_cross_signing"))) return;
diff --git a/src/Lifecycle.ts b/src/Lifecycle.ts
index 5d864cc1cc7..e663a410461 100644
--- a/src/Lifecycle.ts
+++ b/src/Lifecycle.ts
@@ -168,7 +168,7 @@ export async function loadSession(opts: ILoadSessionOpts = {}): Promise
  * Gets the user ID of the persisted session, if one exists. This does not validate
  * that the user's credentials still work, just that they exist and that a user ID
  * is associated with them. The session is not loaded.
- * @returns {[String, bool]} The persisted session's owner and whether the stored
+ * @returns {[string, boolean]} The persisted session's owner and whether the stored
  *     session is for a guest user, if an owner exists. If there is no stored session,
  *     return [null, null].
  */
@@ -494,7 +494,7 @@ async function handleLoadSessionFailure(e: Error): Promise {
  * Also stops the old MatrixClient and clears old credentials/etc out of
  * storage before starting the new client.
  *
- * @param {MatrixClientCreds} credentials The credentials to use
+ * @param {IMatrixClientCreds} credentials The credentials to use
  *
  * @returns {Promise} promise which resolves to the new MatrixClient once it has been started
  */
@@ -525,7 +525,7 @@ export async function setLoggedIn(credentials: IMatrixClientCreds): Promise onLoggedOut());
         return;
     }
@@ -739,19 +739,17 @@ export function logout(): void {
     _isLoggingOut = true;
     const client = MatrixClientPeg.get();
     PlatformPeg.get().destroyPickleKey(client.getUserId(), client.getDeviceId());
-    client.logout().then(onLoggedOut,
-        (err) => {
-            // Just throwing an error here is going to be very unhelpful
-            // if you're trying to log out because your server's down and
-            // you want to log into a different server, so just forget the
-            // access token. It's annoying that this will leave the access
-            // token still valid, but we should fix this by having access
-            // tokens expire (and if you really think you've been compromised,
-            // change your password).
-            logger.log("Failed to call logout API: token will not be invalidated");
-            onLoggedOut();
-        },
-    );
+    client.logout(undefined, true).then(onLoggedOut, (err) => {
+        // Just throwing an error here is going to be very unhelpful
+        // if you're trying to log out because your server's down and
+        // you want to log into a different server, so just forget the
+        // access token. It's annoying that this will leave the access
+        // token still valid, but we should fix this by having access
+        // tokens expire (and if you really think you've been compromised,
+        // change your password).
+        logger.warn("Failed to call logout API: token will not be invalidated", err);
+        onLoggedOut();
+    });
 }
 
 export function softLogout(): void {
@@ -856,21 +854,25 @@ async function startMatrixClient(startSyncing = true): Promise {
  * storage. Used after a session has been logged out.
  */
 export async function onLoggedOut(): Promise {
-    _isLoggingOut = false;
-    // Ensure that we dispatch a view change **before** stopping the client so
-    // so that React components unmount first. This avoids React soft crashes
+    // Ensure that we dispatch a view change **before** stopping the client,
+    // that React components unmount first. This avoids React soft crashes
     // that can occur when components try to use a null client.
-    dis.dispatch({ action: 'on_logged_out' }, true);
+    dis.fire(Action.OnLoggedOut, true);
     stopMatrixClient();
     await clearStorage({ deleteEverything: true });
     LifecycleCustomisations.onLoggedOutAndStorageCleared?.();
 
-    // Do this last so we can make sure all storage has been cleared and all
+    // Do this last, so we can make sure all storage has been cleared and all
     // customisations got the memo.
     if (SdkConfig.get().logout_redirect_url) {
         logger.log("Redirecting to external provider to finish logout");
-        window.location.href = SdkConfig.get().logout_redirect_url;
+        // XXX: Defer this so that it doesn't race with MatrixChat unmounting the world by going to /#/login
+        setTimeout(() => {
+            window.location.href = SdkConfig.get().logout_redirect_url;
+        }, 100);
     }
+    // Do this last to prevent racing `stopMatrixClient` and `on_logged_out` with MatrixChat handling Session.logged_out
+    _isLoggingOut = false;
 }
 
 /**
@@ -908,9 +910,7 @@ async function clearStorage(opts?: { deleteEverything?: boolean }): Promise, roomId: string): void {
     const widgetUrl = event.data.url;
     const widgetName = event.data.name; // optional
     const widgetData = event.data.data; // optional
+    const widgetAvatarUrl = event.data.avatar_url; // optional
     const userWidget = event.data.userWidget;
 
     // both adding/removing widgets need these checks
@@ -337,6 +339,14 @@ function setWidget(event: MessageEvent, roomId: string): void {
             sendError(event, _t("Unable to create widget."), new Error("Optional field 'data' must be an Object."));
             return;
         }
+        if (widgetAvatarUrl !== undefined && typeof widgetAvatarUrl !== 'string') {
+            sendError(
+                event,
+                _t("Unable to create widget."),
+                new Error("Optional field 'avatar_url' must be a string."),
+            );
+            return;
+        }
         if (typeof widgetType !== 'string') {
             sendError(event, _t("Unable to create widget."), new Error("Field 'type' must be a string."));
             return;
@@ -364,13 +374,14 @@ function setWidget(event: MessageEvent, roomId: string): void {
         if (!roomId) {
             sendError(event, _t('Missing roomId.'), null);
         }
-        WidgetUtils.setRoomWidget(roomId, widgetId, widgetType, widgetUrl, widgetName, widgetData).then(() => {
-            sendResponse(event, {
-                success: true,
+        WidgetUtils.setRoomWidget(roomId, widgetId, widgetType, widgetUrl, widgetName, widgetData, widgetAvatarUrl)
+            .then(() => {
+                sendResponse(event, {
+                    success: true,
+                });
+            }, (err) => {
+                sendError(event, _t('Failed to send request.'), err);
             });
-        }, (err) => {
-            sendError(event, _t('Failed to send request.'), err);
-        });
     }
 }
 
diff --git a/src/SecurityManager.ts b/src/SecurityManager.ts
index c67e8ec8d96..50b5f0c950a 100644
--- a/src/SecurityManager.ts
+++ b/src/SecurityManager.ts
@@ -257,7 +257,7 @@ async function onSecretRequested(
     if (userId !== client.getUserId()) {
         return;
     }
-    if (!deviceTrust || !deviceTrust.isVerified()) {
+    if (!deviceTrust?.isVerified()) {
         logger.log(`Ignoring secret request from untrusted device ${deviceId}`);
         return;
     }
@@ -296,7 +296,7 @@ export const crossSigningCallbacks: ICryptoCallbacks = {
 };
 
 export async function promptForBackupPassphrase(): Promise {
-    let key;
+    let key: Uint8Array;
 
     const { finished } = Modal.createTrackedDialog('Restore Backup', '', RestoreKeyBackupDialog, {
         showSummary: false, keyCallback: k => key = k,
diff --git a/src/actions/MatrixActionCreators.ts b/src/actions/MatrixActionCreators.ts
index b8893763361..a307e5b25ed 100644
--- a/src/actions/MatrixActionCreators.ts
+++ b/src/actions/MatrixActionCreators.ts
@@ -18,6 +18,7 @@ import { ClientEvent, MatrixClient } from "matrix-js-sdk/src/client";
 import { MatrixEvent, MatrixEventEvent } from "matrix-js-sdk/src/models/event";
 import { Room, RoomEvent } from "matrix-js-sdk/src/models/room";
 import { IRoomTimelineData } from "matrix-js-sdk/src/models/event-timeline-set";
+import { RoomState, RoomStateEvent } from "matrix-js-sdk/src/models/room-state";
 
 import dis from "../dispatcher/dispatcher";
 import { ActionPayload } from "../dispatcher/payloads";
@@ -175,6 +176,21 @@ export interface IRoomTimelineActionPayload extends Pick {
+    action: 'MatrixActions.RoomState.events';
+    event: MatrixEvent;
+    state: RoomState;
+    lastStateEvent: MatrixEvent | null;
+}
+
 /**
  * Create a MatrixActions.Room.timeline action that represents a
  * MatrixClient `Room.timeline` matrix event, emitted when an event
@@ -210,6 +226,31 @@ function createRoomTimelineAction(
     };
 }
 
+/**
+ * Create a MatrixActions.Room.timeline action that represents a
+ * MatrixClient `Room.timeline` matrix event, emitted when an event
+ * is added to or removed from a timeline of a room.
+ *
+ * @param {MatrixClient} matrixClient the matrix client.
+ * @param {MatrixEvent} event the state event received
+ * @param {RoomState} state the room state into which the event was applied
+ * @param {MatrixEvent | null} lastStateEvent the previous value for this (event-type, state-key) tuple in room state
+ * @returns {IRoomStateEventsActionPayload} an action of type `MatrixActions.RoomState.events`.
+ */
+function createRoomStateEventsAction(
+    matrixClient: MatrixClient,
+    event: MatrixEvent,
+    state: RoomState,
+    lastStateEvent: MatrixEvent | null,
+): IRoomStateEventsActionPayload {
+    return {
+        action: 'MatrixActions.RoomState.events',
+        event,
+        state,
+        lastStateEvent,
+    };
+}
+
 /**
  * @typedef RoomMembershipAction
  * @type {Object}
@@ -312,6 +353,7 @@ export default {
         addMatrixClientListener(matrixClient, RoomEvent.Timeline, createRoomTimelineAction);
         addMatrixClientListener(matrixClient, RoomEvent.MyMembership, createSelfMembershipAction);
         addMatrixClientListener(matrixClient, MatrixEventEvent.Decrypted, createEventDecryptedAction);
+        addMatrixClientListener(matrixClient, RoomStateEvent.Events, createRoomStateEventsAction);
     },
 
     /**
diff --git a/src/actions/RoomListActions.ts b/src/actions/RoomListActions.ts
index 7860e1b2eee..696403aca82 100644
--- a/src/actions/RoomListActions.ts
+++ b/src/actions/RoomListActions.ts
@@ -90,9 +90,9 @@ export default class RoomListActions {
                 return Rooms.guessAndSetDMRoom(
                     room, newTag === DefaultTagID.DM,
                 ).catch((err) => {
-                    logger.error("Failed to set direct chat tag " + err);
-                    Modal.createTrackedDialog('Failed to set direct chat tag', '', ErrorDialog, {
-                        title: _t('Failed to set direct chat tag'),
+                    logger.error("Failed to set DM tag " + err);
+                    Modal.createTrackedDialog('Failed to set direct message tag', '', ErrorDialog, {
+                        title: _t('Failed to set direct message tag'),
                         description: ((err && err.message) ? err.message : _t('Operation failed')),
                     });
                 });
diff --git a/src/components/structures/InteractiveAuth.tsx b/src/components/structures/InteractiveAuth.tsx
index b42e65d57fb..4342355c74c 100644
--- a/src/components/structures/InteractiveAuth.tsx
+++ b/src/components/structures/InteractiveAuth.tsx
@@ -31,6 +31,17 @@ import Spinner from "../views/elements/Spinner";
 
 export const ERROR_USER_CANCELLED = new Error("User cancelled auth session");
 
+type InteractiveAuthCallbackSuccess = (
+    success: true,
+    response: IAuthData,
+    extra?: { emailSid?: string, clientSecret?: string }
+) => void;
+type InteractiveAuthCallbackFailure = (
+    success: false,
+    response: IAuthData | Error,
+) => void;
+export type InteractiveAuthCallback = InteractiveAuthCallbackSuccess & InteractiveAuthCallbackFailure;
+
 interface IProps {
     // matrix client to use for UI auth requests
     matrixClient: MatrixClient;
@@ -66,11 +77,7 @@ interface IProps {
     //            the auth session.
     //      * clientSecret {string} The client secret used in auth
     //            sessions with the ID server.
-    onAuthFinished(
-        status: boolean,
-        result: IAuthData | Error,
-        extra?: { emailSid?: string, clientSecret?: string },
-    ): void;
+    onAuthFinished: InteractiveAuthCallback;
     // As js-sdk interactive-auth
     requestEmailToken?(email: string, secret: string, attempt: number, session: string): Promise<{ sid: string }>;
     // Called when the stage changes, or the stage's phase changes. First
diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx
index 18061855506..87137ef050c 100644
--- a/src/components/structures/MatrixChat.tsx
+++ b/src/components/structures/MatrixChat.tsx
@@ -132,6 +132,7 @@ import { IConfigOptions } from "../../IConfigOptions";
 import { SnakedObject } from "../../utils/SnakedObject";
 import { leaveRoomBehaviour } from "../../utils/leave-behaviour";
 import VideoChannelStore from "../../stores/VideoChannelStore";
+import { IRoomStateEventsActionPayload } from "../../actions/MatrixActionCreators";
 
 // legacy export
 export { default as Views } from "../../Views";
@@ -652,6 +653,20 @@ export default class MatrixChat extends React.PureComponent {
             case 'view_user_info':
                 this.viewUser(payload.userId, payload.subAction);
                 break;
+            case "MatrixActions.RoomState.events": {
+                const event = (payload as IRoomStateEventsActionPayload).event;
+                if (event.getType() === EventType.RoomCanonicalAlias &&
+                    event.getRoomId() === this.state.currentRoomId
+                ) {
+                    // re-view the current room so we can update alias/id in the URL properly
+                    this.viewRoom({
+                        action: Action.ViewRoom,
+                        room_id: this.state.currentRoomId,
+                        metricsTrigger: undefined, // room doesn't change
+                    });
+                }
+                break;
+            }
             case Action.ViewRoom: {
                 // Takes either a room ID or room alias: if switching to a room the client is already
                 // known to be in (eg. user clicks on a room in the recents panel), supply the ID
@@ -744,7 +759,7 @@ export default class MatrixChat extends React.PureComponent {
             case Action.OpenDialPad:
                 Modal.createTrackedDialog('Dial pad', '', DialPadModal, {}, "mx_Dialog_dialPadWrapper");
                 break;
-            case 'on_logged_in':
+            case Action.OnLoggedIn:
                 if (
                     // Skip this handling for token login as that always calls onLoggedIn itself
                     !this.tokenLogin &&
@@ -760,7 +775,7 @@ export default class MatrixChat extends React.PureComponent {
             case 'on_client_not_viable':
                 this.onSoftLogout();
                 break;
-            case 'on_logged_out':
+            case Action.OnLoggedOut:
                 this.onLoggedOut();
                 break;
             case 'will_start_client':
@@ -892,9 +907,7 @@ export default class MatrixChat extends React.PureComponent {
 
             // Store this as the ID of the last room accessed. This is so that we can
             // persist which room is being stored across refreshes and browser quits.
-            if (localStorage) {
-                localStorage.setItem('mx_last_room_id', room.roomId);
-            }
+            localStorage?.setItem('mx_last_room_id', room.roomId);
         }
 
         // If we are redirecting to a Room Alias and it is for the room we already showing then replace history item
diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx
index c87e168456f..67ff0448c7f 100644
--- a/src/components/structures/RoomView.tsx
+++ b/src/components/structures/RoomView.tsx
@@ -927,6 +927,8 @@ export class RoomView extends React.Component {
                 if (payload.composerType) break;
 
                 let timelineRenderingType: TimelineRenderingType = payload.timelineRenderingType;
+                // ThreadView handles Action.ComposerInsert itself due to it having its own editState
+                if (timelineRenderingType === TimelineRenderingType.Thread) break;
                 if (this.state.timelineRenderingType === TimelineRenderingType.Search &&
                     payload.timelineRenderingType === TimelineRenderingType.Search
                 ) {
@@ -1217,15 +1219,6 @@ export class RoomView extends React.Component {
         if (!this.state.room || this.state.room.roomId !== state.roomId) return;
 
         switch (ev.getType()) {
-            case EventType.RoomCanonicalAlias:
-                // re-view the room so MatrixChat can manage the alias in the URL properly
-                dis.dispatch({
-                    action: Action.ViewRoom,
-                    room_id: this.state.room.roomId,
-                    metricsTrigger: undefined, // room doesn't change
-                });
-                break;
-
             case EventType.RoomTombstone:
                 this.setState({ tombstone: this.getRoomTombstone() });
                 break;
diff --git a/src/components/structures/SpaceHierarchy.tsx b/src/components/structures/SpaceHierarchy.tsx
index 9d3bf54730b..80e35d5f5ef 100644
--- a/src/components/structures/SpaceHierarchy.tsx
+++ b/src/components/structures/SpaceHierarchy.tsx
@@ -524,8 +524,13 @@ export const useRoomHierarchy = (space: Room): {
         setRooms(hierarchy.rooms);
     }, [error, hierarchy]);
 
-    const loading = hierarchy?.loading ?? true;
-    return { loading, rooms, hierarchy, loadMore, error };
+    return {
+        loading: hierarchy?.loading ?? true,
+        rooms,
+        hierarchy: hierarchy?.root === space ? hierarchy : undefined,
+        loadMore,
+        error,
+    };
 };
 
 const useIntersectionObserver = (callback: () => void) => {
diff --git a/src/components/structures/SpaceRoomView.tsx b/src/components/structures/SpaceRoomView.tsx
index ff77789802f..4f7c8019241 100644
--- a/src/components/structures/SpaceRoomView.tsx
+++ b/src/components/structures/SpaceRoomView.tsx
@@ -60,7 +60,7 @@ import {
     defaultDmsRenderer,
     defaultRoomsRenderer,
 } from "../views/dialogs/AddExistingToSpaceDialog";
-import AccessibleButton from "../views/elements/AccessibleButton";
+import AccessibleButton, { ButtonEvent } from "../views/elements/AccessibleButton";
 import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton";
 import ErrorBoundary from "../views/elements/ErrorBoundary";
 import Field from "../views/elements/Field";
@@ -295,7 +295,7 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => {
         />;
     });
 
-    const onNextClick = async (ev) => {
+    const onNextClick = async (ev: ButtonEvent) => {
         ev.preventDefault();
         if (busy) return;
         setError("");
@@ -326,7 +326,7 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => {
         setBusy(false);
     };
 
-    let onClick = (ev) => {
+    let onClick = (ev: ButtonEvent) => {
         ev.preventDefault();
         onFinished();
     };
diff --git a/src/components/structures/ThreadView.tsx b/src/components/structures/ThreadView.tsx
index 55dd32bd529..120a604b828 100644
--- a/src/components/structures/ThreadView.tsx
+++ b/src/components/structures/ThreadView.tsx
@@ -53,6 +53,7 @@ import PosthogTrackers from "../../PosthogTrackers";
 import { ButtonEvent } from "../views/elements/AccessibleButton";
 import { RoomViewStore } from '../../stores/RoomViewStore';
 import Spinner from "../views/elements/Spinner";
+import { ComposerInsertPayload, ComposerType } from "../../dispatcher/payloads/ComposerInsertPayload";
 
 interface IProps {
     room: Room;
@@ -136,6 +137,18 @@ export default class ThreadView extends React.Component {
             this.setupThread(payload.event);
         }
         switch (payload.action) {
+            case Action.ComposerInsert: {
+                if (payload.composerType) break;
+                if (payload.timelineRenderingType !== TimelineRenderingType.Thread) break;
+
+                // re-dispatch to the correct composer
+                dis.dispatch({
+                    ...(payload as ComposerInsertPayload),
+                    composerType: this.state.editState ? ComposerType.Edit : ComposerType.Send,
+                });
+                break;
+            }
+
             case Action.EditEvent:
                 // Quit early if it's not a thread context
                 if (payload.timelineRenderingType !== TimelineRenderingType.Thread) return;
diff --git a/src/components/structures/TimelinePanel.tsx b/src/components/structures/TimelinePanel.tsx
index 21a418897a1..f75edfffe13 100644
--- a/src/components/structures/TimelinePanel.tsx
+++ b/src/components/structures/TimelinePanel.tsx
@@ -419,7 +419,7 @@ class TimelinePanel extends React.Component {
         // matrix-js-sdk.
         let serializedEventIdsFromTimelineSets: { [key: string]: string[] }[];
         let serializedEventIdsFromThreadsTimelineSets: { [key: string]: string[] }[];
-        const serializedThreadsMap: { [key: string]: string[] } = {};
+        const serializedThreadsMap: { [key: string]: any } = {};
         if (room) {
             const timelineSets = room.getTimelineSets();
             const threadsTimelineSets = room.threadsTimelineSets;
@@ -430,7 +430,15 @@ class TimelinePanel extends React.Component {
 
             // Serialize all threads in the room from theadId -> event IDs in the thread
             room.getThreads().forEach((thread) => {
-                serializedThreadsMap[thread.id] = thread.events.map(ev => ev.getId());
+                serializedThreadsMap[thread.id] = {
+                    events: thread.events.map(ev => ev.getId()),
+                    numTimelines: thread.timelineSet.getTimelines().length,
+                    liveTimeline: thread.timelineSet.getLiveTimeline().getEvents().length,
+                    prevTimeline: thread.timelineSet.getLiveTimeline().getNeighbouringTimeline(Direction.Backward)
+                        ?.getEvents().length,
+                    nextTimeline: thread.timelineSet.getLiveTimeline().getNeighbouringTimeline(Direction.Forward)
+                        ?.getEvents().length,
+                };
             });
         }
 
diff --git a/src/components/structures/auth/Registration.tsx b/src/components/structures/auth/Registration.tsx
index 7515a4f0d90..d349205ab92 100644
--- a/src/components/structures/auth/Registration.tsx
+++ b/src/components/structures/auth/Registration.tsx
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-import { createClient } from 'matrix-js-sdk/src/matrix';
+import { AuthType, createClient } from 'matrix-js-sdk/src/matrix';
 import React, { Fragment, ReactNode } from 'react';
 import { MatrixClient } from "matrix-js-sdk/src/client";
 import classNames from "classnames";
@@ -34,10 +34,17 @@ import RegistrationForm from '../../views/auth/RegistrationForm';
 import AccessibleButton from '../../views/elements/AccessibleButton';
 import AuthBody from "../../views/auth/AuthBody";
 import AuthHeader from "../../views/auth/AuthHeader";
-import InteractiveAuth from "../InteractiveAuth";
+import InteractiveAuth, { InteractiveAuthCallback } from "../InteractiveAuth";
 import Spinner from "../../views/elements/Spinner";
 import { AuthHeaderDisplay } from './header/AuthHeaderDisplay';
 import { AuthHeaderProvider } from './header/AuthHeaderProvider';
+import SettingsStore from '../../../settings/SettingsStore';
+
+const debuglog = (...args: any[]) => {
+    if (SettingsStore.getValue("debug_registration")) {
+        logger.log.call(console, "Registration debuglog:", ...args);
+    }
+};
 
 interface IProps {
     serverConfig: ValidatedServerConfig;
@@ -287,9 +294,10 @@ export default class Registration extends React.Component {
         );
     };
 
-    private onUIAuthFinished = async (success: boolean, response: any) => {
+    private onUIAuthFinished: InteractiveAuthCallback = async (success, response) => {
+        debuglog("Registration: ui authentication finished: ", { success, response });
         if (!success) {
-            let errorText = response.message || response.toString();
+            let errorText: ReactNode = response.message || response.toString();
             // can we give a better error message?
             if (response.errcode === 'M_RESOURCE_LIMIT_EXCEEDED') {
                 const errorTop = messageForResourceLimitError(
@@ -312,10 +320,10 @@ export default class Registration extends React.Component {
                     

{ errorTop }

{ errorDetail }

; - } else if (response.required_stages && response.required_stages.indexOf('m.login.msisdn') > -1) { + } else if (response.required_stages && response.required_stages.includes(AuthType.Msisdn)) { let msisdnAvailable = false; for (const flow of response.available_flows) { - msisdnAvailable = msisdnAvailable || flow.stages.includes('m.login.msisdn'); + msisdnAvailable = msisdnAvailable || flow.stages.includes(AuthType.Msisdn); } if (!msisdnAvailable) { errorText = _t('This server does not support authentication with a phone number.'); @@ -351,14 +359,31 @@ export default class Registration extends React.Component { // starting the registration process. This isn't perfect since it's possible // the user had a separate guest session they didn't actually mean to replace. const [sessionOwner, sessionIsGuest] = await Lifecycle.getStoredSessionOwner(); - if (sessionOwner && !sessionIsGuest && sessionOwner !== response.userId) { + if (sessionOwner && !sessionIsGuest && sessionOwner !== response.user_id) { logger.log( - `Found a session for ${sessionOwner} but ${response.userId} has just registered.`, + `Found a session for ${sessionOwner} but ${response.user_id} has just registered.`, ); newState.differentLoggedInUserId = sessionOwner; } - if (response.access_token) { + // if we don't have an email at all, only one client can be involved in this flow, and we can directly log in. + // + // if we've got an email, it needs to be verified. in that case, two clients can be involved in this flow, the + // original client starting the process and the client that submitted the verification token. After the token + // has been submitted, it can not be used again. + // + // we can distinguish them based on whether the client has form values saved (if so, it's the one that started + // the registration), or whether it doesn't have any form values saved (in which case it's the client that + // verified the email address) + // + // as the client that started registration may be gone by the time we've verified the email, and only the client + // that verified the email is guaranteed to exist, we'll always do the login in that client. + const hasEmail = Boolean(this.state.formVals.email); + const hasAccessToken = Boolean(response.access_token); + debuglog("Registration: ui auth finished:", { hasEmail, hasAccessToken }); + if (!hasEmail && hasAccessToken) { + // we'll only try logging in if we either have no email to verify at all or we're the client that verified + // the email, not the client that started the registration flow await this.props.onLoggedIn({ userId: response.user_id, deviceId: response.device_id, @@ -416,26 +441,17 @@ export default class Registration extends React.Component { }; private makeRegisterRequest = auth => { - // We inhibit login if we're trying to register with an email address: this - // avoids a lot of complex race conditions that can occur if we try to log - // the user in one one or both of the tabs they might end up with after - // clicking the email link. - let inhibitLogin = Boolean(this.state.formVals.email); - - // Only send inhibitLogin if we're sending username / pw params - // (Since we need to send no params at all to use the ones saved in the - // session). - if (!this.state.formVals.password) inhibitLogin = null; - const registerParams = { username: this.state.formVals.username, password: this.state.formVals.password, initial_device_display_name: this.props.defaultDeviceDisplayName, auth: undefined, + // we still want to avoid the race conditions involved with multiple clients handling registration, but + // we'll handle these after we've received the access_token in onUIAuthFinished inhibit_login: undefined, }; if (auth) registerParams.auth = auth; - if (inhibitLogin !== undefined && inhibitLogin !== null) registerParams.inhibit_login = inhibitLogin; + debuglog("Registration: sending registration request:", auth); return this.state.matrixClient.registerRequest(registerParams); }; @@ -597,22 +613,22 @@ export default class Registration extends React.Component { { _t("Continue with previous account") }

; - } else if (this.state.formVals.password) { - // We're the client that started the registration - regDoneText =

{ _t( - "Log in to your new account.", {}, - { - a: (sub) => { sub }, - }, - ) }

; } else { - // We're not the original client: the user probably got to us by clicking the - // email validation link. We can't offer a 'go straight to your account' link - // as we don't have the original creds. + // regardless of whether we're the client that started the registration or not, we should + // try our credentials anyway regDoneText =

{ _t( - "You can now close this window or log in to your new account.", {}, + "Log in to your new account.", {}, { - a: (sub) => { sub }, + a: (sub) => { + const sessionLoaded = await this.onLoginClickWithCheck(event); + if (sessionLoaded) { + dis.dispatch({ action: "view_home_page" }); + } + }} + >{ sub }, }, ) }

; } diff --git a/src/components/views/beacon/BeaconListItem.tsx b/src/components/views/beacon/BeaconListItem.tsx index eda1580700e..bcfb4971766 100644 --- a/src/components/views/beacon/BeaconListItem.tsx +++ b/src/components/views/beacon/BeaconListItem.tsx @@ -23,10 +23,10 @@ import { useEventEmitterState } from '../../../hooks/useEventEmitter'; import { humanizeTime } from '../../../utils/humanize'; import { _t } from '../../../languageHandler'; import MemberAvatar from '../avatars/MemberAvatar'; -import CopyableText from '../elements/CopyableText'; import BeaconStatus from './BeaconStatus'; import { BeaconDisplayStatus } from './displayStatus'; import StyledLiveBeaconIcon from './StyledLiveBeaconIcon'; +import ShareLatestLocation from './ShareLatestLocation'; interface Props { beacon: Beacon; @@ -69,10 +69,7 @@ const BeaconListItem: React.FC = ({ beacon }) => { label={beaconMember?.name || beacon.beaconInfo.description || beacon.beaconInfoOwner} displayStatus={BeaconDisplayStatus.Active} > - latestLocationState?.uri} - /> + { _t("Updated %(humanizedUpdateTime)s", { humanizedUpdateTime }) } diff --git a/src/components/views/beacon/BeaconStatusTooltip.tsx b/src/components/views/beacon/BeaconStatusTooltip.tsx index bc9f3609395..688abc510aa 100644 --- a/src/components/views/beacon/BeaconStatusTooltip.tsx +++ b/src/components/views/beacon/BeaconStatusTooltip.tsx @@ -19,9 +19,9 @@ 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'; +import ShareLatestLocation from './ShareLatestLocation'; interface Props { beacon: Beacon; @@ -50,10 +50,7 @@ const BeaconStatusTooltip: React.FC = ({ beacon }) => { displayLiveTimeRemaining className='mx_BeaconStatusTooltip_inner' > - beacon.latestLocationState?.uri} - /> + ; }; diff --git a/src/components/views/beacon/BeaconViewDialog.tsx b/src/components/views/beacon/BeaconViewDialog.tsx index a7cdb242d37..f3e2fd12a17 100644 --- a/src/components/views/beacon/BeaconViewDialog.tsx +++ b/src/components/views/beacon/BeaconViewDialog.tsx @@ -32,12 +32,12 @@ import ZoomButtons from '../location/ZoomButtons'; import BeaconMarker from './BeaconMarker'; import { Bounds, getBeaconBounds } from '../../../utils/beacon/bounds'; import { getGeoUri } from '../../../utils/beacon'; -import { Icon as LocationIcon } from '../../../../res/img/element-icons/location.svg'; import { _t } from '../../../languageHandler'; import AccessibleButton from '../elements/AccessibleButton'; import DialogSidebar from './DialogSidebar'; import DialogOwnBeaconStatus from './DialogOwnBeaconStatus'; import BeaconStatusTooltip from './BeaconStatusTooltip'; +import MapFallback from '../location/MapFallback'; interface IProps extends IDialogProps { roomId: Room['roomId']; @@ -110,11 +110,10 @@ const BeaconViewDialog: React.FC = ({ } : -
- { _t('No live locations') } = ({ > { _t('Close') } -
+ } { isSidebarOpen ? setSidebarOpen(false)} /> : diff --git a/src/components/views/beacon/ShareLatestLocation.tsx b/src/components/views/beacon/ShareLatestLocation.tsx new file mode 100644 index 00000000000..09c179f6d62 --- /dev/null +++ b/src/components/views/beacon/ShareLatestLocation.tsx @@ -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 React, { useEffect, useState } from 'react'; +import { BeaconLocationState } from 'matrix-js-sdk/src/content-helpers'; + +import { Icon as ExternalLinkIcon } from '../../../../res/img/external-link.svg'; +import { _t } from '../../../languageHandler'; +import { makeMapSiteLink, parseGeoUri } from '../../../utils/location'; +import CopyableText from '../elements/CopyableText'; +import TooltipTarget from '../elements/TooltipTarget'; + +interface Props { + latestLocationState?: BeaconLocationState; +} + +const ShareLatestLocation: React.FC = ({ latestLocationState }) => { + const [coords, setCoords] = useState(null); + useEffect(() => { + if (!latestLocationState) { + return; + } + const coords = parseGeoUri(latestLocationState.uri); + setCoords(coords); + }, [latestLocationState]); + + if (!latestLocationState || !coords) { + return null; + } + + const latLonString = `${coords.latitude},${coords.longitude}`; + const mapLink = makeMapSiteLink(coords); + + return <> + + + + + + latLonString} + /> + ; +}; + +export default ShareLatestLocation; diff --git a/src/components/views/context_menus/MessageContextMenu.tsx b/src/components/views/context_menus/MessageContextMenu.tsx index 86e9d9cc306..8372ca14bfb 100644 --- a/src/components/views/context_menus/MessageContextMenu.tsx +++ b/src/components/views/context_menus/MessageContextMenu.tsx @@ -50,7 +50,7 @@ import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; import { GetRelationsForEvent, IEventTileOps } from "../rooms/EventTile"; import { OpenForwardDialogPayload } from "../../../dispatcher/payloads/OpenForwardDialogPayload"; import { OpenReportEventDialogPayload } from "../../../dispatcher/payloads/OpenReportEventDialogPayload"; -import { createMapSiteLink } from '../../../utils/location'; +import { createMapSiteLinkFromEvent } from '../../../utils/location'; interface IProps extends IPosition { chevronFace: ChevronFace; @@ -155,6 +155,15 @@ export default class MessageContextMenu extends React.Component this.closeMenu(); }; + private onJumpToRelatedEventClick = (relatedEventId: string): void => { + dis.dispatch({ + action: "view_room", + room_id: this.props.mxEvent.getRoomId(), + event_id: relatedEventId, + highlighted: true, + }); + }; + private onReportEventClick = (): void => { dis.dispatch({ action: Action.OpenReportEventDialog, @@ -351,7 +360,7 @@ export default class MessageContextMenu extends React.Component let openInMapSiteButton: JSX.Element; if (this.canOpenInMapSite(mxEvent)) { - const mapSiteLink = createMapSiteLink(mxEvent); + const mapSiteLink = createMapSiteLinkFromEvent(mxEvent); openInMapSiteButton = ( ); } + let jumpToRelatedEventButton: JSX.Element; + const relatedEventId = mxEvent.getWireContent()?.["m.relates_to"]?.event_id; + if (relatedEventId && SettingsStore.getValue("developerMode")) { + jumpToRelatedEventButton = ( + this.onJumpToRelatedEventClick(relatedEventId)} + /> + ); + } + let reportEventButton: JSX.Element; if (mxEvent.getSender() !== me) { reportEventButton = ( @@ -608,6 +629,7 @@ export default class MessageContextMenu extends React.Component { permalinkButton } { reportEventButton } { externalURLButton } + { jumpToRelatedEventButton } { unhidePreviewButton } { viewSourceButton } { resendReactionsButton } diff --git a/src/components/views/dialogs/DeactivateAccountDialog.tsx b/src/components/views/dialogs/DeactivateAccountDialog.tsx index dbdc3b3639a..55251025fb7 100644 --- a/src/components/views/dialogs/DeactivateAccountDialog.tsx +++ b/src/components/views/dialogs/DeactivateAccountDialog.tsx @@ -22,7 +22,7 @@ import { logger } from "matrix-js-sdk/src/logger"; import Analytics from '../../../Analytics'; import { MatrixClientPeg } from '../../../MatrixClientPeg'; import { _t } from '../../../languageHandler'; -import InteractiveAuth, { ERROR_USER_CANCELLED } from "../../structures/InteractiveAuth"; +import InteractiveAuth, { ERROR_USER_CANCELLED, InteractiveAuthCallback } from "../../structures/InteractiveAuth"; import { DEFAULT_PHASE, PasswordAuthEntry, SSOAuthEntry } from "../auth/InteractiveAuthEntryComponents"; import StyledCheckbox from "../elements/StyledCheckbox"; import BaseDialog from "./BaseDialog"; @@ -104,7 +104,7 @@ export default class DeactivateAccountDialog extends React.Component { + private onUIAuthFinished: InteractiveAuthCallback = (success, result) => { if (success) return; // great! makeRequest() will be called too. if (result === ERROR_USER_CANCELLED) { diff --git a/src/components/views/dialogs/InteractiveAuthDialog.tsx b/src/components/views/dialogs/InteractiveAuthDialog.tsx index 46bad5fd0e1..6f10790811e 100644 --- a/src/components/views/dialogs/InteractiveAuthDialog.tsx +++ b/src/components/views/dialogs/InteractiveAuthDialog.tsx @@ -22,7 +22,7 @@ import { IAuthData } from "matrix-js-sdk/src/interactive-auth"; import { _t } from '../../../languageHandler'; import AccessibleButton from '../elements/AccessibleButton'; -import InteractiveAuth, { ERROR_USER_CANCELLED } from "../../structures/InteractiveAuth"; +import InteractiveAuth, { ERROR_USER_CANCELLED, InteractiveAuthCallback } from "../../structures/InteractiveAuth"; import { SSOAuthEntry } from "../auth/InteractiveAuthEntryComponents"; import BaseDialog from "./BaseDialog"; import { IDialogProps } from "./IDialogProps"; @@ -117,7 +117,7 @@ export default class InteractiveAuthDialog extends React.Component { + private onAuthFinished: InteractiveAuthCallback = (success, result): void => { if (success) { this.props.onFinished(true, result); } else { diff --git a/src/components/views/dialogs/ReportEventDialog.tsx b/src/components/views/dialogs/ReportEventDialog.tsx index 7a2d51889f8..929c33b128e 100644 --- a/src/components/views/dialogs/ReportEventDialog.tsx +++ b/src/components/views/dialogs/ReportEventDialog.tsx @@ -1,5 +1,6 @@ /* Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> +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. @@ -30,6 +31,7 @@ import BaseDialog from "./BaseDialog"; import DialogButtons from "../elements/DialogButtons"; import Field from "../elements/Field"; import Spinner from "../elements/Spinner"; +import LabelledCheckbox from "../elements/LabelledCheckbox"; interface IProps extends IDialogProps { mxEvent: MatrixEvent; @@ -42,6 +44,7 @@ interface IState { err?: string; // If we know it, the nature of the abuse, as specified by MSC3215. nature?: ExtendedNature; + ignoreUserToo: boolean; // if true, user will be ignored/blocked on submit } const MODERATED_BY_STATE_EVENT_TYPE = [ @@ -160,9 +163,14 @@ export default class ReportEventDialog extends React.Component { err: null, // If specified, the nature of the abuse, as specified by MSC3215. nature: null, + ignoreUserToo: false, // default false, for now. Could easily be argued as default true }; } + private onIgnoreUserTooChanged = (newVal: boolean): void => { + this.setState({ ignoreUserToo: newVal }); + }; + // The user has written down a freeform description of the abuse. private onReasonChange = ({ target: { value: reason } }): void => { this.setState({ reason }); @@ -232,6 +240,15 @@ export default class ReportEventDialog extends React.Component { // Report to homeserver admin through the dedicated Matrix API. await client.reportEvent(ev.getRoomId(), ev.getId(), -100, this.state.reason.trim()); } + + // if the user should also be ignored, do that + if (this.state.ignoreUserToo) { + await client.setIgnoredUsers([ + ...client.getIgnoredUsers(), + ev.getSender(), + ]); + } + this.props.onFinished(true); } catch (e) { logger.error(e); @@ -242,7 +259,7 @@ export default class ReportEventDialog extends React.Component { } }; - render() { + public render() { let error = null; if (this.state.err) { error =
@@ -259,6 +276,14 @@ export default class ReportEventDialog extends React.Component { ); } + const ignoreUserCheckbox = ; + const adminMessageMD = SdkConfig .getObject("report_event")?.get("admin_message_md", "adminMessageMD"); let adminMessage; @@ -387,6 +412,7 @@ export default class ReportEventDialog extends React.Component { /> { progress } { error } + { ignoreUserCheckbox }
{ /> { progress } { error } + { ignoreUserCheckbox } string; border?: boolean; + className?: string; } -const CopyableText: React.FC = ({ children, getTextToCopy, border=true }) => { +const CopyableText: React.FC = ({ children, getTextToCopy, border=true, className }) => { const [tooltip, setTooltip] = useState(undefined); const onCopyClickInternal = async (e: ButtonEvent) => { @@ -44,11 +45,11 @@ const CopyableText: React.FC = ({ children, getTextToCopy, border=true } } }; - const className = classNames("mx_CopyableText", { + const combinedClassName = classNames("mx_CopyableText", className, { mx_CopyableText_border: border, }); - return
+ return
{ children } = ({ value, label, byline, disabled, onChange }) => { + return ; +}; + +export default LabelledCheckbox; diff --git a/src/components/views/elements/LabelledToggleSwitch.tsx b/src/components/views/elements/LabelledToggleSwitch.tsx index 952c92ac427..6df972440a9 100644 --- a/src/components/views/elements/LabelledToggleSwitch.tsx +++ b/src/components/views/elements/LabelledToggleSwitch.tsx @@ -1,5 +1,5 @@ /* -Copyright 2019 - 2021 The Matrix.org Foundation C.I.C. +Copyright 2019 - 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. @@ -15,6 +15,7 @@ limitations under the License. */ import React from "react"; +import classNames from "classnames"; import ToggleSwitch from "./ToggleSwitch"; @@ -35,7 +36,7 @@ interface IProps { } export default class LabelledToggleSwitch extends React.PureComponent { - render() { + public render() { // This is a minimal version of a SettingsFlag let firstPart = { this.props.label }; @@ -52,7 +53,9 @@ export default class LabelledToggleSwitch extends React.PureComponent { secondPart = temp; } - const classes = `mx_SettingsFlag ${this.props.className || ""}`; + const classes = classNames("mx_SettingsFlag", this.props.className, { + "mx_SettingsFlag_toggleInFront": this.props.toggleInFront, + }); return (
{ firstPart } diff --git a/src/components/views/location/MapFallback.tsx b/src/components/views/location/MapFallback.tsx new file mode 100644 index 00000000000..75545d5e0fd --- /dev/null +++ b/src/components/views/location/MapFallback.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 React from 'react'; +import classNames from 'classnames'; + +import { Icon as LocationMarkerIcon } from '../../../../res/img/element-icons/location.svg'; +import { Icon as MapFallbackImage } from '../../../../res/img/location/map.svg'; +import Spinner from '../elements/Spinner'; + +interface Props extends React.HTMLAttributes { + className?: string; + isLoading?: boolean; + children?: React.ReactNode | React.ReactNodeArray; +} + +const MapFallback: React.FC = ({ className, isLoading, children, ...rest }) => { + return
+ + { /*
*/ } + { isLoading ? : } + { children } +
; +}; + +export default MapFallback; diff --git a/src/components/views/messages/MBeaconBody.tsx b/src/components/views/messages/MBeaconBody.tsx index fb82cff29e2..bd581d1bce8 100644 --- a/src/components/views/messages/MBeaconBody.tsx +++ b/src/components/views/messages/MBeaconBody.tsx @@ -19,7 +19,6 @@ import { Beacon, BeaconEvent, MatrixEvent } from 'matrix-js-sdk/src/matrix'; import { BeaconLocationState } from 'matrix-js-sdk/src/content-helpers'; import { randomString } from 'matrix-js-sdk/src/randomstring'; -import { Icon as LocationMarkerIcon } from '../../../../res/img/element-icons/location.svg'; import MatrixClientContext from '../../../contexts/MatrixClientContext'; import { useEventEmitterState } from '../../../hooks/useEventEmitter'; import { _t } from '../../../languageHandler'; @@ -28,8 +27,8 @@ import { useBeacon } from '../../../utils/beacon'; import { isSelfLocation } from '../../../utils/location'; import { BeaconDisplayStatus, getBeaconDisplayStatus } from '../beacon/displayStatus'; import BeaconStatus from '../beacon/BeaconStatus'; -import Spinner from '../elements/Spinner'; import Map from '../location/Map'; +import MapFallback from '../location/MapFallback'; import SmartMarker from '../location/SmartMarker'; import OwnBeaconStatus from '../beacon/OwnBeaconStatus'; import BeaconViewDialog from '../beacon/BeaconViewDialog'; @@ -134,12 +133,10 @@ const MBeaconBody: React.FC = React.forwardRef(({ mxEvent }, ref) => /> } - :
- { displayStatus === BeaconDisplayStatus.Loading ? - : - - } -
+ : } { isOwnBeacon ? { } } + protected getBanner(content: IMediaEventContent): JSX.Element { + // Hide it for the threads list & the file panel where we show it as text anyway. + if ([ + TimelineRenderingType.ThreadsList, + TimelineRenderingType.File, + ].includes(this.context.timelineRenderingType)) { + return null; + } + + return ( + + { presentableTextForFile(content, _t("Image"), true, true) } + + ); + } + protected messageContent( contentUrl: string, thumbUrl: string, @@ -448,18 +464,8 @@ export default class MImageBody extends React.Component { } let banner: JSX.Element; - const isTimeline = [ - TimelineRenderingType.Room, - TimelineRenderingType.Search, - TimelineRenderingType.Thread, - TimelineRenderingType.Notification, - ].includes(this.context.timelineRenderingType); - if (this.state.showImage && this.state.hover && isTimeline) { - banner = ( - - { presentableTextForFile(content, _t("Image"), true, true) } - - ); + if (this.state.showImage && this.state.hover) { + banner = this.getBanner(content); } const classes = classNames({ diff --git a/src/components/views/messages/MImageReplyBody.tsx b/src/components/views/messages/MImageReplyBody.tsx index 9edbcec304d..b36438741d0 100644 --- a/src/components/views/messages/MImageReplyBody.tsx +++ b/src/components/views/messages/MImageReplyBody.tsx @@ -40,6 +40,10 @@ export default class MImageReplyBody extends MImageBody { return presentableTextForFile(this.props.mxEvent.getContent(), sticker ? _t("Sticker") : _t("Image"), !sticker); } + protected getBanner(content: IMediaEventContent): JSX.Element { + return null; // we don't need a banner, nor have space for one + } + render() { if (this.state.error) { return super.render(); diff --git a/src/components/views/messages/MLocationBody.tsx b/src/components/views/messages/MLocationBody.tsx index ff87af1dc3a..f5cdf3a969c 100644 --- a/src/components/views/messages/MLocationBody.tsx +++ b/src/components/views/messages/MLocationBody.tsx @@ -16,6 +16,7 @@ limitations under the License. import React from 'react'; import { MatrixEvent } from 'matrix-js-sdk/src/models/event'; +import { randomString } from 'matrix-js-sdk/src/randomstring'; import { _t } from '../../../languageHandler'; import Modal from '../../../Modal'; @@ -45,10 +46,9 @@ export default class MLocationBody extends React.Component { constructor(props: IBodyProps) { super(props); - const randomString = Math.random().toString(16).slice(2, 10); // multiple instances of same map might be in document // eg thread and main timeline, reply - const idSuffix = `${props.mxEvent.getId()}_${randomString}`; + const idSuffix = `${props.mxEvent.getId()}_${randomString(8)}`; this.mapId = `mx_MLocationBody_${idSuffix}`; this.state = { diff --git a/src/components/views/messages/MStickerBody.tsx b/src/components/views/messages/MStickerBody.tsx index 62e2b2ea69e..40c43d73f00 100644 --- a/src/components/views/messages/MStickerBody.tsx +++ b/src/components/views/messages/MStickerBody.tsx @@ -19,6 +19,7 @@ import React from 'react'; import MImageBody from './MImageBody'; import { BLURHASH_FIELD } from "../../../utils/image-media"; import Tooltip from "../elements/Tooltip"; +import { IMediaEventContent } from "../../../customisations/models/IMediaEventContent"; export default class MStickerBody extends MImageBody { // Mostly empty to prevent default behaviour of MImageBody @@ -70,4 +71,8 @@ export default class MStickerBody extends MImageBody { protected getFileBody() { return null; } + + protected getBanner(content: IMediaEventContent): JSX.Element { + return null; // we don't need a banner, we have a tooltip + } } diff --git a/src/components/views/messages/MessageActionBar.tsx b/src/components/views/messages/MessageActionBar.tsx index 85ea8387555..7bd7fd719e8 100644 --- a/src/components/views/messages/MessageActionBar.tsx +++ b/src/components/views/messages/MessageActionBar.tsx @@ -76,6 +76,17 @@ const OptionsButton: React.FC = ({ onFocusChange(menuDisplayed); }, [onFocusChange, menuDisplayed]); + const onOptionsClick = (e: React.MouseEvent): void => { + // Don't open the regular browser or our context menu on right-click + e.preventDefault(); + e.stopPropagation(); + openMenu(); + // when the context menu is opened directly, e.g. via mouse click, the onFocus handler which tracks + // the element that is currently focused is skipped. So we want to call onFocus manually to keep the + // position in the page even when someone is clicking around. + onFocus(); + }; + let contextMenu: ReactElement | null; if (menuDisplayed) { const tile = getTile && getTile(); @@ -97,13 +108,7 @@ const OptionsButton: React.FC = ({ { - openMenu(); - // when the context menu is opened directly, e.g. via mouse click, the onFocus handler which tracks - // the element that is currently focused is skipped. So we want to call onFocus manually to keep the - // position in the page even when someone is clicking around. - onFocus(); - }} + onClick={onOptionsClick} isExpanded={menuDisplayed} inputRef={ref} onFocus={onFocus} diff --git a/src/components/views/right_panel/UserInfo.tsx b/src/components/views/right_panel/UserInfo.tsx index f1c956bb8f3..6a12792e27f 100644 --- a/src/components/views/right_panel/UserInfo.tsx +++ b/src/components/views/right_panel/UserInfo.tsx @@ -1428,8 +1428,8 @@ const UserInfoHeader: React.FC<{ const avatarElement = (
-
-
+
+
{ this.setState({ localAliasesLoading: true }); try { const mxClient = this.context; + let localAliases = []; - if (await mxClient.doesServerSupportUnstableFeature("org.matrix.msc2432")) { - const response = await mxClient.unstableGetLocalAliases(this.props.roomId); - if (Array.isArray(response.aliases)) { - localAliases = response.aliases; - } + const response = await mxClient.getLocalAliases(this.props.roomId); + if (Array.isArray(response?.aliases)) { + localAliases = response.aliases; } this.setState({ localAliases }); diff --git a/src/components/views/rooms/BasicMessageComposer.tsx b/src/components/views/rooms/BasicMessageComposer.tsx index 667d5a42a4e..fd3f5eed3dc 100644 --- a/src/components/views/rooms/BasicMessageComposer.tsx +++ b/src/components/views/rooms/BasicMessageComposer.tsx @@ -354,7 +354,8 @@ export default class BasicMessageEditor extends React.Component this.modifiedFlag = true; const range = getRangeForSelection(this.editorRef.current, model, document.getSelection()); - if (plainText && range.length > 0 && linkify.test(plainText)) { + // If the user is pasting a link, and has a range selected which is not a link, wrap the range with the link + if (plainText && range.length > 0 && linkify.test(plainText) && !linkify.test(range.text)) { formatRangeAsLink(range, plainText); } else { replaceRangeAndMoveCaret(range, parts); @@ -448,12 +449,11 @@ export default class BasicMessageEditor extends React.Component const selection = document.getSelection(); if (this.hasTextSelected && selection.isCollapsed) { this.hasTextSelected = false; - if (this.formatBarRef.current) { - this.formatBarRef.current.hide(); - } + this.formatBarRef.current?.hide(); } else if (!selection.isCollapsed && !isEmpty) { this.hasTextSelected = true; - if (this.formatBarRef.current && this.state.useMarkdown) { + const range = getRangeForSelection(this.editorRef.current, this.props.model, selection); + if (this.formatBarRef.current && this.state.useMarkdown && !!range.text.trim()) { const selectionRect = selection.getRangeAt(0).getBoundingClientRect(); this.formatBarRef.current.showAt(selectionRect); } diff --git a/src/components/views/rooms/ReadReceiptGroup.tsx b/src/components/views/rooms/ReadReceiptGroup.tsx index fc4c6796faa..34891810e33 100644 --- a/src/components/views/rooms/ReadReceiptGroup.tsx +++ b/src/components/views/rooms/ReadReceiptGroup.tsx @@ -209,10 +209,12 @@ export function ReadReceiptGroup( return (
-
+
{ const cli = this.context; - if (await cli.doesServerSupportUnstableFeature("org.matrix.msc2432")) { - const response = await cli.unstableGetLocalAliases(this.props.roomId); - const localAliases = response.aliases; - return Array.isArray(localAliases) && localAliases.length !== 0; - } else { - const room = cli.getRoom(this.props.roomId); - const aliasEvents = room.currentState.getStateEvents(EventType.RoomAliases) || []; - const hasAliases = !!aliasEvents.find((ev) => (ev.getContent().aliases || []).length > 0); - return hasAliases; - } + const response = await cli.getLocalAliases(this.props.roomId); + const localAliases = response.aliases; + return Array.isArray(localAliases) && localAliases.length !== 0; } private renderJoinRule() { diff --git a/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.tsx b/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.tsx index 4e25fb9ddc9..2ec7fcd81fa 100644 --- a/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.tsx @@ -29,6 +29,7 @@ import dis from "../../../../../dispatcher/dispatcher"; import { UserTab } from "../../../dialogs/UserTab"; import { OpenToTabPayload } from "../../../../../dispatcher/payloads/OpenToTabPayload"; import { Action } from "../../../../../dispatcher/actions"; +import SdkConfig from "../../../../../SdkConfig"; interface IProps { closeSettingsFn(success: boolean): void; @@ -43,6 +44,8 @@ interface IState { alwaysShowMenuBar: boolean; minimizeToTraySupported: boolean; minimizeToTray: boolean; + togglingHardwareAccelerationSupported: boolean; + enableHardwareAcceleration: boolean; autocompleteDelay: string; readMarkerInViewThresholdMs: string; readMarkerOutOfViewThresholdMs: string; @@ -121,6 +124,8 @@ export default class PreferencesUserSettingsTab extends React.Component this.setState({ minimizeToTray: checked })); }; + private onHardwareAccelerationChange = (checked: boolean) => { + PlatformPeg.get().setHardwareAccelerationEnabled(checked).then( + () => this.setState({ enableHardwareAcceleration: checked })); + }; + private onAutocompleteDelayChange = (e: React.ChangeEvent) => { this.setState({ autocompleteDelay: e.target.value }); SettingsStore.setValue("autocompleteDelay", null, SettingLevel.DEVICE, e.target.value); @@ -250,6 +268,17 @@ export default class PreferencesUserSettingsTab extends React.Component; } + let hardwareAccelerationOption = null; + if (this.state.togglingHardwareAccelerationSupported) { + const appName = SdkConfig.get().brand; + hardwareAccelerationOption = ; + } + return (
{ _t("Preferences") }
@@ -307,6 +336,7 @@ export default class PreferencesUserSettingsTab extends React.Component{ _t("General") } { this.renderGroup(PreferencesUserSettingsTab.GENERAL_SETTINGS) } { minimizeToTrayOption } + { hardwareAccelerationOption } { autoHideMenuOption } { autoLaunchOption } { warnBeforeExitOption } diff --git a/src/dispatcher/actions.ts b/src/dispatcher/actions.ts index 27f0b423c07..95c08075730 100644 --- a/src/dispatcher/actions.ts +++ b/src/dispatcher/actions.ts @@ -317,5 +317,15 @@ export enum Action { /** * Show current room topic */ - ShowRoomTopic = "show_room_topic" + ShowRoomTopic = "show_room_topic", + + /** + * Fired when the client was logged out. No additional payload information required. + */ + OnLoggedOut = "on_logged_out", + + /** + * Fired when the client was logged in. No additional payload information required. + */ + OnLoggedIn = "on_logged_in", } diff --git a/src/editor/parts.ts b/src/editor/parts.ts index 0d5d6a6613e..c1f729017d1 100644 --- a/src/editor/parts.ts +++ b/src/editor/parts.ts @@ -95,6 +95,7 @@ abstract class BasePart { this._text = text; } + // chr can also be a grapheme cluster protected acceptsInsertion(chr: string, offset: number, inputType: string): boolean { return true; } @@ -130,14 +131,20 @@ abstract class BasePart { // append str, returns the remaining string if a character was rejected. public appendUntilRejected(str: string, inputType: string): string | undefined { const offset = this.text.length; - for (let i = 0; i < str.length; ++i) { - const chr = str.charAt(i); - if (!this.acceptsInsertion(chr, offset + i, inputType)) { - this._text = this._text + str.slice(0, i); - return str.slice(i); + // Take a copy as we will be taking chunks off the start of the string as we process them + // To only need to grapheme split the bits of the string we're working on. + let buffer = str; + while (buffer) { + // We use lodash's grapheme splitter to avoid breaking apart compound emojis + const [char] = split(buffer, "", 2); + if (!this.acceptsInsertion(char, offset + str.length - buffer.length, inputType)) { + break; } + buffer = buffer.slice(char.length); } - this._text = this._text + str; + + this._text += str.slice(0, str.length - buffer.length); + return buffer || undefined; } // inserts str at offset if all the characters in str were accepted, otherwise don't do anything @@ -363,7 +370,7 @@ class NewlinePart extends BasePart implements IBasePart { } } -class EmojiPart extends BasePart implements IBasePart { +export class EmojiPart extends BasePart implements IBasePart { protected acceptsInsertion(chr: string, offset: number): boolean { return EMOJIBASE_REGEX.test(chr); } @@ -585,7 +592,8 @@ export class PartCreator { case "\n": return new NewlinePart(); default: - if (EMOJIBASE_REGEX.test(input[0])) { + // We use lodash's grapheme splitter to avoid breaking apart compound emojis + if (EMOJIBASE_REGEX.test(split(input, "", 2)[0])) { return new EmojiPart(); } return new PlainPart(); diff --git a/src/hooks/usePublicRoomDirectory.ts b/src/hooks/usePublicRoomDirectory.ts new file mode 100644 index 00000000000..24cc8f541a1 --- /dev/null +++ b/src/hooks/usePublicRoomDirectory.ts @@ -0,0 +1,164 @@ +/* +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 { useCallback, useEffect, useState } from "react"; +import { IProtocol, IPublicRoomsChunkRoom } from "matrix-js-sdk/src/client"; +import { IRoomDirectoryOptions } from "matrix-js-sdk/src/@types/requests"; + +import { MatrixClientPeg } from "../MatrixClientPeg"; +import SdkConfig from "../SdkConfig"; +import SettingsStore from "../settings/SettingsStore"; +import { Protocols } from "../utils/DirectoryUtils"; + +export const ALL_ROOMS = "ALL_ROOMS"; +const LAST_SERVER_KEY = "mx_last_room_directory_server"; +const LAST_INSTANCE_KEY = "mx_last_room_directory_instance"; + +export interface IPublicRoomsOpts { + limit: number; + query?: string; +} + +let thirdParty: Protocols; + +export const usePublicRoomDirectory = () => { + const [publicRooms, setPublicRooms] = useState([]); + + const [roomServer, setRoomServer] = useState(undefined); + const [instanceId, setInstanceId] = useState(undefined); + const [protocols, setProtocols] = useState(null); + + const [ready, setReady] = useState(false); + const [loading, setLoading] = useState(false); + + async function initProtocols() { + if (!MatrixClientPeg.get()) { + // We may not have a client yet when invoked from welcome page + setReady(true); + } else if (thirdParty) { + setProtocols(thirdParty); + } else { + const response = await MatrixClientPeg.get().getThirdpartyProtocols(); + thirdParty = response; + setProtocols(response); + } + } + + function setConfig(server: string, instanceId?: string) { + if (!ready) { + throw new Error("public room configuration not initialised yet"); + } else { + setRoomServer(server); + setInstanceId(instanceId ?? null); + } + } + + const search = useCallback(async ({ + limit = 20, + query, + }: IPublicRoomsOpts): Promise => { + if (!query?.length) { + setPublicRooms([]); + return true; + } + + const opts: IRoomDirectoryOptions = { limit }; + + if (roomServer != MatrixClientPeg.getHomeserverName()) { + opts.server = roomServer; + } + + if (instanceId === ALL_ROOMS) { + opts.include_all_networks = true; + } else if (instanceId) { + opts.third_party_instance_id = instanceId; + } + + if (query) { + opts.filter = { + generic_search_term: query, + }; + } + + try { + setLoading(true); + const { chunk } = await MatrixClientPeg.get().publicRooms(opts); + setPublicRooms(chunk); + return true; + } catch (e) { + console.error("Could not fetch public rooms for params", opts, e); + setPublicRooms([]); + return false; + } finally { + setLoading(false); + } + }, [roomServer, instanceId]); + + useEffect(() => { + initProtocols(); + }, []); + + useEffect(() => { + if (protocols === null) { + return; + } + + const myHomeserver = MatrixClientPeg.getHomeserverName(); + const lsRoomServer = localStorage.getItem(LAST_SERVER_KEY); + const lsInstanceId = localStorage.getItem(LAST_INSTANCE_KEY); + + let roomServer = myHomeserver; + if ( + SdkConfig.getObject("room_directory")?.get("servers")?.includes(lsRoomServer) || + SettingsStore.getValue("room_directory_servers")?.includes(lsRoomServer) + ) { + roomServer = lsRoomServer; + } + + let instanceId: string | null = null; + if (roomServer === myHomeserver && ( + lsInstanceId === ALL_ROOMS || + Object.values(protocols).some((p: IProtocol) => { + p.instances.some(i => i.instance_id === lsInstanceId); + }) + )) { + instanceId = lsInstanceId; + } + + setReady(true); + setInstanceId(instanceId); + setRoomServer(roomServer); + }, [protocols]); + + useEffect(() => { + localStorage.setItem(LAST_SERVER_KEY, roomServer); + }, [roomServer]); + + useEffect(() => { + localStorage.setItem(LAST_INSTANCE_KEY, instanceId); + }, [instanceId]); + + return { + ready, + loading, + publicRooms, + protocols, + roomServer, + instanceId, + search, + setConfig, + } as const; +}; diff --git a/src/hooks/useUserDirectory.ts b/src/hooks/useUserDirectory.ts new file mode 100644 index 00000000000..cb7307af2ac --- /dev/null +++ b/src/hooks/useUserDirectory.ts @@ -0,0 +1,64 @@ +/* +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 { useCallback, useState } from "react"; + +import { MatrixClientPeg } from "../MatrixClientPeg"; +import { DirectoryMember } from "../utils/direct-messages"; + +export interface IUserDirectoryOpts { + limit: number; + query?: string; +} + +export const useUserDirectory = () => { + const [users, setUsers] = useState([]); + + const [loading, setLoading] = useState(false); + + const search = useCallback(async ({ + limit = 20, + query: term, + }: IUserDirectoryOpts): Promise => { + if (!term?.length) { + setUsers([]); + return true; + } + + try { + setLoading(true); + const { results } = await MatrixClientPeg.get().searchUserDirectory({ + limit, + term, + }); + setUsers(results.map(user => new DirectoryMember(user))); + return true; + } catch (e) { + console.error("Could not fetch user in user directory for params", { limit, term }, e); + setUsers([]); + return false; + } finally { + setLoading(false); + } + }, []); + + return { + ready: true, + loading, + users, + search, + } as const; +}; diff --git a/src/rageshake/rageshake.ts b/src/rageshake/rageshake.ts index 44a60acd08c..e8461eef763 100644 --- a/src/rageshake/rageshake.ts +++ b/src/rageshake/rageshake.ts @@ -39,6 +39,7 @@ limitations under the License. // the frequency with which we flush to indexeddb import { logger } from "matrix-js-sdk/src/logger"; +import { randomString } from "matrix-js-sdk/src/randomstring"; import { getCircularReplacer } from "../utils/JSON"; @@ -81,7 +82,7 @@ export class ConsoleLogger { this.originalFunctions[fnName](...args); } - private log(level: string, ...args: (Error | DOMException | object | string)[]): void { + public log(level: string, ...args: (Error | DOMException | object | string)[]): void { // We don't know what locale the user may be running so use ISO strings const ts = new Date().toISOString(); @@ -140,7 +141,7 @@ export class IndexedDBLogStore { private indexedDB: IDBFactory, private logger: ConsoleLogger, ) { - this.id = "instance-" + Math.random() + Date.now(); + this.id = "instance-" + randomString(16); } /** diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index a5ca0dbf843..e5cf0b998c2 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -45,6 +45,7 @@ import { ImageSize } from "./enums/ImageSize"; import { MetaSpace } from "../stores/spaces"; import SdkConfig from "../SdkConfig"; import ThreadBetaController from './controllers/ThreadBetaController'; +import { FontWatcher } from "./watchers/FontWatcher"; // These are just a bunch of helper arrays to avoid copy/pasting a bunch of times const LEVELS_ROOM_SETTINGS = [ @@ -434,7 +435,7 @@ export const SETTINGS: {[setting: string]: ISetting} = { "baseFontSize": { displayName: _td("Font size"), supportedLevels: LEVELS_ACCOUNT_SETTINGS, - default: 10, + default: FontWatcher.DEFAULT_SIZE, controller: new FontSizeController(), }, "useCustomFontSize": { @@ -1003,6 +1004,10 @@ export const SETTINGS: {[setting: string]: ISetting} = { supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS, default: false, }, + "debug_registration": { + supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS, + default: false, + }, "audioInputMuted": { supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS, default: false, diff --git a/src/settings/watchers/FontWatcher.ts b/src/settings/watchers/FontWatcher.ts index c70dd78349f..cacbcb6862a 100644 --- a/src/settings/watchers/FontWatcher.ts +++ b/src/settings/watchers/FontWatcher.ts @@ -20,9 +20,12 @@ import IWatcher from "./Watcher"; import { toPx } from '../../utils/units'; import { Action } from '../../dispatcher/actions'; import { SettingLevel } from "../SettingLevel"; +import { UpdateSystemFontPayload } from "../../dispatcher/payloads/UpdateSystemFontPayload"; +import { ActionPayload } from "../../dispatcher/payloads"; export class FontWatcher implements IWatcher { public static readonly MIN_SIZE = 8; + public static readonly DEFAULT_SIZE = 10; public static readonly MAX_SIZE = 15; // Externally we tell the user the font is size 15. Internally we use 10. public static readonly SIZE_DIFF = 5; @@ -34,11 +37,7 @@ export class FontWatcher implements IWatcher { } public start() { - this.setRootFontSize(SettingsStore.getValue("baseFontSize")); - this.setSystemFont({ - useSystemFont: SettingsStore.getValue("useSystemFont"), - font: SettingsStore.getValue("systemFont"), - }); + this.updateFont(); this.dispatcherRef = dis.register(this.onAction); } @@ -46,15 +45,33 @@ export class FontWatcher implements IWatcher { dis.unregister(this.dispatcherRef); } - private onAction = (payload) => { + private updateFont() { + this.setRootFontSize(SettingsStore.getValue("baseFontSize")); + this.setSystemFont({ + useSystemFont: SettingsStore.getValue("useSystemFont"), + font: SettingsStore.getValue("systemFont"), + }); + } + + private onAction = (payload: ActionPayload) => { if (payload.action === Action.UpdateFontSize) { this.setRootFontSize(payload.size); } else if (payload.action === Action.UpdateSystemFont) { - this.setSystemFont(payload); + this.setSystemFont(payload as UpdateSystemFontPayload); + } else if (payload.action === Action.OnLoggedOut) { + // Clear font overrides when logging out + this.setRootFontSize(FontWatcher.DEFAULT_SIZE); + this.setSystemFont({ + useSystemFont: false, + font: "", + }); + } else if (payload.action === Action.OnLoggedIn) { + // Font size can be saved on the account, so grab value when logging in + this.updateFont(); } }; - private setRootFontSize = (size) => { + private setRootFontSize = (size: number) => { const fontSize = Math.max(Math.min(FontWatcher.MAX_SIZE, size), FontWatcher.MIN_SIZE); if (fontSize !== size) { @@ -63,7 +80,21 @@ export class FontWatcher implements IWatcher { document.querySelector(":root").style.fontSize = toPx(fontSize); }; - private setSystemFont = ({ useSystemFont, font }) => { - document.body.style.fontFamily = useSystemFont ? font : ""; + private setSystemFont = ({ useSystemFont, font }: Pick) => { + if (useSystemFont) { + // Make sure that fonts with spaces in their names get interpreted properly + document.body.style.fontFamily = font + .split(',') + .map(font => { + font = font.trim(); + if (!font.startsWith('"') && !font.endsWith('"')) { + font = `"${font}"`; + } + return font; + }) + .join(','); + } else { + document.body.style.fontFamily = ""; + } }; } diff --git a/src/stores/LifecycleStore.ts b/src/stores/LifecycleStore.ts index 618f4b4162f..5d10ed3e396 100644 --- a/src/stores/LifecycleStore.ts +++ b/src/stores/LifecycleStore.ts @@ -71,7 +71,7 @@ class LifecycleStore extends Store { break; } case 'on_client_not_viable': - case 'on_logged_out': + case Action.OnLoggedOut: this.reset(); break; } diff --git a/src/stores/ReadyWatchingStore.ts b/src/stores/ReadyWatchingStore.ts index c44664a08e7..a142693e62c 100644 --- a/src/stores/ReadyWatchingStore.ts +++ b/src/stores/ReadyWatchingStore.ts @@ -22,6 +22,7 @@ import { EventEmitter } from "events"; import { MatrixClientPeg } from "../MatrixClientPeg"; import { ActionPayload } from "../dispatcher/payloads"; import { IDestroyable } from "../utils/IDestroyable"; +import { Action } from "../dispatcher/actions"; export abstract class ReadyWatchingStore extends EventEmitter implements IDestroyable { protected matrixClient: MatrixClient; @@ -83,7 +84,7 @@ export abstract class ReadyWatchingStore extends EventEmitter implements IDestro this.matrixClient = payload.matrixClient; await this.onReady(); } - } else if (payload.action === 'on_client_not_viable' || payload.action === 'on_logged_out') { + } else if (payload.action === 'on_client_not_viable' || payload.action === Action.OnLoggedOut) { if (this.matrixClient) { await this.onNotReady(); this.matrixClient = null; diff --git a/src/stores/RoomViewStore.tsx b/src/stores/RoomViewStore.tsx index f9871c57528..d8259097a2a 100644 --- a/src/stores/RoomViewStore.tsx +++ b/src/stores/RoomViewStore.tsx @@ -241,7 +241,7 @@ export class RoomViewStore extends Store { break; } case 'on_client_not_viable': - case 'on_logged_out': + case Action.OnLoggedOut: this.reset(); break; case 'reply_to_event': diff --git a/src/stores/SetupEncryptionStore.ts b/src/stores/SetupEncryptionStore.ts index b5df05b2ff4..38ba0bd9b0a 100644 --- a/src/stores/SetupEncryptionStore.ts +++ b/src/stores/SetupEncryptionStore.ts @@ -89,9 +89,7 @@ export class SetupEncryptionStore extends EventEmitter { return; } this.started = false; - if (this.verificationRequest) { - this.verificationRequest.off(VerificationRequestEvent.Change, this.onVerificationRequestChange); - } + this.verificationRequest?.off(VerificationRequestEvent.Change, this.onVerificationRequestChange); if (MatrixClientPeg.get()) { MatrixClientPeg.get().removeListener(CryptoEvent.VerificationRequest, this.onVerificationRequest); MatrixClientPeg.get().removeListener(CryptoEvent.UserTrustStatusChanged, this.onUserTrustStatusChanged); @@ -99,6 +97,7 @@ export class SetupEncryptionStore extends EventEmitter { } public async fetchKeyInfo(): Promise { + if (!this.started) return; // bail if we were stopped const cli = MatrixClientPeg.get(); const keys = await cli.isSecretStored('m.cross_signing.master'); if (keys === null || Object.keys(keys).length === 0) { @@ -270,6 +269,7 @@ export class SetupEncryptionStore extends EventEmitter { } private async setActiveVerificationRequest(request: VerificationRequest): Promise { + if (!this.started) return; // bail if we were stopped if (request.otherUserId !== MatrixClientPeg.get().getUserId()) return; if (this.verificationRequest) { diff --git a/src/stores/VideoChannelStore.ts b/src/stores/VideoChannelStore.ts index 9ab521b50ff..8d7b32a94a6 100644 --- a/src/stores/VideoChannelStore.ts +++ b/src/stores/VideoChannelStore.ts @@ -136,14 +136,40 @@ export default class VideoChannelStore extends AsyncStoreWithClient { } } + // Now that we got the messaging, we need a way to ensure that it doesn't get stopped + const dontStopMessaging = new Promise((resolve, reject) => { + const listener = (uid: string) => { + if (uid === jitsiUid) { + cleanup(); + reject(new Error("Messaging stopped")); + } + }; + const done = () => { + cleanup(); + resolve(); + }; + const cleanup = () => { + messagingStore.off(WidgetMessagingStoreEvent.StopMessaging, listener); + this.off(VideoChannelEvent.Connect, done); + this.off(VideoChannelEvent.Disconnect, done); + }; + + messagingStore.on(WidgetMessagingStoreEvent.StopMessaging, listener); + this.on(VideoChannelEvent.Connect, done); + this.on(VideoChannelEvent.Disconnect, done); + }); + if (!messagingStore.isWidgetReady(jitsiUid)) { // Wait for the widget to be ready to receive our join event try { - await waitForEvent( - messagingStore, - WidgetMessagingStoreEvent.WidgetReady, - (uid: string) => uid === jitsiUid, - ); + await Promise.race([ + waitForEvent( + messagingStore, + WidgetMessagingStoreEvent.WidgetReady, + (uid: string) => uid === jitsiUid, + ), + dontStopMessaging, + ]); } catch (e) { throw new Error(`Video channel in room ${roomId} never became ready: ${e}`); } @@ -178,11 +204,12 @@ export default class VideoChannelStore extends AsyncStoreWithClient { videoDevice: videoDevice?.label, }); try { - await waitForJoin; + await Promise.race([waitForJoin, dontStopMessaging]); } catch (e) { // If it timed out, clean up our advance preparations this.activeChannel = null; this.roomId = null; + messaging.off(`action:${ElementWidgetActions.CallParticipants}`, this.onParticipants); messaging.off(`action:${ElementWidgetActions.MuteAudio}`, this.onMuteAudio); messaging.off(`action:${ElementWidgetActions.UnmuteAudio}`, this.onUnmuteAudio); @@ -190,6 +217,11 @@ export default class VideoChannelStore extends AsyncStoreWithClient { messaging.off(`action:${ElementWidgetActions.UnmuteVideo}`, this.onUnmuteVideo); messaging.off(`action:${ElementWidgetActions.HangupCall}`, this.onHangup); + if (messaging.transport.ready) { + // The messaging still exists, which means Jitsi might still be going in the background + messaging.transport.send(ElementWidgetActions.ForceHangupCall, {}); + } + this.emit(VideoChannelEvent.Disconnect, roomId); throw new Error(`Failed to join call in room ${roomId}: ${e}`); diff --git a/src/stores/notifications/RoomNotificationStateStore.ts b/src/stores/notifications/RoomNotificationStateStore.ts index 887e1a7332c..3409f657a8d 100644 --- a/src/stores/notifications/RoomNotificationStateStore.ts +++ b/src/stores/notifications/RoomNotificationStateStore.ts @@ -124,7 +124,8 @@ export class RoomNotificationStateStore extends AsyncStoreWithClient { if (this.globalState.symbol !== globalState.symbol || this.globalState.count !== globalState.count || this.globalState.color !== globalState.color || - this.globalState.numUnreadStates !== globalState.numUnreadStates + this.globalState.numUnreadStates !== globalState.numUnreadStates || + state !== prevState ) { this._globalState = globalState; this.emit(UPDATE_STATUS_INDICATOR, globalState, state, prevState, data); diff --git a/src/stores/spaces/SpaceStore.ts b/src/stores/spaces/SpaceStore.ts index 0d9d55e8897..0bdde6a2c30 100644 --- a/src/stores/spaces/SpaceStore.ts +++ b/src/stores/spaces/SpaceStore.ts @@ -1081,6 +1081,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { ?.["m.room_versions"]?.["org.matrix.msc3244.room_capabilities"]?.["restricted"]; }); + const oldMetaSpaces = this._enabledMetaSpaces; const enabledMetaSpaces = SettingsStore.getValue("Spaces.enabledMetaSpaces"); this._enabledMetaSpaces = metaSpaceOrder.filter(k => enabledMetaSpaces[k]); @@ -1090,6 +1091,11 @@ export class SpaceStoreClass extends AsyncStoreWithClient { this.sendUserProperties(); this.rebuildSpaceHierarchy(); // trigger an initial update + // rebuildSpaceHierarchy will only send an update if the spaces have changed. + // If only the meta spaces have changed, we need to send an update ourselves. + if (arrayHasDiff(oldMetaSpaces, this._enabledMetaSpaces)) { + this.emit(UPDATE_TOP_LEVEL_SPACES, this.spacePanelSpaces, this.enabledMetaSpaces); + } // restore selected state from last session if any and still valid const lastSpaceId = window.localStorage.getItem(ACTIVE_SPACE_LS_KEY); diff --git a/src/stores/widgets/ElementWidgetActions.ts b/src/stores/widgets/ElementWidgetActions.ts index 117c4b47f3a..7ad41885be9 100644 --- a/src/stores/widgets/ElementWidgetActions.ts +++ b/src/stores/widgets/ElementWidgetActions.ts @@ -21,6 +21,7 @@ export enum ElementWidgetActions { WidgetReady = "io.element.widget_ready", JoinCall = "io.element.join", HangupCall = "im.vector.hangup", + ForceHangupCall = "io.element.force_hangup", CallParticipants = "io.element.participants", MuteAudio = "io.element.mute_audio", UnmuteAudio = "io.element.unmute_audio", @@ -35,6 +36,12 @@ export enum ElementWidgetActions { ViewRoom = "io.element.view_room", } +export interface IHangupCallApiRequest extends IWidgetApiRequest { + data: { + errorMessage?: string; + }; +} + /** * @deprecated Use MSC2931 instead */ diff --git a/src/stores/widgets/StopGapWidget.ts b/src/stores/widgets/StopGapWidget.ts index 5f95e441093..1cc0ebc74bf 100644 --- a/src/stores/widgets/StopGapWidget.ts +++ b/src/stores/widgets/StopGapWidget.ts @@ -37,6 +37,7 @@ import { MatrixEvent, MatrixEventEvent } from "matrix-js-sdk/src/models/event"; import { logger } from "matrix-js-sdk/src/logger"; import { ClientEvent } from "matrix-js-sdk/src/client"; +import { _t } from "../../languageHandler"; import { StopGapWidgetDriver } from "./StopGapWidgetDriver"; import { WidgetMessagingStore } from "./WidgetMessagingStore"; import { RoomViewStore } from "../RoomViewStore"; @@ -50,7 +51,7 @@ import ActiveWidgetStore from "../ActiveWidgetStore"; import { objectShallowClone } from "../../utils/objects"; import defaultDispatcher from "../../dispatcher/dispatcher"; import { Action } from "../../dispatcher/actions"; -import { ElementWidgetActions, IViewRoomApiRequest } from "./ElementWidgetActions"; +import { ElementWidgetActions, IHangupCallApiRequest, IViewRoomApiRequest } from "./ElementWidgetActions"; import { ModalWidgetStore } from "../ModalWidgetStore"; import ThemeWatcher from "../../settings/watchers/ThemeWatcher"; import { ElementWidgetCapabilities } from "./ElementWidgetCapabilities"; @@ -59,6 +60,8 @@ import { getUserLanguage } from "../../languageHandler"; import { WidgetVariableCustomisations } from "../../customisations/WidgetVariables"; import { arrayFastClone } from "../../utils/arrays"; import { ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload"; +import Modal from "../../Modal"; +import ErrorDialog from "../../components/views/dialogs/ErrorDialog"; // TODO: Destroy all of this code @@ -353,6 +356,21 @@ export class StopGapWidget extends EventEmitter { }, ); } + + if (WidgetType.JITSI.matches(this.mockWidget.type)) { + this.messaging.on(`action:${ElementWidgetActions.HangupCall}`, + (ev: CustomEvent) => { + if (ev.detail.data?.errorMessage) { + Modal.createTrackedDialog("Connection lost", "", ErrorDialog, { + title: _t("Connection lost"), + description: _t("You were disconnected from the call. (Error: %(message)s)", { + message: ev.detail.data.errorMessage, + }), + }); + } + }, + ); + } } public async prepare(): Promise { diff --git a/src/stores/widgets/WidgetMessagingStore.ts b/src/stores/widgets/WidgetMessagingStore.ts index bcc46c0e43a..686f222a1f7 100644 --- a/src/stores/widgets/WidgetMessagingStore.ts +++ b/src/stores/widgets/WidgetMessagingStore.ts @@ -25,6 +25,7 @@ import WidgetUtils from "../../utils/WidgetUtils"; export enum WidgetMessagingStoreEvent { StoreMessaging = "store_messaging", + StopMessaging = "stop_messaging", WidgetReady = "widget_ready", } @@ -71,9 +72,7 @@ export class WidgetMessagingStore extends AsyncStoreWithClient { } public stopMessaging(widget: Widget, roomId: string) { - const uid = WidgetUtils.calcWidgetUid(widget.id, roomId); - this.widgetMap.remove(uid)?.stop(); - this.readyWidgets.delete(uid); + this.stopMessagingByUid(WidgetUtils.calcWidgetUid(widget.id, roomId)); } public getMessaging(widget: Widget, roomId: string): ClientWidgetApi { @@ -86,6 +85,8 @@ export class WidgetMessagingStore extends AsyncStoreWithClient { */ public stopMessagingByUid(widgetUid: string) { this.widgetMap.remove(widgetUid)?.stop(); + this.readyWidgets.delete(widgetUid); + this.emit(WidgetMessagingStoreEvent.StopMessaging, widgetUid); } /** diff --git a/src/utils/WellKnownUtils.ts b/src/utils/WellKnownUtils.ts index e41adabb903..451f956f16f 100644 --- a/src/utils/WellKnownUtils.ts +++ b/src/utils/WellKnownUtils.ts @@ -24,6 +24,7 @@ const E2EE_WK_KEY = "io.element.e2ee"; const E2EE_WK_KEY_DEPRECATED = "im.vector.riot.e2ee"; export const TILE_SERVER_WK_KEY = new UnstableValue( "m.tile_server", "org.matrix.msc3488.tile_server"); +const EMBEDDED_PAGES_WK_PROPERTY = "io.element.embedded_pages"; /* eslint-disable camelcase */ export interface ICallBehaviourWellKnown { @@ -39,6 +40,10 @@ export interface IE2EEWellKnown { export interface ITileServerWellKnown { map_style_url?: string; } + +export interface IEmbeddedPagesWellKnown { + home_url?: string; +} /* eslint-enable camelcase */ export function getCallBehaviourWellKnown(): ICallBehaviourWellKnown { @@ -70,6 +75,16 @@ export function tileServerFromWellKnown( ); } +export function getEmbeddedPagesWellKnown(): IEmbeddedPagesWellKnown | undefined { + return embeddedPagesFromWellKnown(MatrixClientPeg.get()?.getClientWellKnown()); +} + +export function embeddedPagesFromWellKnown( + clientWellKnown?: IClientWellKnown, +): IEmbeddedPagesWellKnown { + return (clientWellKnown?.[EMBEDDED_PAGES_WK_PROPERTY]); +} + export function isSecureBackupRequired(): boolean { const wellKnown = getE2EEWellKnown(); return wellKnown && wellKnown["secure_backup_required"] === true; diff --git a/src/utils/WidgetUtils.ts b/src/utils/WidgetUtils.ts index b2f33b22253..8eebed3871c 100644 --- a/src/utils/WidgetUtils.ts +++ b/src/utils/WidgetUtils.ts @@ -286,6 +286,7 @@ export default class WidgetUtils { widgetUrl?: string, widgetName?: string, widgetData?: object, + widgetAvatarUrl?: string, ) { let content; @@ -299,6 +300,7 @@ export default class WidgetUtils { url: widgetUrl, name: widgetName, data: widgetData, + avatar_url: widgetAvatarUrl, }; } else { content = {}; diff --git a/src/utils/location/map.ts b/src/utils/location/map.ts index b3b74d14495..5c5ab34e18c 100644 --- a/src/utils/location/map.ts +++ b/src/utils/location/map.ts @@ -36,7 +36,9 @@ export const createMap = ( style: styleUrl, zoom: 15, interactive, + attributionControl: false, }); + map.addControl(new maplibregl.AttributionControl(), 'top-right'); map.on('error', (e) => { logger.error( @@ -63,7 +65,7 @@ export const createMarker = (coords: GeolocationCoordinates, element: HTMLElemen return marker; }; -const makeLink = (coords: GeolocationCoordinates): string => { +export const makeMapSiteLink = (coords: GeolocationCoordinates): string => { return ( "https://www.openstreetmap.org/" + `?mlat=${coords.latitude}` + @@ -72,18 +74,18 @@ const makeLink = (coords: GeolocationCoordinates): string => { ); }; -export const createMapSiteLink = (event: MatrixEvent): string => { +export const createMapSiteLinkFromEvent = (event: MatrixEvent): string => { const content: Object = event.getContent(); const mLocation = content[M_LOCATION.name]; if (mLocation !== undefined) { const uri = mLocation["uri"]; if (uri !== undefined) { - return makeLink(parseGeoUri(uri)); + return makeMapSiteLink(parseGeoUri(uri)); } } else { const geoUri = content["geo_uri"]; if (geoUri) { - return makeLink(parseGeoUri(geoUri)); + return makeMapSiteLink(parseGeoUri(geoUri)); } } return null; diff --git a/src/utils/pages.ts b/src/utils/pages.ts index 03bab1563b4..75e4fef9bf6 100644 --- a/src/utils/pages.ts +++ b/src/utils/pages.ts @@ -17,6 +17,7 @@ limitations under the License. import { logger } from "matrix-js-sdk/src/logger"; import { IConfigOptions } from "../IConfigOptions"; +import { getEmbeddedPagesWellKnown } from '../utils/WellKnownUtils'; import { SnakedObject } from "./SnakedObject"; export function getHomePageUrl(appConfig: IConfigOptions): string | null { @@ -38,6 +39,10 @@ export function getHomePageUrl(appConfig: IConfigOptions): string | null { } } + if (!pageUrl) { + pageUrl = getEmbeddedPagesWellKnown()?.home_url; + } + return pageUrl; } diff --git a/test/CallHandler-test.ts b/test/CallHandler-test.ts index 01c1085032e..6a4f29aaa17 100644 --- a/test/CallHandler-test.ts +++ b/test/CallHandler-test.ts @@ -21,12 +21,10 @@ import EventEmitter from 'events'; import CallHandler, { CallHandlerEvent, PROTOCOL_PSTN, PROTOCOL_PSTN_PREFIXED, PROTOCOL_SIP_NATIVE, PROTOCOL_SIP_VIRTUAL, } from '../src/CallHandler'; -import { stubClient, mkStubRoom } from './test-utils'; +import { stubClient, mkStubRoom, untilDispatch } from './test-utils'; import { MatrixClientPeg } from '../src/MatrixClientPeg'; -import dis from '../src/dispatcher/dispatcher'; import DMRoomMap from '../src/utils/DMRoomMap'; import SdkConfig from '../src/SdkConfig'; -import { ActionPayload } from '../src/dispatcher/payloads'; import { Action } from "../src/dispatcher/actions"; // The Matrix IDs that the user sees when talking to Alice & Bob @@ -95,18 +93,6 @@ class FakeCall extends EventEmitter { } } -function untilDispatch(waitForAction: string): Promise { - let dispatchHandle; - return new Promise(resolve => { - dispatchHandle = dis.register(payload => { - if (payload.action === waitForAction) { - dis.unregister(dispatchHandle); - resolve(payload); - } - }); - }); -} - function untilCallHandlerEvent(callHandler: CallHandler, event: CallHandlerEvent): Promise { return new Promise((resolve) => { callHandler.addListener(event, () => { diff --git a/test/components/views/beacon/BeaconViewDialog-test.tsx b/test/components/views/beacon/BeaconViewDialog-test.tsx index 8d0fb30e307..12b40939392 100644 --- a/test/components/views/beacon/BeaconViewDialog-test.tsx +++ b/test/components/views/beacon/BeaconViewDialog-test.tsx @@ -89,6 +89,10 @@ describe('', () => { const getComponent = (props = {}) => mount(); + beforeAll(() => { + maplibregl.AttributionControl = jest.fn(); + }); + beforeEach(() => { jest.spyOn(OwnBeaconStore.instance, 'getLiveBeaconIds').mockRestore(); diff --git a/test/components/views/beacon/ShareLatestLocation-test.tsx b/test/components/views/beacon/ShareLatestLocation-test.tsx new file mode 100644 index 00000000000..28d36bc9772 --- /dev/null +++ b/test/components/views/beacon/ShareLatestLocation-test.tsx @@ -0,0 +1,59 @@ +/* +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 { mount } from 'enzyme'; +import { act } from 'react-dom/test-utils'; + +import ShareLatestLocation from '../../../../src/components/views/beacon/ShareLatestLocation'; +import { copyPlaintext } from '../../../../src/utils/strings'; +import { flushPromises } from '../../../test-utils'; + +jest.mock('../../../../src/utils/strings', () => ({ + copyPlaintext: jest.fn().mockResolvedValue(undefined), +})); + +describe('', () => { + const defaultProps = { + latestLocationState: { + uri: 'geo:51,42;u=35', + timestamp: 123, + }, + }; + const getComponent = (props = {}) => + mount(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders null when no location', () => { + const component = getComponent({ latestLocationState: undefined }); + expect(component.html()).toBeNull(); + }); + + it('renders share buttons when there is a location', async () => { + const component = getComponent(); + expect(component).toMatchSnapshot(); + + await act(async () => { + component.find('.mx_CopyableText_copyButton').at(0).simulate('click'); + await flushPromises(); + }); + + expect(copyPlaintext).toHaveBeenCalledWith('51,42'); + }); +}); diff --git a/test/components/views/beacon/__snapshots__/BeaconListItem-test.tsx.snap b/test/components/views/beacon/__snapshots__/BeaconListItem-test.tsx.snap index 1518a60dba9..221d534c029 100644 --- a/test/components/views/beacon/__snapshots__/BeaconListItem-test.tsx.snap +++ b/test/components/views/beacon/__snapshots__/BeaconListItem-test.tsx.snap @@ -1,3 +1,3 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[` when a beacon is live and has locations renders beacon info 1`] = `"
  • Alice's carLive until 16:04
    Updated a few seconds ago
  • "`; +exports[` when a beacon is live and has locations renders beacon info 1`] = `"
  • Alice's carLive until 16:04
    Updated a few seconds ago
  • "`; diff --git a/test/components/views/beacon/__snapshots__/BeaconViewDialog-test.tsx.snap b/test/components/views/beacon/__snapshots__/BeaconViewDialog-test.tsx.snap index 59e47767817..648ed3e93fa 100644 --- a/test/components/views/beacon/__snapshots__/BeaconViewDialog-test.tsx.snap +++ b/test/components/views/beacon/__snapshots__/BeaconViewDialog-test.tsx.snap @@ -1,37 +1,83 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[` renders a fallback when no live beacons remain 1`] = ` -
    -
    - - No live locations - - +
    +
    + + No live locations + + +
    + Close +
    +
    +
    + , +
    +
    + + No live locations + + - Close -
    - -
    +
    + Close +
    + +
    , +] `; diff --git a/test/components/views/beacon/__snapshots__/ShareLatestLocation-test.tsx.snap b/test/components/views/beacon/__snapshots__/ShareLatestLocation-test.tsx.snap new file mode 100644 index 00000000000..5f55d3103d0 --- /dev/null +++ b/test/components/views/beacon/__snapshots__/ShareLatestLocation-test.tsx.snap @@ -0,0 +1,79 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` renders share buttons when there is a location 1`] = ` + + +
    + +
    + +
    + + +
    + + +
    + + +
    + + +`; diff --git a/test/components/views/elements/AccessibleButton-test.tsx b/test/components/views/elements/AccessibleButton-test.tsx new file mode 100644 index 00000000000..89385dd0577 --- /dev/null +++ b/test/components/views/elements/AccessibleButton-test.tsx @@ -0,0 +1,204 @@ +/* +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 { mount } from 'enzyme'; +import { act } from 'react-dom/test-utils'; + +import AccessibleButton from '../../../../src/components/views/elements/AccessibleButton'; +import { Key } from '../../../../src/Keyboard'; +import { mockPlatformPeg, unmockPlatformPeg } from '../../../test-utils'; + +describe('', () => { + const defaultProps = { + onClick: jest.fn(), + children: 'i am a button', + }; + const getComponent = (props = {}) => + mount(); + + beforeEach(() => { + mockPlatformPeg(); + }); + + afterAll(() => { + unmockPlatformPeg(); + }); + + const makeKeyboardEvent = (key: string) => ({ + key, + stopPropagation: jest.fn(), + preventDefault: jest.fn(), + }) as unknown as KeyboardEvent; + + it('renders div with role button by default', () => { + const component = getComponent(); + expect(component).toMatchSnapshot(); + }); + + it('renders a button element', () => { + const component = getComponent({ element: 'button' }); + expect(component).toMatchSnapshot(); + }); + + it('renders with correct classes when button has kind', () => { + const component = getComponent({ + kind: 'primary', + }); + expect(component).toMatchSnapshot(); + }); + + it('disables button correctly', () => { + const onClick = jest.fn(); + const component = getComponent({ + onClick, + disabled: true, + }); + expect(component.find('.mx_AccessibleButton').props().disabled).toBeTruthy(); + expect(component.find('.mx_AccessibleButton').props()['aria-disabled']).toBeTruthy(); + + act(() => { + component.simulate('click'); + }); + + expect(onClick).not.toHaveBeenCalled(); + + act(() => { + const keydownEvent = makeKeyboardEvent(Key.ENTER); + component.simulate('keydown', keydownEvent); + }); + + expect(onClick).not.toHaveBeenCalled(); + }); + + it('calls onClick handler on button click', () => { + const onClick = jest.fn(); + const component = getComponent({ + onClick, + }); + + act(() => { + component.simulate('click'); + }); + + expect(onClick).toHaveBeenCalled(); + }); + + it('calls onClick handler on button mousedown when triggerOnMousedown is passed', () => { + const onClick = jest.fn(); + const component = getComponent({ + onClick, + triggerOnMouseDown: true, + }); + + act(() => { + component.simulate('mousedown'); + }); + + expect(onClick).toHaveBeenCalled(); + }); + + describe('handling keyboard events', () => { + it('calls onClick handler on enter keydown', () => { + const onClick = jest.fn(); + const component = getComponent({ + onClick, + }); + + const keyboardEvent = makeKeyboardEvent(Key.ENTER); + act(() => { + component.simulate('keydown', keyboardEvent); + }); + + expect(onClick).toHaveBeenCalled(); + + act(() => { + component.simulate('keyup', keyboardEvent); + }); + + // handler only called once on keydown + expect(onClick).toHaveBeenCalledTimes(1); + // called for both keyup and keydown + expect(keyboardEvent.stopPropagation).toHaveBeenCalledTimes(2); + expect(keyboardEvent.preventDefault).toHaveBeenCalledTimes(2); + }); + + it('calls onClick handler on space keyup', () => { + const onClick = jest.fn(); + const component = getComponent({ + onClick, + }); + + const keyboardEvent = makeKeyboardEvent(Key.SPACE); + act(() => { + component.simulate('keydown', keyboardEvent); + }); + + expect(onClick).not.toHaveBeenCalled(); + + act(() => { + component.simulate('keyup', keyboardEvent); + }); + + // handler only called once on keyup + expect(onClick).toHaveBeenCalledTimes(1); + // called for both keyup and keydown + expect(keyboardEvent.stopPropagation).toHaveBeenCalledTimes(2); + expect(keyboardEvent.preventDefault).toHaveBeenCalledTimes(2); + }); + + it('calls onKeydown/onKeyUp handlers for keys other than space and enter', () => { + const onClick = jest.fn(); + const onKeyDown = jest.fn(); + const onKeyUp = jest.fn(); + const component = getComponent({ + onClick, + onKeyDown, + onKeyUp, + }); + + const keyboardEvent = makeKeyboardEvent(Key.K); + act(() => { + component.simulate('keydown', keyboardEvent); + component.simulate('keyup', keyboardEvent); + }); + + expect(onClick).not.toHaveBeenCalled(); + expect(onKeyDown).toHaveBeenCalled(); + expect(onKeyUp).toHaveBeenCalled(); + expect(keyboardEvent.stopPropagation).not.toHaveBeenCalled(); + expect(keyboardEvent.preventDefault).not.toHaveBeenCalled(); + }); + + it('does nothing on non space/enter key presses when no onKeydown/onKeyUp handlers provided', () => { + const onClick = jest.fn(); + const component = getComponent({ + onClick, + }); + + const keyboardEvent = makeKeyboardEvent(Key.K); + act(() => { + component.simulate('keydown', keyboardEvent); + component.simulate('keyup', keyboardEvent); + }); + + // no onClick call, no problems + expect(onClick).not.toHaveBeenCalled(); + expect(keyboardEvent.stopPropagation).not.toHaveBeenCalled(); + expect(keyboardEvent.preventDefault).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/test/components/views/elements/LabelledCheckbox-test.tsx b/test/components/views/elements/LabelledCheckbox-test.tsx new file mode 100644 index 00000000000..b03ee0c1f18 --- /dev/null +++ b/test/components/views/elements/LabelledCheckbox-test.tsx @@ -0,0 +1,124 @@ +/* +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 { mount } from 'enzyme'; +import { act } from "react-dom/test-utils"; + +import LabelledCheckbox from "../../../../src/components/views/elements/LabelledCheckbox"; + +// Fake random strings to give a predictable snapshot for checkbox IDs +jest.mock( + 'matrix-js-sdk/src/randomstring', + () => { + return { + randomString: () => "abdefghi", + }; + }, +); + +describe('', () => { + type CompProps = React.ComponentProps; + const getComponent = (props: CompProps) => mount(); + type CompClass = ReturnType; + + const getCheckbox = (component: CompClass) => component.find(`input[type="checkbox"]`); + const getLabel = (component: CompClass) => component.find(`.mx_LabelledCheckbox_label`); + const getByline = (component: CompClass) => component.find(`.mx_LabelledCheckbox_byline`); + + const isChecked = (checkbox: ReturnType) => checkbox.is(`[checked=true]`); + const isDisabled = (checkbox: ReturnType) => checkbox.is(`[disabled=true]`); + const getText = (span: ReturnType) => span.length > 0 ? span.at(0).text() : null; + + test.each([null, "this is a byline"])( + "should render with byline of %p", + (byline) => { + const props: CompProps = { + label: "Hello world", + value: true, + byline: byline, + onChange: jest.fn(), + }; + const component = getComponent(props); + const checkbox = getCheckbox(component); + + expect(component).toMatchSnapshot(); + expect(isChecked(checkbox)).toBe(true); + expect(isDisabled(checkbox)).toBe(false); + expect(getText(getLabel(component))).toBe(props.label); + expect(getText(getByline(component))).toBe(byline); + }, + ); + + it('should support unchecked by default', () => { + const props: CompProps = { + label: "Hello world", + value: false, + onChange: jest.fn(), + }; + const component = getComponent(props); + + expect(isChecked(getCheckbox(component))).toBe(false); + }); + + it('should be possible to disable the checkbox', () => { + const props: CompProps = { + label: "Hello world", + value: false, + disabled: true, + onChange: jest.fn(), + }; + const component = getComponent(props); + + expect(isDisabled(getCheckbox(component))).toBe(true); + }); + + it('should emit onChange calls', () => { + const props: CompProps = { + label: "Hello world", + value: false, + onChange: jest.fn(), + }; + const component = getComponent(props); + + expect(props.onChange).not.toHaveBeenCalled(); + + act(() => { + getCheckbox(component).simulate('change'); + }); + + expect(props.onChange).toHaveBeenCalledTimes(1); + }); + + it('should react to value and disabled prop changes', () => { + const props: CompProps = { + label: "Hello world", + value: false, + onChange: jest.fn(), + }; + const component = getComponent(props); + let checkbox = getCheckbox(component); + + expect(isChecked(checkbox)).toBe(false); + expect(isDisabled(checkbox)).toBe(false); + + component.setProps({ value: true, disabled: true }); + checkbox = getCheckbox(component); // refresh reference to checkbox + + expect(isChecked(checkbox)).toBe(true); + expect(isDisabled(checkbox)).toBe(true); + }); +}); diff --git a/test/components/views/elements/__snapshots__/AccessibleButton-test.tsx.snap b/test/components/views/elements/__snapshots__/AccessibleButton-test.tsx.snap new file mode 100644 index 00000000000..0d049b34f7f --- /dev/null +++ b/test/components/views/elements/__snapshots__/AccessibleButton-test.tsx.snap @@ -0,0 +1,62 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` renders a button element 1`] = ` + + + +`; + +exports[` renders div with role button by default 1`] = ` + +
    + i am a button +
    +
    +`; + +exports[` renders with correct classes when button has kind 1`] = ` + +
    + i am a button +
    +
    +`; diff --git a/test/components/views/elements/__snapshots__/LabelledCheckbox-test.tsx.snap b/test/components/views/elements/__snapshots__/LabelledCheckbox-test.tsx.snap new file mode 100644 index 00000000000..34cdbe59be9 --- /dev/null +++ b/test/components/views/elements/__snapshots__/LabelledCheckbox-test.tsx.snap @@ -0,0 +1,108 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` should render with byline of "this is a byline" 1`] = ` + +