Compare commits

...

38 Commits

Author SHA1 Message Date
RiotRobot
87d40ab0e0 v1.11.112 2025-09-16 11:52:32 +00:00
Michael Telatynski
8e9a43d70c Merge commit from fork
* Validate room upgrade relationships properly

Ensures only correctly related rooms are left when leaving the latest version of a room.
Ensures the room list does not wrongly hide rooms which have not yet been upgraded.
Ensures the breadcrumbs store finds the correct latest version of a room for a given stored room.

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Tests

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2025-09-16 12:43:49 +01:00
RiotRobot
9a11a80483 Upgrade dependency to matrix-js-sdk@38.2.0 2025-09-16 11:41:51 +00:00
ElementRobot
8d07e797c5 Hold Electron toasts until after the client starts (#30768) (#30769)
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2025-09-16 12:05:08 +01:00
ElementRobot
2e8e6e92cc Add mechanism for Electron to render toasts (#30765) (#30767)
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2025-09-16 09:41:11 +01:00
RiotRobot
c7c0e91fdc v1.11.111 2025-09-10 09:24:49 +00:00
RiotRobot
fc06cf1276 Upgrade dependency to matrix-js-sdk@38.1.0 2025-09-09 16:32:22 +00:00
ElementRobot
4f702b70aa Ensure container starts if it is mounted with an empty /modules directory. (#30699) (#30705)
* Set nullglob

* Replace with a if statement (because we're using sh)

* combine if

(cherry picked from commit 1c30bec083)

Co-authored-by: Will Hunt <will@half-shot.uk>
2025-09-05 09:10:59 +00:00
RiotRobot
9f15532d12 v1.11.111-rc.0 2025-09-02 13:01:52 +00:00
RiotRobot
71cf19f4b2 Upgrade dependency to matrix-js-sdk@38.1.0-rc.0 2025-09-02 12:55:51 +00:00
Will Hunt
1925132a3c Do not hide media from your own user by default (#29797)
* Always show media from your own user

* Update usages of useMediaVisible

* lint

* Add a test for HideActionButton

* Improve docs

* Document the event

* fixup test

* Allow users to hide their own media if they wish.

* Update tests

* remove a check\

* tweak

* tweak
2025-09-02 12:21:12 +00:00
Michael Telatynski
8fa3d7e4b7 Fix room joining over federation not specifying vias or using aliases (#30641)
* Fix room joining over federation not specifying vias or using aliases

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Be consistent with viaServers

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Simplify modules

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Only consider canAskToJoin on 403 as per spec

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Remove unused helper which I only just added =(

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Update tests

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Add tests

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Add test

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Add tests

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2025-09-02 11:10:10 +00:00
David Baker
1b4a979b6c Fix stable-suffixed MSC4133 support (#30649)
It looked for the ".stable" suffixed feature to work out what URL
to use but not to see whether the server supported it.
2025-09-02 08:41:49 +00:00
R Midhun Suresh
d287ac07a3 Add links to relevant docs in template (#30656) 2025-09-01 16:51:09 +00:00
Bojidar Marinov
8903927e0c Remember whether sidebar is shown for calls when switching rooms (#30262)
* Remember whether sidebar is shown for calls when switching rooms

Stores the sidebar state per-room in LegacyCallHandler, along with other details about calls.

* Hide the Show/Hide Sidebar from the Picture-in-Picture preview

The toggle sidebar button currently does nothing in PIP mode, since PIP mode never shows a sidebar (even when the call is made fullscreen from the PIP preview)

* Add test for Show/Hide Sidebar feature

* Add more tests for LegacyCallView and LegacyCallViewForRoom

Also, fix issue where LegacyCallViewForRoom used roomId and not callId for checking for sidebar state
2025-09-01 14:33:33 +00:00
Will Hunt
4d48d1b2f2 Open the proper integration settings on integrations disabled error (#30538)
* Open the proper integration settings on integrations disabled error.

* Convert to functional component.

* Add test

* update snap
2025-09-01 07:30:49 +00:00
David Baker
f75d41054f Fix i18n of message when a setting is disabled (#30646)
The function was supposed to return an i18ned string but lacked a _t
2025-08-29 09:18:37 +00:00
Richard van der Hoff
701019052c MatrixChat: only start session load once (#30642)
* MatrixChat: only start session load once

When running in development mode, `MatrixChat.componentDidMount` is run twice,
meaning it checks the session lock twice. Sometimes, this means it shows the
"Element is running in another window" page.

The real problem is of course that we're running all this application logic
inside the `MatrixChat` component, but as a quick workaround, we can just
remember that we've started the session load code, and bail out on the second pass.

* Address review comments
2025-08-28 16:09:54 +00:00
renovate[bot]
cf692e751b Update vector-im (compound-web to 8.2.1, design tokens is already at 6.0.0 on develop) (#30373)
* Update vector-im

* Update snapshots

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Update tests

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Make BaseDialog's div keyboard focusable and fix test.

* Update more e2e tests to use switch instead of checkbox

* fix useParticipants incorrectly returning an array when a map is expected

* Try again to fix calParticipants

* try fix RoomHeader tests again by also mocking useParticipants

* Revert "try fix RoomHeader tests again by also mocking useParticipants"

This reverts commit f83093cc44.

* Try with no dependencies

* try mocking useCall rather than just useParticipantCount

* Mock the call store rather than the hook

* Only mock the call object for tests that expect it.

* Revert "Only mock the call object for tests that expect it."

This reverts commit 043d812b1d.

* Revert "Mock the call store rather than the hook"

This reverts commit 644be3155c.

* Revert "try mocking useCall rather than just useParticipantCount"

This reverts commit 92034aaff9.

* Revert "Try with no dependencies"

This reverts commit fb502a68a0.

* Reapply "try fix RoomHeader tests again by also mocking useParticipants"

This reverts commit e456782efd.

* Revert "try fix RoomHeader tests again by also mocking useParticipants"

This reverts commit f83093cc44.

* Revert "Try again to fix calParticipants"

This reverts commit c45ad3063f.

* Revert "fix useParticipants incorrectly returning an array when a map is expected"

This reverts commit e06d76e3f7.

* bump compound-web

* Update snapshots

* Fix bad merge, we don't need the second call to escape. The comment a couple of lines up explains things.

* Trigger build to fix licence/cla check

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
Co-authored-by: David Langley <davidl@element.io>
2025-08-28 12:24:08 +00:00
David Langley
1a005ad5d2 Mock CallStore.getCall rather than individual hooks like useParticipantCount (#30636) 2025-08-28 08:43:00 +00:00
ElementRobot
42f7bc1d0d [create-pull-request] automated change (#30637)
Co-authored-by: t3chguy <2403652+t3chguy@users.noreply.github.com>
2025-08-28 06:18:02 +00:00
David Langley
b7f89db43c ListView should not handle the arrow keys if there is a modifier applied (#30633)
* ListView should not handle the arrow keys if there is a modifier applied.

* lint

* Reduce nesting
2025-08-27 15:48:33 +00:00
RiotRobot
98a04e1812 Reset matrix-js-sdk back to develop branch 2025-08-27 13:49:38 +00:00
RiotRobot
42d726a4ff Merge branch 'master' into develop 2025-08-27 13:49:23 +00:00
RiotRobot
b6f5843028 v1.11.110 2025-08-27 13:45:54 +00:00
RiotRobot
81d054bb99 Upgrade dependency to matrix-js-sdk@38.0.0 2025-08-27 13:34:31 +00:00
David Langley
a1f56ebbf2 Deflake test (#30634) 2025-08-27 13:17:12 +00:00
Richard van der Hoff
a003ebcb35 Fix yarn lint:types when matrix-js-sdk is not yarn linked. (#30612)
* Add missing dependencies on `@types` packages

Because we import the typescript source from matrix-js-sdk rather than the
`.d.ts` files, `tsc` ends up type-checking the js-sdk source. That means that
we need to have the `@types` packages that js-sdk needs.

* Add missing type definitions for `setInterval` and `setTimeout`

Our source assumes that `setTimeout` returns a number, not a
`Timeout`. If we `yarn link` js-sdk, then (somehow) we end up using the
definitions from there, but it's not really correct.

* Configure knip to ignore new deps
2025-08-27 13:13:00 +00:00
David Langley
87b4918d34 Make BaseDialog's div keyboard focusable and fix test. (#30631)
* Make BaseDialog's div keyboard focusable and fix test.

* Less weird test

* Update snapshots

* More snapshots
2025-08-27 12:41:39 +00:00
ElementRobot
c6f47cfd8e [create-pull-request] automated change (#30629)
Co-authored-by: t3chguy <2403652+t3chguy@users.noreply.github.com>
2025-08-27 10:36:39 +00:00
AlirezaMrtz
a112dfe1db Fix: Allow triple-click text selection to flow around pills (#30349)
* Fix: Allow triple-click text selection to flow around keyword pills

* Fix: Remove unnecessary align-items property from Pill component

* Change display property of .mx_Pill from inline to inline-block to fix rendering issue in Playwright tests

* Add test for triple-click message selection with pills
2025-08-27 08:06:01 +00:00
Robin
4b4cb896eb Watch for a 'join' action to know when the call is connected (#29492)
Previously we were watching for changes to the room state to know when you become connected to a call. However, the room state might not change if you had a stuck membership event prior to re-joining the call. It's going to be more reliable to watch for the 'join' action that Element Call sends, and use that to track the connection state.
2025-08-27 09:04:36 +01:00
ElementRobot
6a1c0502aa [create-pull-request] automated change (#30628)
Co-authored-by: t3chguy <2403652+t3chguy@users.noreply.github.com>
2025-08-27 06:24:04 +00:00
Florian Duros
ea5e525133 Fix: add missing tooltip and aria-label to lock icon next to composer (#30623)
* fix: add missing tooltip and aria-label to lock icon next to composer

* test: update snapshot
2025-08-26 15:29:41 +00:00
David Langley
14d16364db Don't render context menu when scrolling (#30613)
* Don't render context menu when scrolling

* Add test to check context menu is not rendered when scrolling

* Add comment.
2025-08-26 11:12:34 +00:00
RiotRobot
aab1fae299 Upgrade dependency to matrix-js-sdk@38.0.0-rc.1 2025-08-21 14:18:45 +00:00
RiotRobot
f5d6f8f639 v1.11.110-rc.0 2025-08-19 13:20:54 +00:00
RiotRobot
cc20136170 Upgrade dependency to matrix-js-sdk@38.0.0-rc.0 2025-08-19 13:10:27 +00:00
122 changed files with 1975 additions and 1295 deletions

View File

@@ -2,6 +2,7 @@
## Checklist
- [ ] I have read through [review guidelines](../docs/review.md) and [CONTRIBUTING.md](../CONTRIBUTING.md).
- [ ] Tests written for new code (and old code if feasible).
- [ ] New or updated `public`/`exported` symbols have accurate [TSDoc](https://tsdoc.org/) documentation.
- [ ] Linter and other CI checks pass.

View File

@@ -1,3 +1,57 @@
Changes in [1.11.112](https://github.com/element-hq/element-web/releases/tag/v1.11.112) (2025-09-16)
====================================================================================================
Fix [CVE-2025-59161](https://www.cve.org/CVERecord?id=CVE-2025-59161) / [GHSA-m6c8-98f4-75rr](https://github.com/element-hq/element-web/security/advisories/GHSA-m6c8-98f4-75rr)
Changes in [1.11.111](https://github.com/element-hq/element-web/releases/tag/v1.11.111) (2025-09-10)
====================================================================================================
## ✨ Features
* Do not hide media from your own user by default ([#29797](https://github.com/element-hq/element-web/pull/29797)). Contributed by @Half-Shot.
* Remember whether sidebar is shown for calls when switching rooms ([#30262](https://github.com/element-hq/element-web/pull/30262)). Contributed by @bojidar-bg.
* Open the proper integration settings on integrations disabled error ([#30538](https://github.com/element-hq/element-web/pull/30538)). Contributed by @Half-Shot.
* Show a "progress" dialog while invites are being sent ([#30561](https://github.com/element-hq/element-web/pull/30561)). Contributed by @richvdh.
* Move the room list to the new ListView(backed by react-virtuoso) ([#30515](https://github.com/element-hq/element-web/pull/30515)). Contributed by @langleyd.
## 🐛 Bug Fixes
* [Backport staging] Ensure container starts if it is mounted with an empty /modules directory. ([#30705](https://github.com/element-hq/element-web/pull/30705)). Contributed by @RiotRobot.
* Fix room joining over federation not specifying vias or using aliases ([#30641](https://github.com/element-hq/element-web/pull/30641)). Contributed by @t3chguy.
* Fix stable-suffixed MSC4133 support ([#30649](https://github.com/element-hq/element-web/pull/30649)). Contributed by @dbkr.
* Fix i18n of message when a setting is disabled ([#30646](https://github.com/element-hq/element-web/pull/30646)). Contributed by @dbkr.
* ListView should not handle the arrow keys if there is a modifier applied ([#30633](https://github.com/element-hq/element-web/pull/30633)). Contributed by @langleyd.
* Make BaseDialog's div keyboard focusable and fix test. ([#30631](https://github.com/element-hq/element-web/pull/30631)). Contributed by @langleyd.
* Fix: Allow triple-click text selection to flow around pills ([#30349](https://github.com/element-hq/element-web/pull/30349)). Contributed by @AlirezaMrtz.
* Watch for a 'join' action to know when the call is connected ([#29492](https://github.com/element-hq/element-web/pull/29492)). Contributed by @robintown.
* Fix: add missing tooltip and aria-label to lock icon next to composer ([#30623](https://github.com/element-hq/element-web/pull/30623)). Contributed by @florianduros.
* Don't render context menu when scrolling ([#30613](https://github.com/element-hq/element-web/pull/30613)). Contributed by @langleyd.
Changes in [1.11.110](https://github.com/element-hq/element-web/releases/tag/v1.11.110) (2025-08-27)
====================================================================================================
## ✨ Features
* Hide recovery key when re-entering it while creating or changing it ([#30499](https://github.com/element-hq/element-web/pull/30499)). Contributed by @andybalaam.
* Add `?no_universal_links=true` to OIDC url so EX doesn't try to handle it ([#29439](https://github.com/element-hq/element-web/pull/29439)). Contributed by @t3chguy.
* Show a blue lock for unencrypted rooms and hide the grey shield for encrypted rooms ([#30440](https://github.com/element-hq/element-web/pull/30440)). Contributed by @langleyd.
* Add support for Module API 1.4 ([#30185](https://github.com/element-hq/element-web/pull/30185)). Contributed by @t3chguy.
* MVVM - Introduce some helpers for snapshot management ([#30398](https://github.com/element-hq/element-web/pull/30398)). Contributed by @MidhunSureshR.
## 🐛 Bug Fixes
* A11y: move focus to right panel when opened ([#30553](https://github.com/element-hq/element-web/pull/30553)). Contributed by @florianduros.
* Fix e2e warning icon should be white ([#30539](https://github.com/element-hq/element-web/pull/30539)). Contributed by @florianduros.
* Remove NoOneHere disabled reason. ([#30524](https://github.com/element-hq/element-web/pull/30524)). Contributed by @toger5.
* Fix downloading files with authenticated media API ([#30520](https://github.com/element-hq/element-web/pull/30520)). Contributed by @t3chguy.
* Fix call permissions check confusion around element call ([#30521](https://github.com/element-hq/element-web/pull/30521)). Contributed by @t3chguy.
* Fix line wrap around emoji verification ([#30523](https://github.com/element-hq/element-web/pull/30523)). Contributed by @t3chguy.
* Don't highlight redacted events ([#30519](https://github.com/element-hq/element-web/pull/30519)). Contributed by @t3chguy.
* Fix matrix.to links not being handled in the app ([#30522](https://github.com/element-hq/element-web/pull/30522)). Contributed by @t3chguy.
* Fix issue of new room list taking up the full width ([#30459](https://github.com/element-hq/element-web/pull/30459)). Contributed by @langleyd.
* Fix widget persistence in React development mode ([#30509](https://github.com/element-hq/element-web/pull/30509)). Contributed by @robintown.
* Fix widget initialization in React development mode ([#30463](https://github.com/element-hq/element-web/pull/30463)). Contributed by @robintown.
Changes in [1.11.109](https://github.com/element-hq/element-web/releases/tag/v1.11.109) (2025-08-11)
====================================================================================================
This release supports the upcoming v12 ("hydra") Matrix room version and is necessary to view and participate in these rooms.

View File

@@ -14,10 +14,9 @@ entrypoint_log() {
mkdir -p /tmp/element-web-config
cp /app/config*.json /tmp/element-web-config/
# If there are modules to be loaded
if [ -d "/modules" ]; then
# If the module directory exists AND the module directory has modules in it
if [ -d "/modules" ] && [ "$( ls -A '/modules' )" ]; then
cd /modules
for MODULE in *
do
# If the module has a package.json, use its main field as the entrypoint

View File

@@ -42,6 +42,13 @@ export default {
"util",
// Embedded into webapp
"@element-hq/element-call-embedded",
// Used by matrix-js-sdk, which means we have to include them as a
// dependency so that // we can run `tsc` (since we import the typescript
// source of js-sdk, rather than the transpiled and annotated JS like you
// would with a normal library).
"@types/content-type",
"@types/sdp-transform",
],
ignoreBinaries: [
// Used in scripts & workflows

View File

@@ -1,6 +1,6 @@
{
"name": "element-web",
"version": "1.11.109",
"version": "1.11.112",
"description": "Element: the future of secure communication",
"author": "New Vector Ltd.",
"repository": {
@@ -134,7 +134,7 @@
"maplibre-gl": "^5.0.0",
"matrix-encrypt-attachment": "^1.0.3",
"matrix-events-sdk": "0.0.1",
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#develop",
"matrix-js-sdk": "38.2.0",
"matrix-widget-api": "^1.10.0",
"memoize-one": "^6.0.0",
"mime": "^4.0.4",
@@ -203,6 +203,7 @@
"@testing-library/react": "^16.0.0",
"@testing-library/user-event": "^14.5.2",
"@types/commonmark": "^0.27.4",
"@types/content-type": "^1.1.9",
"@types/counterpart": "^0.18.1",
"@types/css-tree": "^2.3.8",
"@types/diff-match-patch": "^1.0.32",
@@ -226,6 +227,7 @@
"@types/react-dom": "19.1.7",
"@types/react-transition-group": "^4.4.0",
"@types/sanitize-html": "2.16.0",
"@types/sdp-transform": "^2.4.10",
"@types/semver": "^7.5.8",
"@types/tar-js": "^0.3.5",
"@types/ua-parser-js": "^0.7.36",

View File

@@ -126,7 +126,7 @@ test.describe("'Turn on key storage' toast", () => {
await toast.getByRole("button", { name: "Continue" }).click();
// Then we see the Encryption settings dialog with an option to turn on key storage
await expect(page.getByRole("checkbox", { name: "Allow key storage" })).toBeVisible();
await expect(page.getByRole("switch", { name: "Allow key storage" })).toBeVisible();
// And when we close that
await page.getByRole("button", { name: "Close dialog" }).click();
@@ -153,7 +153,7 @@ test.describe("'Turn on key storage' toast", () => {
await page.getByRole("button", { name: "Go to Settings" }).click();
// Then we see Encryption settings again
await expect(page.getByRole("checkbox", { name: "Allow key storage" })).toBeVisible();
await expect(page.getByRole("switch", { name: "Allow key storage" })).toBeVisible();
// And when we close that, see the toast, click Dismiss, and Yes, Dismiss
await page.getByRole("button", { name: "Close dialog" }).click();

View File

@@ -300,9 +300,9 @@ export async function doTwoWaySasVerification(page: Page, verifier: JSHandle<Ver
export async function enableKeyBackup(app: ElementAppPage): Promise<string> {
const encryptionTab = await app.settings.openUserSettings("Encryption");
const keyStorageToggle = encryptionTab.getByRole("checkbox", { name: "Allow key storage" });
const keyStorageToggle = encryptionTab.getByRole("switch", { name: "Allow key storage" });
if (!(await keyStorageToggle.isChecked())) {
await encryptionTab.getByRole("checkbox", { name: "Allow key storage" }).click();
await encryptionTab.getByRole("switch", { name: "Allow key storage" }).click();
}
await encryptionTab.getByRole("button", { name: "Set up recovery" }).click();
@@ -323,11 +323,11 @@ export async function enableKeyBackup(app: ElementAppPage): Promise<string> {
export async function disableKeyBackup(app: ElementAppPage): Promise<void> {
const encryptionTab = await app.settings.openUserSettings("Encryption");
const keyStorageToggle = encryptionTab.getByRole("checkbox", { name: "Allow key storage" });
const keyStorageToggle = encryptionTab.getByRole("switch", { name: "Allow key storage" });
if (await keyStorageToggle.isChecked()) {
await encryptionTab.getByRole("checkbox", { name: "Allow key storage" }).click();
await encryptionTab.getByRole("switch", { name: "Allow key storage" }).click();
await encryptionTab.getByRole("button", { name: "Delete key storage" }).click();
await encryptionTab.getByRole("checkbox", { name: "Allow key storage" }).isVisible();
await encryptionTab.getByRole("switch", { name: "Allow key storage" }).isVisible();
// Wait for the update to account data to stick
await new Promise((resolve) => setTimeout(resolve, 2000));

View File

@@ -193,6 +193,9 @@ test.describe("Room list", () => {
await roomListView.getByRole("option", { name: "Open room room20" }).click();
// Make sure the room with the unread is visible before we press the keyboard action to select it
await expect(roomListView.getByRole("option", { name: "1 notification" })).toBeVisible();
await page.keyboard.press("Alt+Shift+ArrowDown");
await expect(page.getByRole("heading", { name: "1 notification", level: 1 })).toBeVisible();

View File

@@ -100,3 +100,51 @@ test.describe("permalinks", () => {
});
});
});
test.describe("triple-click message selection", () => {
test.use({
displayName: "Alice",
});
test("should select entire message line when triple-clicking on message with pills", async ({
page,
app,
user,
bot,
}) => {
await bot.prepareClient();
const roomId = await app.client.createRoom({ name: "Test Room" });
await app.client.inviteUser(roomId, bot.credentials.userId);
await app.viewRoomByName("Test Room");
// Send a message with user and room pills
await app.client.sendMessage(
roomId,
`Testing triple-click message selection. ` +
`User: ${permalinkPrefix}${bot.credentials.userId}, ` +
`Room: ${permalinkPrefix}${roomId}, ` +
`Message: ${permalinkPrefix}${roomId}/$dummy-event, ` +
`and @room mention.`,
);
const timeline = page.locator(".mx_RoomView_timeline");
const messageTile = timeline.locator(".mx_EventTile").last();
// Triple-click on the message body to select its entire content
const messageBody = messageTile.locator(".mx_EventTile_body");
await messageBody.click({ clickCount: 3 });
// Get the expected text content of the message, including pills
const expectedText = await messageBody.innerText();
// Get the currently selected text from the page
const selectedText = await page.evaluate(() => {
const selection = window.getSelection();
return selection ? selection.toString().trim() : "";
});
// Verify that the selected text exactly matches the message content
expect(selectedText).toBe(expectedText);
});
});

View File

@@ -85,7 +85,7 @@ class Helpers {
* Return the system theme toggle
*/
getMatchSystemThemeCheckbox() {
return this.getThemePanel().getByRole("checkbox", { name: "Match system theme" });
return this.getThemePanel().getByRole("switch", { name: "Match system theme" });
}
/**
@@ -219,7 +219,7 @@ class Helpers {
* Return the compact layout checkbox
*/
getCompactLayoutCheckbox() {
return this.getMessageLayoutPanel().getByRole("checkbox", { name: "Show compact text and messages" });
return this.getMessageLayoutPanel().getByRole("switch", { name: "Show compact text and messages" });
}
/**

View File

@@ -117,7 +117,7 @@ test.describe("Encryption tab", () => {
await verifySession(app, recoveryKey.encodedPrivateKey);
await util.openEncryptionTab();
await page.getByRole("checkbox", { name: "Allow key storage" }).click();
await page.getByRole("switch", { name: "Allow key storage" }).click();
await expect(
page.getByRole("heading", { name: "Are you sure you want to turn off key storage and delete it?" }),
@@ -136,7 +136,7 @@ test.describe("Encryption tab", () => {
await page.getByRole("button", { name: "Delete key storage" }).click();
await expect(page.getByRole("checkbox", { name: "Allow key storage" })).not.toBeChecked();
await expect(page.getByRole("switch", { name: "Allow key storage" })).not.toBeChecked();
for (const prom of deleteRequestPromises) {
const request = await prom;

View File

@@ -908,23 +908,37 @@ test.describe("Timeline", () => {
});
});
test("should be able to hide an image", { tag: "@screenshot" }, async ({ page, app, room, context }) => {
await app.viewRoomById(room.roomId);
await sendImage(app.client, room.roomId, NEW_AVATAR);
await app.timeline.scrollToBottom();
const imgTile = page.locator(".mx_MImageBody").first();
await expect(imgTile).toBeVisible();
await imgTile.hover();
await page.getByRole("button", { name: "Hide" }).click();
test(
"should be able to hide an image",
{ tag: "@screenshot" },
async ({ page, app, homeserver, room, context }) => {
await app.viewRoomById(room.roomId);
// Check that the image is now hidden.
await expect(page.getByRole("button", { name: "Show image" })).toBeVisible();
});
const bot = new Bot(page, homeserver, {});
await bot.prepareClient();
await app.client.inviteUser(room.roomId, bot.credentials.userId);
test("should be able to hide a video", async ({ page, app, room, context }) => {
await sendImage(bot, room.roomId, NEW_AVATAR);
await app.timeline.scrollToBottom();
const imgTile = page.locator(".mx_MImageBody").first();
await expect(imgTile).toBeVisible();
await imgTile.hover();
await page.getByRole("button", { name: "Hide" }).click();
// Check that the image is now hidden.
await expect(page.getByRole("button", { name: "Show image" })).toBeVisible();
},
);
test("should be able to hide a video", async ({ page, app, homeserver, room, context }) => {
await app.viewRoomById(room.roomId);
const upload = await app.client.uploadContent(VIDEO_FILE, { name: "bbb.webm", type: "video/webm" });
await app.client.sendEvent(room.roomId, null, "m.room.message" as EventType, {
const bot = new Bot(page, homeserver, {});
await bot.prepareClient();
await app.client.inviteUser(room.roomId, bot.credentials.userId);
const upload = await bot.uploadContent(VIDEO_FILE, { name: "bbb.webm", type: "video/webm" });
await bot.sendEvent(room.roomId, null, "m.room.message" as EventType, {
msgtype: "m.video" as MsgType,
body: "bbb.webm",
url: upload.content_uri,

View File

@@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details.
import { SynapseContainer as BaseSynapseContainer } from "@element-hq/element-web-playwright-common/lib/testcontainers";
const TAG = "develop@sha256:18e9e77eac01709e9ab4d26cf20c36bf5a1567756bb5a78c00cabf366d65a950";
const TAG = "develop@sha256:8ce4c1a466e1e32bcffde390250a6785527d235436ae42afa9bf94d2a9288746";
/**
* SynapseContainer which freezes the docker digest to stabilise tests,

View File

@@ -11,8 +11,7 @@ Please see LICENSE files in the repository root for full details.
line-height: $font-17px;
border-radius: $font-16px;
vertical-align: text-top;
display: inline-flex;
align-items: center;
display: inline-block;
box-sizing: border-box;
max-width: 100%;
overflow: hidden;
@@ -57,6 +56,8 @@ Please see LICENSE files in the repository root for full details.
margin-inline-start: -0.3em; /* Otherwise the gap is too large */
margin-inline-end: 0.2em;
min-width: $font-16px; /* ensure the avatar is not compressed */
user-select: text;
vertical-align: -2.5px;
}
.mx_Pill_text {

View File

@@ -68,9 +68,17 @@ type ElectronChannel =
| "openDesktopCapturerSourcePicker"
| "userAccessToken"
| "homeserverUrl"
| "serverSupportedVersions";
| "serverSupportedVersions"
| "showToast";
declare global {
// use `number` as the return type in all cases for globalThis.set{Interval,Timeout},
// so we don't accidentally use the methods on NodeJS.Timeout - they only exist in a subset of environments.
// The overload for clear{Interval,Timeout} is resolved as expected.
// We use `ReturnType<typeof setTimeout>` in the code to be agnostic of if this definition gets loaded.
function setInterval(handler: TimerHandler, timeout: number, ...arguments: any[]): number;
function setTimeout(handler: TimerHandler, timeout: number, ...arguments: any[]): number;
interface Window {
mxSendRageshake: (text: string, withLogs?: boolean) => void;
matrixLogger: typeof logger;

View File

@@ -112,6 +112,7 @@ export enum LegacyCallHandlerEvent {
CallsChanged = "calls_changed",
CallChangeRoom = "call_change_room",
SilencedCallsChanged = "silenced_calls_changed",
ShownSidebarsChanged = "shown_sidebars_changed",
CallState = "call_state",
ProtocolSupport = "protocol_support",
}
@@ -120,6 +121,7 @@ type EventEmitterMap = {
[LegacyCallHandlerEvent.CallsChanged]: (calls: Map<string, MatrixCall>) => void;
[LegacyCallHandlerEvent.CallChangeRoom]: (call: MatrixCall) => void;
[LegacyCallHandlerEvent.SilencedCallsChanged]: (calls: Set<string>) => void;
[LegacyCallHandlerEvent.ShownSidebarsChanged]: (sidebarsShown: Map<string, boolean>) => void;
[LegacyCallHandlerEvent.CallState]: (mappedRoomId: string | null, status: CallState) => void;
[LegacyCallHandlerEvent.ProtocolSupport]: () => void;
};
@@ -144,6 +146,8 @@ export default class LegacyCallHandler extends TypedEventEmitter<LegacyCallHandl
private silencedCalls = new Set<string>(); // callIds
private shownSidebars = new Map<string, boolean>(); // callId (call) -> sidebar show
private backgroundAudio = new BackgroundAudio();
private playingSources: Record<string, AudioBufferSourceNode> = {}; // Record them for stopping
@@ -240,6 +244,15 @@ export default class LegacyCallHandler extends TypedEventEmitter<LegacyCallHandl
return false;
}
public setCallSidebarShown(callId: string, sidebarShown: boolean): void {
this.shownSidebars.set(callId, sidebarShown);
this.emit(LegacyCallHandlerEvent.ShownSidebarsChanged, this.shownSidebars);
}
public isCallSidebarShown(callId?: string): boolean {
return !!callId && (this.shownSidebars.get(callId) ?? true);
}
private async checkProtocols(maxTries: number): Promise<void> {
try {
const protocols = await MatrixClientPeg.safeGet().getThirdpartyProtocols();

View File

@@ -6,6 +6,8 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
type CacheResult = { roomId: string; viaServers: string[] };
/**
* This is meant to be a cache of room alias to room ID so that moving between
* rooms happens smoothly (for example using browser back / forward buttons).
@@ -16,12 +18,12 @@ Please see LICENSE files in the repository root for full details.
* A similar thing could also be achieved via `pushState` with a state object,
* but keeping it separate like this seems easier in case we do want to extend.
*/
const aliasToIDMap = new Map<string, string>();
const cache = new Map<string, CacheResult>();
export function storeRoomAliasInCache(alias: string, id: string): void {
aliasToIDMap.set(alias, id);
export function storeRoomAliasInCache(alias: string, roomId: string, viaServers: string[]): void {
cache.set(alias, { roomId, viaServers });
}
export function getCachedRoomIDForAlias(alias: string): string | undefined {
return aliasToIDMap.get(alias);
export function getCachedRoomIdForAlias(alias: string): CacheResult | undefined {
return cache.get(alias);
}

View File

@@ -51,7 +51,7 @@ import ThemeController from "../../settings/controllers/ThemeController";
import { startAnyRegistrationFlow } from "../../Registration";
import ResizeNotifier from "../../utils/ResizeNotifier";
import AutoDiscoveryUtils from "../../utils/AutoDiscoveryUtils";
import { makeRoomPermalink } from "../../utils/permalinks/Permalinks";
import { calculateRoomVia, makeRoomPermalink } from "../../utils/permalinks/Permalinks";
import ThemeWatcher, { ThemeWatcherEvent } from "../../settings/watchers/ThemeWatcher";
import { FontWatcher } from "../../settings/watchers/FontWatcher";
import { storeRoomAliasInCache } from "../../RoomAliasCache";
@@ -238,6 +238,8 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
private readonly stores: SdkContextClass;
private loadSessionAbortController = new AbortController();
private sessionLoadStarted = false;
public constructor(props: IProps) {
super(props);
this.stores = SdkContextClass.instance;
@@ -470,15 +472,20 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
this.fontWatcher.start();
initSentry(SdkConfig.get("sentry"));
if (!checkSessionLockFree()) {
// another instance holds the lock; confirm its theft before proceeding
setTimeout(() => this.setState({ view: Views.CONFIRM_LOCK_THEFT }), 0);
} else {
this.startInitSession();
}
window.addEventListener("resize", this.onWindowResized);
// Once we start loading the MatrixClient, we can't stop, even if MatrixChat gets unmounted (as it does
// in React's Strict Mode). So, start loading the session now, but only if this MatrixChat was not previously
// mounted.
if (!this.sessionLoadStarted) {
this.sessionLoadStarted = true;
if (!checkSessionLockFree()) {
// another instance holds the lock; confirm its theft before proceeding
setTimeout(() => this.setState({ view: Views.CONFIRM_LOCK_THEFT }), 0);
} else {
this.startInitSession();
}
}
}
public componentDidUpdate(prevProps: IProps, prevState: IState): void {
@@ -1019,7 +1026,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
presentedId = theAlias;
// Store display alias of the presented room in cache to speed future
// navigation.
storeRoomAliasInCache(theAlias, room.roomId);
storeRoomAliasInCache(theAlias, room.roomId, calculateRoomVia(room));
}
// Store this as the ID of the last room accessed. This is so that we can

View File

@@ -245,6 +245,7 @@ class PipContainerInner extends React.Component<IProps, IState> {
secondaryCall={this.state.secondaryCall}
pipMode={pipMode}
onResize={onResize}
sidebarShown={false}
/>
));
}

View File

@@ -184,28 +184,32 @@ export function ListView<Item, Context = any>(props: IListViewProps<Item, Contex
// Guard against null/undefined events and modified keys which we don't want to handle here but do
// at the settings level shortcuts(E.g. Select next room, etc )
if (e || !isModifiedKeyEvent(e)) {
if (e.code === Key.ARROW_UP && currentIndex !== undefined) {
scrollToItem(currentIndex - 1, false);
handled = true;
} else if (e.code === Key.ARROW_DOWN && currentIndex !== undefined) {
scrollToItem(currentIndex + 1, true);
handled = true;
} else if (e.code === Key.HOME) {
scrollToIndex(0);
handled = true;
} else if (e.code === Key.END) {
scrollToIndex(items.length - 1);
handled = true;
} else if (e.code === Key.PAGE_DOWN && visibleRange && currentIndex !== undefined) {
const numberDisplayed = visibleRange.endIndex - visibleRange.startIndex;
scrollToItem(Math.min(currentIndex + numberDisplayed, items.length - 1), true, `start`);
handled = true;
} else if (e.code === Key.PAGE_UP && visibleRange && currentIndex !== undefined) {
const numberDisplayed = visibleRange.endIndex - visibleRange.startIndex;
scrollToItem(Math.max(currentIndex - numberDisplayed, 0), false, `start`);
handled = true;
}
// Guard against null/undefined events and modified keys
if (!e || isModifiedKeyEvent(e)) {
onKeyDown?.(e);
return;
}
if (e.code === Key.ARROW_UP && currentIndex !== undefined) {
scrollToItem(currentIndex - 1, false);
handled = true;
} else if (e.code === Key.ARROW_DOWN && currentIndex !== undefined) {
scrollToItem(currentIndex + 1, true);
handled = true;
} else if (e.code === Key.HOME) {
scrollToIndex(0);
handled = true;
} else if (e.code === Key.END) {
scrollToIndex(items.length - 1);
handled = true;
} else if (e.code === Key.PAGE_DOWN && visibleRange && currentIndex !== undefined) {
const numberDisplayed = visibleRange.endIndex - visibleRange.startIndex;
scrollToItem(Math.min(currentIndex + numberDisplayed, items.length - 1), true, `start`);
handled = true;
} else if (e.code === Key.PAGE_UP && visibleRange && currentIndex !== undefined) {
const numberDisplayed = visibleRange.endIndex - visibleRange.startIndex;
scrollToItem(Math.max(currentIndex - numberDisplayed, 0), false, `start`);
handled = true;
}
if (handled) {

View File

@@ -145,6 +145,9 @@ export default class BaseDialog extends React.Component<IProps> {
const lockProps: Record<string, any> = {
"onKeyDown": this.onKeyDown,
"role": "dialog",
// Allow the dialog to be keyboard focusable
// So the escape key handling works in more cases (say you select the header)
"tabIndex": -1,
// This should point to a node describing the dialog.
// If we were about to completely follow this recommendation we'd need to
// make all the components relying on BaseDialog to be aware of it.

View File

@@ -1,55 +1,53 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2024, 2025 New Vector Ltd.
Copyright 2019 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import React, { useCallback } from "react";
import { _t } from "../../../languageHandler";
import dis from "../../../dispatcher/dispatcher";
import { Action } from "../../../dispatcher/actions";
import BaseDialog from "./BaseDialog";
import DialogButtons from "../elements/DialogButtons";
import { UserTab } from "./UserTab";
interface IProps {
onFinished(): void;
}
export default class IntegrationsDisabledDialog extends React.Component<IProps> {
private onAcknowledgeClick = (): void => {
this.props.onFinished();
};
export const IntegrationsDisabledDialog: React.FC<IProps> = ({ onFinished }) => {
const onOpenSettingsClick = useCallback(() => {
onFinished();
dis.dispatch({
action: Action.ViewUserSettings,
initialTabId: UserTab.Security,
});
}, [onFinished]);
private onOpenSettingsClick = (): void => {
this.props.onFinished();
dis.fire(Action.ViewUserSettings);
};
public render(): React.ReactNode {
return (
<BaseDialog
className="mx_IntegrationsDisabledDialog"
hasCancel={true}
onFinished={this.props.onFinished}
title={_t("integrations|disabled_dialog_title")}
>
<div className="mx_IntegrationsDisabledDialog_content">
<p>
{_t("integrations|disabled_dialog_description", {
manageIntegrations: _t("integration_manager|manage_title"),
})}
</p>
</div>
<DialogButtons
primaryButton={_t("common|settings")}
onPrimaryButtonClick={this.onOpenSettingsClick}
cancelButton={_t("action|ok")}
onCancel={this.onAcknowledgeClick}
/>
</BaseDialog>
);
}
}
return (
<BaseDialog
className="mx_IntegrationsDisabledDialog"
hasCancel={true}
onFinished={onFinished}
title={_t("integrations|disabled_dialog_title")}
>
<div className="mx_IntegrationsDisabledDialog_content">
<p>
{_t("integrations|disabled_dialog_description", {
manageIntegrations: _t("integration_manager|manage_title"),
})}
</p>
</div>
<DialogButtons
primaryButton={_t("common|settings")}
onPrimaryButtonClick={onOpenSettingsClick}
cancelButton={_t("action|ok")}
onCancel={onFinished}
/>
</BaseDialog>
);
};

View File

@@ -53,7 +53,7 @@ import { getKeyBindingsManager } from "../../../../KeyBindingsManager";
import { _t } from "../../../../languageHandler";
import { MatrixClientPeg } from "../../../../MatrixClientPeg";
import { PosthogAnalytics } from "../../../../PosthogAnalytics";
import { getCachedRoomIDForAlias } from "../../../../RoomAliasCache";
import { getCachedRoomIdForAlias } from "../../../../RoomAliasCache";
import { showStartChatInviteDialog } from "../../../../RoomInvite";
import { SettingLevel } from "../../../../settings/SettingLevel";
import SettingsStore from "../../../../settings/SettingsStore";
@@ -912,7 +912,7 @@ const SpotlightDialog: React.FC<IProps> = ({ initialText = "", initialFilter = n
if (
trimmedQuery.startsWith("#") &&
trimmedQuery.includes(":") &&
(!getCachedRoomIDForAlias(trimmedQuery) || !cli.getRoom(getCachedRoomIDForAlias(trimmedQuery)))
(!getCachedRoomIdForAlias(trimmedQuery) || !cli.getRoom(getCachedRoomIdForAlias(trimmedQuery)!.roomId))
) {
joinRoomSection = (
<div className="mx_SpotlightDialog_section mx_SpotlightDialog_otherSearches" role="group">

View File

@@ -151,7 +151,7 @@ interface Props extends ReplacerOptions {
const EventContentBody = memo(
({ as, mxEvent, stripReply, content, linkify, highlights, includeDir = true, ref, ...options }: Props) => {
const enableBigEmoji = useSettingValue("TextualBody.enableBigEmoji");
const [mediaIsVisible] = useMediaVisible(mxEvent?.getId(), mxEvent?.getRoomId());
const [mediaIsVisible] = useMediaVisible(mxEvent);
const replacer = useReplacer(content, mxEvent, options);
const linkifyOptions = useMemo(

View File

@@ -25,7 +25,7 @@ interface IProps {
* Quick action button for marking a media event as hidden.
*/
export const HideActionButton: React.FC<IProps> = ({ mxEvent }) => {
const [mediaIsVisible, setVisible] = useMediaVisible(mxEvent.getId(), mxEvent.getRoomId());
const [mediaIsVisible, setVisible] = useMediaVisible(mxEvent);
if (!mediaIsVisible) {
return;

View File

@@ -686,7 +686,7 @@ export class MImageBodyInner extends React.Component<IProps, IState> {
// Wrap MImageBody component so we can use a hook here.
const MImageBody: React.FC<IBodyProps> = (props) => {
const [mediaVisible, setVisible] = useMediaVisible(props.mxEvent.getId(), props.mxEvent.getRoomId());
const [mediaVisible, setVisible] = useMediaVisible(props.mxEvent);
return <MImageBodyInner mediaVisible={mediaVisible} setMediaVisible={setVisible} {...props} />;
};

View File

@@ -38,7 +38,7 @@ class MImageReplyBodyInner extends MImageBodyInner {
}
}
const MImageReplyBody: React.FC<IBodyProps> = (props) => {
const [mediaVisible, setVisible] = useMediaVisible(props.mxEvent.getId(), props.mxEvent.getRoomId());
const [mediaVisible, setVisible] = useMediaVisible(props.mxEvent);
return <MImageReplyBodyInner mediaVisible={mediaVisible} setMediaVisible={setVisible} {...props} />;
};

View File

@@ -20,7 +20,7 @@ class MStickerBodyInner extends MImageBodyInner {
protected onClick = (ev: React.MouseEvent): void => {
ev.preventDefault();
if (!this.props.mediaVisible) {
this.props.setMediaVisible?.(true);
this.props.setMediaVisible(true);
}
};
@@ -79,7 +79,7 @@ class MStickerBodyInner extends MImageBodyInner {
}
const MStickerBody: React.FC<IBodyProps> = (props) => {
const [mediaVisible, setVisible] = useMediaVisible(props.mxEvent.getId(), props.mxEvent.getRoomId());
const [mediaVisible, setVisible] = useMediaVisible(props.mxEvent);
return <MStickerBodyInner mediaVisible={mediaVisible} setMediaVisible={setVisible} {...props} />;
};

View File

@@ -342,7 +342,7 @@ class MVideoBodyInner extends React.PureComponent<IProps, IState> {
// Wrap MVideoBody component so we can use a hook here.
const MVideoBody: React.FC<IBodyProps> = (props) => {
const [mediaVisible, setVisible] = useMediaVisible(props.mxEvent.getId(), props.mxEvent.getRoomId());
const [mediaVisible, setVisible] = useMediaVisible(props.mxEvent);
return <MVideoBodyInner mediaVisible={mediaVisible} setMediaVisible={setVisible} {...props} />;
};

View File

@@ -30,7 +30,7 @@ interface IProps {
const LinkPreviewGroup: React.FC<IProps> = ({ links, mxEvent, onCancelClick }) => {
const cli = useContext(MatrixClientContext);
const [expanded, toggleExpanded] = useStateToggle();
const [mediaVisible] = useMediaVisible(mxEvent.getId(), mxEvent.getRoomId());
const [mediaVisible] = useMediaVisible(mxEvent);
const ts = mxEvent.getTs();
const previews = useAsyncMemo<[string, IPreviewUrlResponse][]>(

View File

@@ -531,12 +531,15 @@ export class MessageComposer extends React.Component<IProps, IState> {
if (!this.props.e2eStatus) {
leftIcon = (
<div className="mx_MessageComposer_e2eIconWrapper">
<LockOffIcon
width={12}
height={12}
color="var(--cpd-color-icon-info-primary)"
className="mx_E2EIcon mx_MessageComposer_e2eIcon"
/>
<Tooltip label={_t("composer|room_unencrypted")}>
<LockOffIcon
aria-label={_t("composer|room_unencrypted")}
width={12}
height={12}
color="var(--cpd-color-icon-info-primary)"
className="mx_E2EIcon mx_MessageComposer_e2eIcon"
/>
</Tooltip>
</div>
);
} else if (this.props.e2eStatus !== E2EStatus.Normal) {

View File

@@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details.
*/
import React, { useCallback, useRef, type JSX } from "react";
import React, { useCallback, useRef, useState, type JSX } from "react";
import { type Room } from "matrix-js-sdk/src/matrix";
import { type ScrollIntoViewLocation } from "react-virtuoso";
import { isEqual } from "lodash";
@@ -33,6 +33,7 @@ export function RoomList({ vm: { roomsResult, activeIndex } }: RoomListProps): J
const lastSpaceId = useRef<string | undefined>(undefined);
const lastFilterKeys = useRef<FilterKey[] | undefined>(undefined);
const roomCount = roomsResult.rooms.length;
const [isScrolling, setIsScrolling] = useState(false);
const getItemComponent = useCallback(
(
index: number,
@@ -57,10 +58,11 @@ export function RoomList({ vm: { roomsResult, activeIndex } }: RoomListProps): J
roomIndex={index}
roomCount={roomCount}
onFocus={onFocus}
listIsScrolling={isScrolling}
/>
);
},
[activeIndex, roomCount],
[activeIndex, roomCount, isScrolling],
);
const getItemKey = useCallback((item: Room): string => {
@@ -116,6 +118,7 @@ export function RoomList({ vm: { roomsResult, activeIndex } }: RoomListProps): J
getItemKey={getItemKey}
isItemFocusable={() => true}
onKeyDown={keyDownCallback}
isScrolling={setIsScrolling}
/>
);
}

View File

@@ -41,6 +41,10 @@ interface RoomListItemViewProps extends React.HTMLAttributes<HTMLButtonElement>
* The total number of rooms in the list
*/
roomCount: number;
/**
* Whether the list is currently scrolling
*/
listIsScrolling: boolean;
}
/**
@@ -53,6 +57,7 @@ export const RoomListItemView = memo(function RoomListItemView({
onFocus,
roomIndex: index,
roomCount: count,
listIsScrolling,
...props
}: RoomListItemViewProps): JSX.Element {
const ref = useRef<HTMLButtonElement>(null);
@@ -141,7 +146,11 @@ export const RoomListItemView = memo(function RoomListItemView({
</Flex>
);
if (!vm.showContextMenu) return content;
// Rendering multiple context menus can causes crashes in radix upstream,
// See https://github.com/radix-ui/primitives/issues/2717.
// We also don't need the context menu while scrolling so can improve scroll performance
// by not rendering it.
if (!vm.showContextMenu || listIsScrolling) return content;
return (
<RoomListItemContextMenuView

View File

@@ -50,6 +50,10 @@ interface IProps {
onMouseDownOnHeader?: (event: React.MouseEvent<Element, MouseEvent>) => void;
showApps?: boolean;
sidebarShown: boolean;
setSidebarShown?: (sidebarShown: boolean) => void;
}
interface IState {
@@ -62,7 +66,6 @@ interface IState {
primaryFeed?: CallFeed;
secondaryFeed?: CallFeed;
sidebarFeeds: Array<CallFeed>;
sidebarShown: boolean;
}
function getFullScreenElement(): Element | null {
@@ -97,7 +100,6 @@ export default class LegacyCallView extends React.Component<IProps, IState> {
primaryFeed: primary,
secondaryFeed: secondary,
sidebarFeeds: sidebar,
sidebarShown: true,
};
}
@@ -269,8 +271,9 @@ export default class LegacyCallView extends React.Component<IProps, IState> {
isScreensharing = await this.props.call.setScreensharingEnabled(true);
}
this.props.setSidebarShown?.(true);
this.setState({
sidebarShown: true,
screensharing: isScreensharing,
});
};
@@ -320,12 +323,12 @@ export default class LegacyCallView extends React.Component<IProps, IState> {
};
private onToggleSidebar = (): void => {
this.setState({ sidebarShown: !this.state.sidebarShown });
this.props.setSidebarShown?.(!this.props.sidebarShown);
};
private renderCallControls(): JSX.Element {
const { call, pipMode } = this.props;
const { callState, micMuted, vidMuted, screensharing, sidebarShown, secondaryFeed, sidebarFeeds } = this.state;
const { call, pipMode, sidebarShown } = this.props;
const { callState, micMuted, vidMuted, screensharing, secondaryFeed, sidebarFeeds } = this.state;
// If SDPStreamMetadata isn't supported don't show video mute button in voice calls
const vidMuteButtonShown = call.opponentSupportsSDPStreamMetadata() || call.hasLocalUserMediaVideoTrack;
@@ -337,7 +340,8 @@ export default class LegacyCallView extends React.Component<IProps, IState> {
(call.opponentSupportsSDPStreamMetadata() || call.hasLocalUserMediaVideoTrack) &&
call.state === CallState.Connected;
// Show the sidebar button only if there is something to hide/show
const sidebarButtonShown = (secondaryFeed && !secondaryFeed.isVideoMuted()) || sidebarFeeds.length > 0;
const sidebarButtonShown =
!pipMode && ((secondaryFeed && !secondaryFeed.isVideoMuted()) || sidebarFeeds.length > 0);
// The dial pad & 'more' button actions are only relevant in a connected call
const contextMenuButtonShown = callState === CallState.Connected;
const dialpadButtonShown = callState === CallState.Connected && call.opponentSupportsDTMF();
@@ -372,7 +376,7 @@ export default class LegacyCallView extends React.Component<IProps, IState> {
}
private renderToast(): JSX.Element | null {
const { call } = this.props;
const { call, sidebarShown } = this.props;
const someoneIsScreensharing = call.getFeeds().some((feed) => {
return feed.purpose === SDPStreamMetadataPurpose.Screenshare;
});
@@ -380,7 +384,7 @@ export default class LegacyCallView extends React.Component<IProps, IState> {
if (!someoneIsScreensharing) return null;
const isScreensharing = call.isScreensharing();
const { primaryFeed, sidebarShown } = this.state;
const { primaryFeed } = this.state;
const sharerName = primaryFeed?.getMember()?.name;
if (!sharerName) return null;
@@ -393,8 +397,8 @@ export default class LegacyCallView extends React.Component<IProps, IState> {
}
private renderContent(): JSX.Element {
const { pipMode, call, onResize } = this.props;
const { isLocalOnHold, isRemoteOnHold, sidebarShown, primaryFeed, secondaryFeed, sidebarFeeds } = this.state;
const { pipMode, call, onResize, sidebarShown } = this.props;
const { isLocalOnHold, isRemoteOnHold, primaryFeed, secondaryFeed, sidebarFeeds } = this.state;
const callRoomId = LegacyCallHandler.instance.roomIdForCall(call);
const callRoom = (callRoomId ? MatrixClientPeg.safeGet().getRoom(callRoomId) : undefined) ?? undefined;
@@ -537,8 +541,8 @@ export default class LegacyCallView extends React.Component<IProps, IState> {
}
public render(): React.ReactNode {
const { call, secondaryCall, pipMode, showApps, onMouseDownOnHeader } = this.props;
const { sidebarShown, sidebarFeeds } = this.state;
const { call, secondaryCall, pipMode, showApps, onMouseDownOnHeader, sidebarShown } = this.props;
const { sidebarFeeds } = this.state;
const client = MatrixClientPeg.safeGet();
const callRoomId = LegacyCallHandler.instance.roomIdForCall(call);

View File

@@ -25,6 +25,7 @@ interface IProps {
interface IState {
call: MatrixCall | null;
sidebarShown: boolean;
}
/*
@@ -34,19 +35,23 @@ interface IState {
export default class LegacyCallViewForRoom extends React.Component<IProps, IState> {
public constructor(props: IProps) {
super(props);
const call = this.getCall();
this.state = {
call: this.getCall(),
call,
sidebarShown: !!call && LegacyCallHandler.instance.isCallSidebarShown(call.callId),
};
}
public componentDidMount(): void {
LegacyCallHandler.instance.addListener(LegacyCallHandlerEvent.CallState, this.updateCall);
LegacyCallHandler.instance.addListener(LegacyCallHandlerEvent.CallChangeRoom, this.updateCall);
LegacyCallHandler.instance.addListener(LegacyCallHandlerEvent.ShownSidebarsChanged, this.updateCall);
}
public componentWillUnmount(): void {
LegacyCallHandler.instance.removeListener(LegacyCallHandlerEvent.CallState, this.updateCall);
LegacyCallHandler.instance.removeListener(LegacyCallHandlerEvent.CallChangeRoom, this.updateCall);
LegacyCallHandler.instance.removeListener(LegacyCallHandlerEvent.ShownSidebarsChanged, this.updateCall);
}
private updateCall = (): void => {
@@ -54,6 +59,10 @@ export default class LegacyCallViewForRoom extends React.Component<IProps, IStat
if (newCall !== this.state.call) {
this.setState({ call: newCall });
}
const newSidebarShown = !!newCall && LegacyCallHandler.instance.isCallSidebarShown(newCall.callId);
if (newSidebarShown !== this.state.sidebarShown) {
this.setState({ sidebarShown: newSidebarShown });
}
};
private getCall(): MatrixCall | null {
@@ -75,6 +84,11 @@ export default class LegacyCallViewForRoom extends React.Component<IProps, IStat
this.props.resizeNotifier.stopResizing();
};
private setSidebarShown = (sidebarShown: boolean): void => {
if (!this.state.call) return;
LegacyCallHandler.instance.setCallSidebarShown(this.state.call.callId, sidebarShown);
};
public render(): React.ReactNode {
if (!this.state.call) return null;
@@ -99,7 +113,13 @@ export default class LegacyCallViewForRoom extends React.Component<IProps, IStat
className="mx_LegacyCallViewForRoom_ResizeWrapper"
handleClasses={{ bottom: "mx_LegacyCallViewForRoom_ResizeHandle" }}
>
<LegacyCallView call={this.state.call} pipMode={false} showApps={this.props.showApps} />
<LegacyCallView
call={this.state.call}
pipMode={false}
showApps={this.props.showApps}
sidebarShown={this.state.sidebarShown}
setSidebarShown={this.setSidebarShown}
/>
</Resizable>
</div>
);

View File

@@ -6,7 +6,7 @@ Please see LICENSE files in the repository root for full details.
*/
import { useCallback } from "react";
import { JoinRule } from "matrix-js-sdk/src/matrix";
import { JoinRule, type MatrixEvent } from "matrix-js-sdk/src/matrix";
import { SettingLevel } from "../settings/SettingLevel";
import { useSettingValue } from "./useSettings";
@@ -19,14 +19,25 @@ const PRIVATE_JOIN_RULES: JoinRule[] = [JoinRule.Invite, JoinRule.Knock, JoinRul
/**
* Should the media event be visible in the client, or hidden.
* @param eventId The eventId of the media event.
* @returns A boolean describing the hidden status, and a function to set the visiblity.
*
* This function uses the `mediaPreviewConfig` setting to determine the rules for the room
* along with the `showMediaEventIds` setting for specific events.
*
* A function may be provided to alter the visible state.
*
* @param The event that contains the media. If not provided, the global rule is used.
*
* @returns Returns a tuple of:
* A boolean describing the hidden status.
* A function to show or hide the event.
*/
export function useMediaVisible(eventId?: string, roomId?: string): [boolean, (visible: boolean) => void] {
const mediaPreviewSetting = useSettingValue("mediaPreviewConfig", roomId);
export function useMediaVisible(mxEvent?: MatrixEvent): [boolean, (visible: boolean) => void] {
const eventId = mxEvent?.getId();
const mediaPreviewSetting = useSettingValue("mediaPreviewConfig", mxEvent?.getRoomId());
const client = useMatrixClientContext();
const eventVisibility = useSettingValue("showMediaEventIds");
const joinRule = useRoomState(client.getRoom(roomId) ?? undefined, (state) => state.getJoinRule());
const room = client.getRoom(mxEvent?.getRoomId()) ?? undefined;
const joinRule = useRoomState(room, (state) => state.getJoinRule());
const setMediaVisible = useCallback(
(visible: boolean) => {
SettingsStore.setValue("showMediaEventIds", null, SettingLevel.DEVICE, {
@@ -43,6 +54,9 @@ export function useMediaVisible(eventId?: string, roomId?: string): [boolean, (v
// Always prefer the explicit per-event user preference here.
if (explicitEventVisiblity !== undefined) {
return [explicitEventVisiblity, setMediaVisible];
} else if (mxEvent?.getSender() === client.getUserId()) {
// If this event is ours and we've not set an explicit visibility, default to on.
return [true, setMediaVisible];
} else if (mediaPreviewSetting.media_previews === MediaPreviewValue.Off) {
return [false, setMediaVisible];
} else if (mediaPreviewSetting.media_previews === MediaPreviewValue.On) {

View File

@@ -646,12 +646,12 @@
"mode_plain": "Skrýt formátování",
"mode_rich_text": "Zobrazit formátování",
"no_perms_notice": "Nemáte oprávnění zveřejňovat příspěvky v této místnosti",
"placeholder": "Odeslat zprávu…",
"placeholder_encrypted": "Odeslat šifrovanou zprávu",
"placeholder_reply": "Odpovědět…",
"placeholder_reply_encrypted": "Odeslat šifrovanou odpověď…",
"placeholder_thread": "Odpovědět na vlákno…",
"placeholder_thread_encrypted": "Odpovědět na zašifrované vlákno…",
"placeholder": "Odeslat nešifrovanou zprávu…",
"placeholder_encrypted": "Odeslat zprávu...",
"placeholder_reply": "Odeslat nešifrovanou odpověď…",
"placeholder_reply_encrypted": "Odeslat odpověď…",
"placeholder_thread": "Odpovědět v nešifrovaném vláknu…",
"placeholder_thread_encrypted": "Odpovědět ve vláknu…",
"poll_button": "Hlasování",
"poll_button_no_perms_description": "Nemáte oprávnění zahajovat hlasování v této místnosti.",
"poll_button_no_perms_title": "Vyžaduje oprávnění",
@@ -922,7 +922,8 @@
},
"privacy_warning": "Ujistěte se, že tuto obrazovku nikdo nevidí!",
"restoring": "Obnovení klíčů ze zálohy",
"security_key_title": "Klíč pro obnovení"
"security_key_label": "Klíč pro obnovení",
"security_key_title": "Zadejte klíč pro obnovení"
},
"bootstrap_title": "Příprava klíčů",
"confirm_encryption_setup_body": "Kliknutím na tlačítko níže potvrďte nastavení šifrování.",
@@ -1367,6 +1368,10 @@
"name_email_mxid_share_space": "Pozvěte někoho pomocí jeho jména, e-mailové adresy, uživatelského jména (například <userId/>) nebo <a>sdílejte tento prostor</a>.",
"name_mxid_share_room": "Pozvěte někoho pomocí svého jména, uživatelského jména (například <userId />) nebo <a>sdílejte tuto místnost</a>.",
"name_mxid_share_space": "Pozvěte někoho pomocí jeho jména, uživatelského jména (například <userId/>) nebo <a>sdílejte tento prostor</a>.",
"progress": {
"dont_close": "Nezavírejte aplikaci, dokud neskončíte.",
"preparing": "Příprava pozvánek..."
},
"recents_section": "Nedávné konverzace",
"room_failed_partial": "Poslali jsme ostatním, ale níže uvedení lidé nemohli být pozváni do <RoomName/>",
"room_failed_partial_title": "Některé pozvánky nebylo možné odeslat",
@@ -1535,6 +1540,9 @@
"render_reaction_images_description": "Někdy se označují jako \"vlastní emoji\".",
"report_to_moderators": "Nahlásit moderátorům",
"report_to_moderators_description": "V místnostech, které podporují moderování, můžete pomocí tlačítka \"Nahlásit\" nahlásit zneužití moderátorům místnosti.",
"share_history_on_invite": "Sdílet šifrovanou historii s novými členy",
"share_history_on_invite_description": "Při pozvání uživatele do šifrované místnosti, u které je viditelnost historie nastavena na „sdílená“, sdílet šifrovanou historii s tímto uživatelem a přijmout šifrovanou historii, když jste pozváni do takové místnosti.",
"share_history_on_invite_warning": "Tato funkce je EXPERIMENTÁLNÍ a nejsou v ní implementována všechna bezpečnostní opatření. Neaktivujte ji na produkčních účtech.",
"sliding_sync": "Režim klouzavé synchronizace",
"sliding_sync_description": "V aktivním vývoji, nelze zakázat.",
"sliding_sync_disabled_notice": "Pro vypnutí se odhlaste a znovu přihlaste",
@@ -1657,6 +1665,7 @@
"filter_placeholder": "Najít člena místnosti",
"invite_button_no_perms_tooltip": "Nemáte oprávnění zvát uživatele",
"invited_label": "Pozván",
"list_title": "Seznam členů",
"no_matches": "Žádné shody"
},
"member_list_back_action_label": "Členové místnosti",
@@ -1761,6 +1770,7 @@
},
"power_level": {
"admin": "Správce",
"creator": "Vlastník",
"custom": "Vlastní (%(level)s)",
"custom_level": "Vlastní úroveň",
"default": "Výchozí",
@@ -1914,6 +1924,7 @@
"thread_list": {
"context_menu_label": "Možnosti vláken"
},
"title": "Pravý panel",
"video_room_chat": {
"title": "Chatovat"
}
@@ -3400,6 +3411,7 @@
"unable_to_find": "Pokusili jste se načíst bod na časové ose místnosti, ale nepodařilo se ho najít."
},
"m.audio": {
"audio_player": "Audio přehrávač",
"error_downloading_audio": "Chyba při stahování audia",
"error_processing_audio": "Došlo k chybě při zpracovávání hlasové zprávy",
"error_processing_voice_message": "Chyba při zpracování hlasové zprávy",

View File

@@ -654,6 +654,7 @@
"poll_button_no_perms_description": "You do not have permission to start polls in this room.",
"poll_button_no_perms_title": "Permission Required",
"replying_title": "Replying",
"room_unencrypted": "Messages in this room are not end-to-end encrypted",
"room_upgraded_link": "The conversation continues here.",
"room_upgraded_notice": "This room has been replaced and is no longer active.",
"send_button_title": "Send message",

View File

@@ -654,6 +654,7 @@
"poll_button_no_perms_description": "Vous navez pas la permission de démarrer un sondage dans ce salon.",
"poll_button_no_perms_title": "Autorisation requise",
"replying_title": "Répond",
"room_unencrypted": "Les messages dans ce salon ne sont pas chiffrés de bout en bout",
"room_upgraded_link": "La discussion continue ici.",
"room_upgraded_notice": "Ce salon a été remplacé et nest plus actif.",
"send_button_title": "Envoyer le message",
@@ -1366,6 +1367,10 @@
"name_email_mxid_share_space": "Invitez quelquun grâce à son nom, adresse e-mail, nom dutilisateur (tel que <userId/>) ou <a>partagez cet espace</a>.",
"name_mxid_share_room": "Invitez quelquun à partir de son nom, pseudo (comme <userId/>) ou <a>partagez ce salon</a>.",
"name_mxid_share_space": "Invitez quelquun grâce à son nom, nom dutilisateur (tel que <userId/>) ou <a>partagez cet espace</a>.",
"progress": {
"dont_close": "Ne fermez pas l\"application tant que l'opération est en cours",
"preparing": "Préparation des invitations..."
},
"recents_section": "Conversations récentes",
"room_failed_partial": "Nous avons envoyé les invitations, mais les personnes ci-dessous nont pas pu être invitées à rejoindre <RoomName/>",
"room_failed_partial_title": "Certaines invitations nont pas pu être envoyées",

View File

@@ -654,6 +654,7 @@
"poll_button_no_perms_description": "Du har ikke tillatelse til å starte avstemninger i dette rommet.",
"poll_button_no_perms_title": "Tillatelse kreves",
"replying_title": "Svarer på",
"room_unencrypted": "Meldinger i dette rommet er ikke ende-til-ende krypterte",
"room_upgraded_link": "Samtalen fortsetter her.",
"room_upgraded_notice": "Dette rommet har blitt erstattet og er ikke lenger aktivt.",
"send_button_title": "Send melding",

View File

@@ -13,7 +13,7 @@ import SdkConfig from "../SdkConfig";
import Modal from "../Modal";
import { IntegrationManagerInstance, Kind } from "./IntegrationManagerInstance";
import IntegrationsImpossibleDialog from "../components/views/dialogs/IntegrationsImpossibleDialog";
import IntegrationsDisabledDialog from "../components/views/dialogs/IntegrationsDisabledDialog";
import { IntegrationsDisabledDialog } from "../components/views/dialogs/IntegrationsDisabledDialog";
import WidgetUtils from "../utils/WidgetUtils";
import { MatrixClientPeg } from "../MatrixClientPeg";

View File

@@ -23,14 +23,12 @@ import { type IWidgetApiRequest, type ClientWidgetApi, type IWidgetData } from "
import {
type MatrixRTCSession,
MatrixRTCSessionEvent,
type CallMembership,
MatrixRTCSessionManagerEvents,
} from "matrix-js-sdk/src/matrixrtc";
import type EventEmitter from "events";
import type { IApp } from "../stores/WidgetStore";
import SettingsStore from "../settings/SettingsStore";
import MediaDeviceHandler, { MediaDeviceKindEnum } from "../MediaDeviceHandler";
import { timeout } from "../utils/promise";
import WidgetUtils from "../utils/WidgetUtils";
import { WidgetType } from "../widgets/WidgetType";
@@ -193,18 +191,6 @@ export abstract class Call extends TypedEventEmitter<CallEvent, CallEventHandler
*/
public abstract clean(): Promise<void>;
/**
* Contacts the widget to connect to the call or prompt the user to connect to the call.
* @param {MediaDeviceInfo | null} audioInput The audio input to use, or
* null to start muted.
* @param {MediaDeviceInfo | null} audioInput The video input to use, or
* null to start muted.
*/
protected abstract performConnection(
audioInput: MediaDeviceInfo | null,
videoInput: MediaDeviceInfo | null,
): Promise<void>;
/**
* Contacts the widget to disconnect from the call.
*/
@@ -212,28 +198,10 @@ export abstract class Call extends TypedEventEmitter<CallEvent, CallEventHandler
/**
* Starts the communication between the widget and the call.
* The call then waits for the necessary requirements to actually perform the connection
* or connects right away depending on the call type. (Jitsi, Legacy, ElementCall...)
* It uses the media devices set in MediaDeviceHandler.
* The widget associated with the call must be active
* for this to succeed.
* The widget associated with the call must be active for this to succeed.
* Only call this if the call state is: ConnectionState.Disconnected.
*/
public async start(): Promise<void> {
const { [MediaDeviceKindEnum.AudioInput]: audioInputs, [MediaDeviceKindEnum.VideoInput]: videoInputs } =
(await MediaDeviceHandler.getDevices())!;
let audioInput: MediaDeviceInfo | null = null;
if (!MediaDeviceHandler.startWithAudioMuted) {
const deviceId = MediaDeviceHandler.getAudioInput();
audioInput = audioInputs.find((d) => d.deviceId === deviceId) ?? audioInputs[0] ?? null;
}
let videoInput: MediaDeviceInfo | null = null;
if (!MediaDeviceHandler.startWithVideoMuted) {
const deviceId = MediaDeviceHandler.getVideoInput();
videoInput = videoInputs.find((d) => d.deviceId === deviceId) ?? videoInputs[0] ?? null;
}
const messagingStore = WidgetMessagingStore.instance;
this.messaging = messagingStore.getMessagingForUid(this.widgetUid) ?? null;
if (!this.messaging) {
@@ -254,13 +222,23 @@ export abstract class Call extends TypedEventEmitter<CallEvent, CallEventHandler
throw new Error(`Failed to bind call widget in room ${this.roomId}: ${e}`);
}
}
await this.performConnection(audioInput, videoInput);
}
protected setConnected(): void {
this.room.on(RoomEvent.MyMembership, this.onMyMembership);
window.addEventListener("beforeunload", this.beforeUnload);
this.connectionState = ConnectionState.Connected;
}
/**
* Manually marks the call as disconnected.
*/
protected setDisconnected(): void {
this.room.off(RoomEvent.MyMembership, this.onMyMembership);
window.removeEventListener("beforeunload", this.beforeUnload);
this.connectionState = ConnectionState.Disconnected;
}
/**
* Disconnects the user from the call.
*/
@@ -273,15 +251,6 @@ export abstract class Call extends TypedEventEmitter<CallEvent, CallEventHandler
this.close();
}
/**
* Manually marks the call as disconnected.
*/
public setDisconnected(): void {
this.room.off(RoomEvent.MyMembership, this.onMyMembership);
window.removeEventListener("beforeunload", this.beforeUnload);
this.connectionState = ConnectionState.Disconnected;
}
/**
* Stops further communication with the widget and tells the UI to close.
*/
@@ -467,66 +436,10 @@ export class JitsiCall extends Call {
});
}
protected async performConnection(
audioInput: MediaDeviceInfo | null,
videoInput: MediaDeviceInfo | null,
): Promise<void> {
// Ensure that the messaging doesn't get stopped while we're waiting for responses
const dontStopMessaging = new Promise<void>((resolve, reject) => {
const messagingStore = WidgetMessagingStore.instance;
const listener = (uid: string): void => {
if (uid === this.widgetUid) {
cleanup();
reject(new Error("Messaging stopped"));
}
};
const done = (): void => {
cleanup();
resolve();
};
const cleanup = (): void => {
messagingStore.off(WidgetMessagingStoreEvent.StopMessaging, listener);
this.off(CallEvent.ConnectionState, done);
};
messagingStore.on(WidgetMessagingStoreEvent.StopMessaging, listener);
this.on(CallEvent.ConnectionState, done);
});
// Empirically, it's possible for Jitsi Meet to crash instantly at startup,
// sending a hangup event that races with the rest of this method, so we need
// to add the hangup listener now rather than later
public async start(): Promise<void> {
await super.start();
this.messaging!.on(`action:${ElementWidgetActions.JoinCall}`, this.onJoin);
this.messaging!.on(`action:${ElementWidgetActions.HangupCall}`, this.onHangup);
// Actually perform the join
const response = waitForEvent(
this.messaging!,
`action:${ElementWidgetActions.JoinCall}`,
(ev: CustomEvent<IWidgetApiRequest>) => {
ev.preventDefault();
this.messaging!.transport.reply(ev.detail, {}); // ack
return true;
},
);
const request = this.messaging!.transport.send(ElementWidgetActions.JoinCall, {
audioInput: audioInput?.label ?? null,
videoInput: videoInput?.label ?? null,
});
try {
await Promise.race([Promise.all([request, response]), dontStopMessaging]);
} catch (e) {
// If it timed out, clean up our advance preparations
this.messaging!.off(`action:${ElementWidgetActions.HangupCall}`, this.onHangup);
if (this.messaging!.transport.ready) {
// The messaging still exists, which means Jitsi might still be going in the background
this.messaging!.transport.send(ElementWidgetActions.HangupCall, { force: true });
}
throw new Error(`Failed to join call in room ${this.roomId}: ${e}`);
}
ActiveWidgetStore.instance.on(ActiveWidgetStoreEvent.Dock, this.onDock);
ActiveWidgetStore.instance.on(ActiveWidgetStoreEvent.Undock, this.onUndock);
}
@@ -549,18 +462,17 @@ export class JitsiCall extends Call {
}
}
public setDisconnected(): void {
// During tests this.messaging can be undefined
this.messaging?.off(`action:${ElementWidgetActions.HangupCall}`, this.onHangup);
public close(): void {
this.messaging!.off(`action:${ElementWidgetActions.JoinCall}`, this.onJoin);
this.messaging!.off(`action:${ElementWidgetActions.HangupCall}`, this.onHangup);
ActiveWidgetStore.instance.off(ActiveWidgetStoreEvent.Dock, this.onDock);
ActiveWidgetStore.instance.off(ActiveWidgetStoreEvent.Undock, this.onUndock);
super.setDisconnected();
super.close();
}
public destroy(): void {
this.room.off(RoomStateEvent.Update, this.onRoomState);
this.on(CallEvent.ConnectionState, this.onConnectionState);
this.off(CallEvent.ConnectionState, this.onConnectionState);
if (this.participantsExpirationTimer !== null) {
clearTimeout(this.participantsExpirationTimer);
this.participantsExpirationTimer = null;
@@ -612,27 +524,21 @@ export class JitsiCall extends Call {
await this.messaging!.transport.send(ElementWidgetActions.SpotlightLayout, {});
};
private readonly onJoin = (ev: CustomEvent<IWidgetApiRequest>): void => {
ev.preventDefault();
this.messaging!.transport.reply(ev.detail, {}); // ack
this.setConnected();
};
private readonly onHangup = async (ev: CustomEvent<IWidgetApiRequest>): Promise<void> => {
// If we're already in the middle of a client-initiated disconnection,
// ignore the event
if (this.connectionState === ConnectionState.Disconnecting) return;
ev.preventDefault();
// In case this hangup is caused by Jitsi Meet crashing at startup,
// wait for the connection event in order to avoid racing
if (this.connectionState === ConnectionState.Disconnected) {
await waitForEvent(this, CallEvent.ConnectionState);
}
this.messaging!.transport.reply(ev.detail, {}); // ack
this.setDisconnected();
this.close();
// In video rooms we immediately want to restart the call after hangup
// The lobby will be shown again and it connects to all signals from Jitsi.
if (isVideoRoom(this.room)) {
this.start();
}
if (!isVideoRoom(this.room)) this.close();
};
}
@@ -860,54 +766,38 @@ export class ElementCall extends Call {
ElementCall.createOrGetCallWidget(room.roomId, room.client, skipLobby, isVideoRoom(room));
}
protected async performConnection(
audioInput: MediaDeviceInfo | null,
videoInput: MediaDeviceInfo | null,
): Promise<void> {
public async start(): Promise<void> {
await super.start();
this.messaging!.on(`action:${ElementWidgetActions.JoinCall}`, this.onJoin);
this.messaging!.on(`action:${ElementWidgetActions.HangupCall}`, this.onHangup);
this.messaging!.once(`action:${ElementWidgetActions.Close}`, this.onClose);
this.messaging!.on(`action:${ElementWidgetActions.Close}`, this.onClose);
this.messaging!.on(`action:${ElementWidgetActions.DeviceMute}`, this.onDeviceMute);
// TODO: Watch for a widget action telling us that the join button was clicked, rather than
// relying on the MatrixRTC session state, to set the state to connecting
const session = this.client.matrixRTC.getActiveRoomSession(this.room);
if (session) {
await waitForEvent(
session,
MatrixRTCSessionEvent.MembershipsChanged,
(_, newMemberships: CallMembership[]) =>
newMemberships.some((m) => m.sender === this.client.getUserId()),
false, // allow user to wait as long as they want (no timeout)
);
} else {
await waitForEvent(
this.client.matrixRTC,
MatrixRTCSessionManagerEvents.SessionStarted,
(roomId: string, session: MatrixRTCSession) =>
this.session.callId === session.callId && roomId === this.roomId,
false, // allow user to wait as long as they want (no timeout)
);
}
}
protected async performDisconnection(): Promise<void> {
const response = waitForEvent(
this.messaging!,
`action:${ElementWidgetActions.HangupCall}`,
(ev: CustomEvent<IWidgetApiRequest>) => {
ev.preventDefault();
this.messaging!.transport.reply(ev.detail, {}); // ack
return true;
},
);
const request = this.messaging!.transport.send(ElementWidgetActions.HangupCall, {});
try {
await this.messaging!.transport.send(ElementWidgetActions.HangupCall, {});
await waitForEvent(
this.session,
MatrixRTCSessionEvent.MembershipsChanged,
(_, newMemberships: CallMembership[]) =>
!newMemberships.some((m) => m.sender === this.client.getUserId()),
);
await Promise.all([request, response]);
} catch (e) {
throw new Error(`Failed to hangup call in room ${this.roomId}: ${e}`);
}
}
public setDisconnected(): void {
public close(): void {
this.messaging!.off(`action:${ElementWidgetActions.JoinCall}`, this.onJoin);
this.messaging!.off(`action:${ElementWidgetActions.HangupCall}`, this.onHangup);
this.messaging!.off(`action:${ElementWidgetActions.Close}`, this.onClose);
this.messaging!.off(`action:${ElementWidgetActions.DeviceMute}`, this.onDeviceMute);
super.setDisconnected();
super.close();
}
public destroy(): void {
@@ -954,22 +844,27 @@ export class ElementCall extends Call {
this.messaging!.transport.reply(ev.detail, {}); // ack
};
private readonly onJoin = (ev: CustomEvent<IWidgetApiRequest>): void => {
ev.preventDefault();
this.messaging!.transport.reply(ev.detail, {}); // ack
this.setConnected();
};
private readonly onHangup = async (ev: CustomEvent<IWidgetApiRequest>): Promise<void> => {
// If we're already in the middle of a client-initiated disconnection,
// ignore the event
if (this.connectionState === ConnectionState.Disconnecting) return;
ev.preventDefault();
this.messaging!.transport.reply(ev.detail, {}); // ack
this.setDisconnected();
// In video rooms we immediately want to reconnect after hangup
// This starts the lobby again and connects to all signals from EC.
if (isVideoRoom(this.room)) {
this.start();
}
};
private readonly onClose = async (ev: CustomEvent<IWidgetApiRequest>): Promise<void> => {
ev.preventDefault();
this.messaging!.transport.reply(ev.detail, {}); // ack
// User is done with the call; tell the UI to close it
this.close();
this.setDisconnected(); // Just in case the widget forgot to emit a hangup action (maybe it's in an error state)
this.close(); // User is done with the call; tell the UI to close it
};
public clean(): Promise<void> {

View File

@@ -9,33 +9,31 @@ import { type NavigationApi as INavigationApi } from "@element-hq/element-web-mo
import { navigateToPermalink } from "../utils/permalinks/navigator.ts";
import { parsePermalink } from "../utils/permalinks/Permalinks.ts";
import { getCachedRoomIDForAlias } from "../RoomAliasCache.ts";
import { MatrixClientPeg } from "../MatrixClientPeg.ts";
import dispatcher from "../dispatcher/dispatcher.ts";
import { Action } from "../dispatcher/actions.ts";
import SettingsStore from "../settings/SettingsStore.ts";
import type { ViewRoomPayload } from "../dispatcher/payloads/ViewRoomPayload.ts";
export class NavigationApi implements INavigationApi {
public async toMatrixToLink(link: string, join = false): Promise<void> {
navigateToPermalink(link);
const parts = parsePermalink(link);
if (parts?.roomIdOrAlias && join) {
let roomId: string | undefined = parts.roomIdOrAlias;
if (roomId.startsWith("#")) {
roomId = getCachedRoomIDForAlias(parts.roomIdOrAlias);
if (!roomId) {
// alias resolution failed
const result = await MatrixClientPeg.safeGet().getRoomIdForAlias(parts.roomIdOrAlias);
roomId = result.room_id;
}
}
if (roomId) {
dispatcher.dispatch({
action: Action.JoinRoom,
canAskToJoin: SettingsStore.getValue("feature_ask_to_join"),
roomId,
if (parts?.roomIdOrAlias) {
if (parts.roomIdOrAlias.startsWith("#")) {
dispatcher.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
room_alias: parts.roomIdOrAlias,
via_servers: parts.viaServers ?? undefined,
auto_join: join,
metricsTrigger: undefined,
});
} else {
dispatcher.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
room_id: parts.roomIdOrAlias,
via_servers: parts.viaServers ?? undefined,
auto_join: join,
metricsTrigger: undefined,
});
}
}

View File

@@ -28,13 +28,12 @@ import dispatcher from "../dispatcher/dispatcher";
import { navigateToPermalink } from "../utils/permalinks/navigator";
import { parsePermalink } from "../utils/permalinks/Permalinks";
import { MatrixClientPeg } from "../MatrixClientPeg";
import { getCachedRoomIDForAlias } from "../RoomAliasCache";
import { Action } from "../dispatcher/actions";
import { type OverwriteLoginPayload } from "../dispatcher/payloads/OverwriteLoginPayload";
import { type ActionPayload } from "../dispatcher/payloads";
import SettingsStore from "../settings/SettingsStore";
import WidgetStore, { type IApp } from "../stores/WidgetStore";
import { type Container, WidgetLayoutStore } from "../stores/widgets/WidgetLayoutStore";
import type { ViewRoomPayload } from "../dispatcher/payloads/ViewRoomPayload.ts";
/**
* Glue between the `ModuleApi` interface and the react-sdk. Anticipates one instance
@@ -183,28 +182,22 @@ export class ProxiedModuleApi implements ModuleApi {
navigateToPermalink(uri);
const parts = parsePermalink(uri);
if (parts?.roomIdOrAlias && andJoin) {
let roomId: string | undefined = parts.roomIdOrAlias;
let servers = parts.viaServers;
if (roomId.startsWith("#")) {
roomId = getCachedRoomIDForAlias(parts.roomIdOrAlias);
if (!roomId) {
// alias resolution failed
const result = await MatrixClientPeg.safeGet().getRoomIdForAlias(parts.roomIdOrAlias);
roomId = result.room_id;
if (!servers) servers = result.servers; // use provided servers first, if available
}
}
dispatcher.dispatch({
action: Action.ViewRoom,
room_id: roomId,
via_servers: servers,
});
if (andJoin) {
dispatcher.dispatch({
action: Action.JoinRoom,
canAskToJoin: SettingsStore.getValue("feature_ask_to_join"),
if (parts?.roomIdOrAlias) {
if (parts.roomIdOrAlias.startsWith("#")) {
dispatcher.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
room_alias: parts.roomIdOrAlias,
via_servers: parts.viaServers ?? undefined,
auto_join: andJoin ?? false,
metricsTrigger: undefined,
});
} else {
dispatcher.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
room_id: parts.roomIdOrAlias,
via_servers: parts.viaServers ?? undefined,
auto_join: andJoin ?? false,
metricsTrigger: undefined,
});
}
}

View File

@@ -8,7 +8,7 @@ Please see LICENSE files in the repository root for full details.
*/
import React, { type ReactNode } from "react";
import { UNSTABLE_MSC4133_EXTENDED_PROFILES } from "matrix-js-sdk/src/matrix";
import { STABLE_MSC4133_EXTENDED_PROFILES, UNSTABLE_MSC4133_EXTENDED_PROFILES } from "matrix-js-sdk/src/matrix";
import { type MediaPreviewConfig } from "../@types/media_preview.ts";
// Import i18n.tsx instead of languageHandler to avoid circular deps
@@ -844,7 +844,7 @@ export const SETTINGS: Settings = {
controller: new ServerSupportUnstableFeatureController(
"userTimezonePublish",
defaultWatchManager,
[[UNSTABLE_MSC4133_EXTENDED_PROFILES]],
[[UNSTABLE_MSC4133_EXTENDED_PROFILES], [STABLE_MSC4133_EXTENDED_PROFILES]],
undefined,
_td("labs|extended_profiles_msc_support"),
),

View File

@@ -340,7 +340,7 @@ export default class SettingsStore {
}
/**
* Retrieves the reason a setting is disabled if one is assigned.
* Retrieves the internationalised reason a setting is disabled if one is assigned.
* If a setting is not disabled, or no reason is given by the `SettingController`,
* this will return undefined.
* @param {string} settingName The setting to look up.

View File

@@ -11,6 +11,7 @@ import MatrixClientBackedController from "./MatrixClientBackedController";
import { type WatchManager } from "../WatchManager";
import SettingsStore from "../SettingsStore";
import { type SettingKey } from "../Settings.tsx";
import { _t, type TranslationKey } from "../../languageHandler.tsx";
/**
* Disables a given setting if the server unstable feature it requires is not supported
@@ -33,7 +34,7 @@ export default class ServerSupportUnstableFeatureController extends MatrixClient
private readonly watchers: WatchManager,
private readonly unstableFeatureGroups: string[][],
private readonly stableVersion?: string,
private readonly disabledMessage?: string,
private readonly disabledMessage?: TranslationKey,
private readonly forcedValue: any = false,
) {
super();
@@ -96,7 +97,7 @@ export default class ServerSupportUnstableFeatureController extends MatrixClient
public get settingDisabled(): boolean | string {
if (this.disabled) {
return this.disabledMessage ?? true;
return this.disabledMessage ? _t(this.disabledMessage) : true;
}
return false;
}

View File

@@ -143,7 +143,7 @@ export class BreadcrumbsStore extends AsyncStoreWithClient<IState> {
// If the room is upgraded, use that room instead. We'll also splice out
// any children of the room.
const history = this.matrixClient?.getRoomUpgradeHistory(room.roomId, false, msc3946ProcessDynamicPredecessor);
const history = this.matrixClient?.getRoomUpgradeHistory(room.roomId, true, msc3946ProcessDynamicPredecessor);
if (history && history.length > 1) {
room = history[history.length - 1]; // Last room is most recent in history

View File

@@ -26,7 +26,7 @@ import { type MatrixDispatcher } from "../dispatcher/dispatcher";
import { MatrixClientPeg } from "../MatrixClientPeg";
import Modal from "../Modal";
import { _t } from "../languageHandler";
import { getCachedRoomIDForAlias, storeRoomAliasInCache } from "../RoomAliasCache";
import { getCachedRoomIdForAlias, storeRoomAliasInCache } from "../RoomAliasCache";
import { Action } from "../dispatcher/actions";
import { retry } from "../utils/promise";
import { TimelineRenderingType } from "../contexts/RoomContext";
@@ -438,6 +438,7 @@ export class RoomViewStore extends EventEmitter {
action: Action.JoinRoom,
roomId: payload.room_id,
metricsTrigger: payload.metricsTrigger as JoinRoomPayload["metricsTrigger"],
canAskToJoin: SettingsStore.getValue("feature_ask_to_join"),
});
}
@@ -445,10 +446,16 @@ export class RoomViewStore extends EventEmitter {
await setMarkedUnreadState(room, MatrixClientPeg.safeGet(), false);
}
} else if (payload.room_alias) {
let roomId: string;
let viaServers: string[] | undefined;
// Try the room alias to room ID navigation cache first to avoid
// blocking room navigation on the homeserver.
let roomId = getCachedRoomIDForAlias(payload.room_alias);
if (!roomId) {
const cachedResult = getCachedRoomIdForAlias(payload.room_alias);
if (cachedResult) {
roomId = cachedResult.roomId;
viaServers = cachedResult.viaServers;
} else {
// Room alias cache miss, so let's ask the homeserver. Resolve the alias
// and then do a second dispatch with the room ID acquired.
this.setState({
@@ -467,8 +474,9 @@ export class RoomViewStore extends EventEmitter {
});
try {
const result = await MatrixClientPeg.safeGet().getRoomIdForAlias(payload.room_alias);
storeRoomAliasInCache(payload.room_alias, result.room_id);
storeRoomAliasInCache(payload.room_alias, result.room_id, result.servers);
roomId = result.room_id;
viaServers = result.servers;
} catch (err) {
logger.error("RVS failed to get room id for alias: ", err);
this.dis?.dispatch<ViewRoomErrorPayload>({
@@ -485,6 +493,7 @@ export class RoomViewStore extends EventEmitter {
this.dis?.dispatch({
...payload,
room_id: roomId,
via_servers: viaServers,
});
}
}
@@ -509,12 +518,13 @@ export class RoomViewStore extends EventEmitter {
joining: true,
});
// take a copy of roomAlias & roomId as they may change by the time the join is complete
const { roomAlias, roomId } = this.state;
const address = payload.roomId || roomAlias || roomId!;
// take a copy of roomAlias, roomId & viaServers as they may change by the time the join is complete
const { roomAlias, roomId = payload.roomId, viaServers = [] } = this.state;
// prefer the room alias if we have one as it allows joining over federation even with no viaServers
const address = roomAlias || roomId!;
const joinOpts: IJoinRoomOpts = {
viaServers: this.state.viaServers || [],
viaServers,
...(payload.opts ?? {}),
};
if (SettingsStore.getValue("feature_share_history_on_invite")) {
@@ -547,7 +557,7 @@ export class RoomViewStore extends EventEmitter {
canAskToJoin: payload.canAskToJoin,
});
if (payload.canAskToJoin) {
if (payload.canAskToJoin && err instanceof MatrixError && err.httpStatus === 403) {
this.dis?.dispatch({ action: Action.PromptAskToJoin });
}
}

View File

@@ -8,7 +8,7 @@ Please see LICENSE files in the repository root for full details.
import { logger } from "matrix-js-sdk/src/logger";
import { EventType, KnownMembership } from "matrix-js-sdk/src/matrix";
import type { EmptyObject, Room, RoomState } from "matrix-js-sdk/src/matrix";
import type { EmptyObject, Room } from "matrix-js-sdk/src/matrix";
import type { MatrixDispatcher } from "../../dispatcher/dispatcher";
import type { ActionPayload } from "../../dispatcher/payloads";
import type { FilterKey } from "./skip-list/filters";
@@ -250,12 +250,15 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient<EmptyObject> {
// If we're joining an upgraded room, we'll want to make sure we don't proliferate
// the dead room in the list.
if (oldMembership !== EffectiveMembership.Join && newMembership === EffectiveMembership.Join) {
const roomState: RoomState = payload.room.currentState;
const predecessor = roomState.findPredecessor(this.msc3946ProcessDynamicPredecessor);
if (predecessor) {
const prevRoom = this.matrixClient?.getRoom(predecessor.roomId);
if (prevRoom) this.roomSkipList.removeRoom(prevRoom);
else logger.warn(`Unable to find predecessor room with id ${predecessor.roomId}`);
const room: Room = payload.room;
const roomUpgradeHistory = room.client.getRoomUpgradeHistory(
room.roomId,
true,
this.msc3946ProcessDynamicPredecessor,
);
const predecessors = roomUpgradeHistory.slice(0, roomUpgradeHistory.indexOf(room));
for (const predecessor of predecessors) {
this.roomSkipList.removeRoom(predecessor);
}
}

View File

@@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import { type MatrixClient, type Room, type RoomState, EventType, type EmptyObject } from "matrix-js-sdk/src/matrix";
import { type MatrixClient, type Room, EventType, type EmptyObject } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import SettingsStore from "../../settings/SettingsStore";
@@ -308,24 +308,22 @@ export class RoomListStoreClass extends AsyncStoreWithClient<EmptyObject> implem
const oldMembership = getEffectiveMembership(membershipPayload.oldMembership);
const newMembership = getEffectiveMembershipTag(membershipPayload.room, membershipPayload.membership);
if (oldMembership !== EffectiveMembership.Join && newMembership === EffectiveMembership.Join) {
// If we're joining an upgraded room, we'll want to make sure we don't proliferate
// the dead room in the list.
const roomState: RoomState = membershipPayload.room.currentState;
const predecessor = roomState.findPredecessor(this.msc3946ProcessDynamicPredecessor);
if (predecessor) {
const prevRoom = this.matrixClient?.getRoom(predecessor.roomId);
if (prevRoom) {
const isSticky = this.algorithm.stickyRoom === prevRoom;
if (isSticky) {
this.algorithm.setStickyRoom(null);
}
// Note: we hit the algorithm instead of our handleRoomUpdate() function to
// avoid redundant updates.
this.algorithm.handleRoomUpdate(prevRoom, RoomUpdateCause.RoomRemoved);
} else {
logger.warn(`Unable to find predecessor room with id ${predecessor.roomId}`);
// If we're joining an upgraded room, we'll want to make sure we don't proliferate the dead room in the list.
const room: Room = membershipPayload.room;
const roomUpgradeHistory = room.client.getRoomUpgradeHistory(
room.roomId,
true,
this.msc3946ProcessDynamicPredecessor,
);
const predecessors = roomUpgradeHistory.slice(0, roomUpgradeHistory.indexOf(room));
for (const predecessor of predecessors) {
const isSticky = this.algorithm.stickyRoom === predecessor;
if (isSticky) {
this.algorithm.setStickyRoom(null);
}
// Note: we hit the algorithm instead of our handleRoomUpdate() function to
// avoid redundant updates.
this.algorithm.handleRoomUpdate(predecessor, RoomUpdateCause.RoomRemoved);
}
await this.handleRoomUpdate(membershipPayload.room, RoomUpdateCause.NewRoom);

View File

@@ -49,7 +49,7 @@ import {
UPDATE_SUGGESTED_ROOMS,
UPDATE_TOP_LEVEL_SPACES,
} from ".";
import { getCachedRoomIDForAlias } from "../../RoomAliasCache";
import { getCachedRoomIdForAlias } from "../../RoomAliasCache";
import { EffectiveMembership, getEffectiveMembership } from "../../utils/membership";
import {
flattenSpaceHierarchyWithCache,
@@ -1249,7 +1249,8 @@ export class SpaceStoreClass extends AsyncStoreWithClient<EmptyObject> {
let roomId = payload.room_id;
if (payload.room_alias && !roomId) {
roomId = getCachedRoomIDForAlias(payload.room_alias);
const result = getCachedRoomIdForAlias(payload.room_alias);
if (result) roomId = result.roomId;
}
if (!roomId) return; // we'll get re-fired with the room ID shortly

View File

@@ -117,7 +117,7 @@ export class MediaEventHelper implements IDestroyable {
/**
* Determine if the media event in question supports being hidden in the timeline.
* @param event Any matrix event.
* @returns `true` if the media can be hidden, otherwise false.
* @returns `true` if the media can be hidden, otherwise `false`.
*/
public static canHide(event: MatrixEvent): boolean {
if (!event) return false;

View File

@@ -40,7 +40,7 @@ export async function leaveRoomBehaviour(
let leavingAllVersions = true;
const history = matrixClient.getRoomUpgradeHistory(
roomId,
false,
true,
SettingsStore.getValue("feature_dynamic_room_predecessors"),
);
if (history && history.length > 0) {

View File

@@ -18,6 +18,7 @@ import {
} from "matrix-js-sdk/src/matrix";
import React from "react";
import { logger } from "matrix-js-sdk/src/logger";
import { uniqueId } from "lodash";
import BasePlatform, { UpdateCheckStatus, type UpdateStatus } from "../../BasePlatform";
import type BaseEventIndexManager from "../../indexing/BaseEventIndexManager";
@@ -43,6 +44,7 @@ import { SeshatIndexManager } from "./SeshatIndexManager";
import { IPCManager } from "./IPCManager";
import { _t } from "../../languageHandler";
import { BadgeOverlayRenderer } from "../../favicon";
import GenericToast from "../../components/views/toasts/GenericToast.tsx";
interface SquirrelUpdate {
releaseNotes: string;
@@ -95,6 +97,7 @@ export default class ElectronPlatform extends BasePlatform {
private badgeOverlayRenderer?: BadgeOverlayRenderer;
private config!: IConfigOptions;
private supportedSettings?: Record<string, boolean>;
private clientStartedPromiseWithResolvers = Promise.withResolvers<void>();
public constructor() {
super();
@@ -182,6 +185,27 @@ export default class ElectronPlatform extends BasePlatform {
await this.ipc.call("callDisplayMediaCallback", source ?? { id: "", name: "", thumbnailURL: "" });
});
this.electron.on("showToast", async (ev, { title, description, priority = 40 }) => {
await this.clientStartedPromiseWithResolvers.promise;
const key = uniqueId("electron_showToast_");
const onPrimaryClick = (): void => {
ToastStore.sharedInstance().dismissToast(key);
};
ToastStore.sharedInstance().addOrReplaceToast({
key,
title,
props: {
description,
primaryLabel: _t("action|dismiss"),
onPrimaryClick,
},
component: GenericToast,
priority,
});
});
BreadcrumbsStore.instance.on(UPDATE_EVENT, this.onBreadcrumbsUpdate);
this.initialised = this.initialise();
@@ -193,6 +217,10 @@ export default class ElectronPlatform extends BasePlatform {
if (["call_state"].includes(payload.action)) {
this.electron.send("app_onAction", payload);
}
if (payload.action === "client_started") {
this.clientStartedPromiseWithResolvers.resolve();
}
}
private async initialise(): Promise<void> {

View File

@@ -79,7 +79,6 @@ export class MockedCall extends Call {
// No action needed for any of the following methods since this is just a mock
public async clean(): Promise<void> {}
// Public to allow spying
public async performConnection(): Promise<void> {}
public async performDisconnection(): Promise<void> {}
public destroy() {

View File

@@ -585,4 +585,42 @@ describe("LegacyCallHandler without third party protocols", () => {
expect(mockAudioBufferSourceNode.start).not.toHaveBeenCalled();
});
});
describe("sidebar state", () => {
const roomId = "test-room-id";
it("should default to showing sidebar", () => {
const call = new MatrixCall({
client: MatrixClientPeg.safeGet(),
roomId,
});
const cli = MatrixClientPeg.safeGet();
cli.emit(CallEventHandlerEvent.Incoming, call);
expect(callHandler.isCallSidebarShown(call.callId)).toEqual(true);
});
it("should remember sidebar state per call", () => {
const call = new MatrixCall({
client: MatrixClientPeg.safeGet(),
roomId,
});
const cli = MatrixClientPeg.safeGet();
cli.emit(CallEventHandlerEvent.Incoming, call);
expect(callHandler.isCallSidebarShown(call.callId)).toEqual(true);
callHandler.setCallSidebarShown(call.callId, false);
expect(callHandler.isCallSidebarShown(call.callId)).toEqual(false);
call.emit(CallEvent.Hangup, call);
const call2 = new MatrixCall({
client: MatrixClientPeg.safeGet(),
roomId,
});
cli.emit(CallEventHandlerEvent.Incoming, call2);
expect(callHandler.isCallSidebarShown(call2.callId)).toEqual(true);
});
});
});

View File

@@ -7,6 +7,7 @@ exports[`dialogTermsInteractionCallback should render a dialog with the expected
class=""
data-focus-lock-disabled="false"
role="dialog"
tabindex="-1"
>
<div
class="mx_Dialog_header"

View File

@@ -12,6 +12,7 @@ exports[`<NewRecoveryMethodDialog /> when key backup is disabled 1`] = `
class="mx_KeyBackupFailedDialog mx_Dialog_fixedWidth"
data-focus-lock-disabled="false"
role="dialog"
tabindex="-1"
>
<div
class="mx_Dialog_header"
@@ -83,6 +84,7 @@ exports[`<NewRecoveryMethodDialog /> when key backup is enabled 1`] = `
class="mx_KeyBackupFailedDialog mx_Dialog_fixedWidth"
data-focus-lock-disabled="false"
role="dialog"
tabindex="-1"
>
<div
class="mx_Dialog_header"

View File

@@ -388,6 +388,7 @@ exports[`<MatrixChat /> with an existing session onAction() room actions leave_r
class="mx_QuestionDialog mx_Dialog_fixedWidth"
data-focus-lock-disabled="false"
role="dialog"
tabindex="-1"
>
<div
class="mx_Dialog_header"
@@ -444,6 +445,7 @@ exports[`<MatrixChat /> with an existing session onAction() room actions leave_r
class="mx_QuestionDialog mx_Dialog_fixedWidth"
data-focus-lock-disabled="false"
role="dialog"
tabindex="-1"
>
<div
class="mx_Dialog_header"

View File

@@ -1,225 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`RoomView for a local room in state CREATING should match the snapshot 1`] = `
<div>
<div
class="mx_RoomView mx_RoomView--local"
>
<header
class="flex mx_RoomHeader light-panel"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-3x); --mx-flex-wrap: nowrap;"
>
<button
aria-label="Open room settings"
aria-live="off"
class="_avatar_1qbcf_8 mx_BaseAvatar _avatar-imageless_1qbcf_52"
data-color="3"
data-testid="avatar-img"
data-type="round"
role="button"
style="--cpd-avatar-size: 40px;"
tabindex="-1"
>
u
</button>
<button
aria-label="Room info"
class="mx_RoomHeader_infoWrapper"
tabindex="0"
>
<div
class="mx_RoomHeader_info box-flex"
style="--mx-box-flex: 1;"
>
<div
aria-level="1"
class="_typography_6v6n8_153 _font-body-lg-semibold_6v6n8_74 mx_RoomHeader_heading"
dir="auto"
role="heading"
>
<span
class="mx_RoomHeader_truncated mx_lineClamp"
>
@user:example.com
</span>
</div>
</div>
</button>
<button
aria-disabled="false"
aria-label="Video call"
class="_icon-button_1pz9o_8"
data-kind="primary"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="0"
>
<div
class="_indicator-icon_zr2a0_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
aria-labelledby="«rg4»"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 4h10a2 2 0 0 1 2 2v4.286l3.35-2.871a1 1 0 0 1 1.65.76v7.65a1 1 0 0 1-1.65.76L18 13.715V18a2 2 0 0 1-2 2H6a4 4 0 0 1-4-4V8a4 4 0 0 1 4-4"
/>
</svg>
</div>
</button>
<button
aria-disabled="false"
aria-label="Voice call"
aria-labelledby="«rg9»"
class="_icon-button_1pz9o_8"
data-kind="primary"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="0"
>
<div
class="_indicator-icon_zr2a0_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m20.958 16.374.039 3.527q0 .427-.33.756-.33.33-.756.33a16 16 0 0 1-6.57-1.105 16.2 16.2 0 0 1-5.563-3.663 16.1 16.1 0 0 1-3.653-5.573 16.3 16.3 0 0 1-1.115-6.56q0-.427.33-.757T4.095 3l3.528.039a1.07 1.07 0 0 1 1.085.93l.543 3.954q.039.271-.039.504a1.1 1.1 0 0 1-.271.426l-1.64 1.64q.505 1.008 1.154 1.909c.433.6 1.444 1.696 1.444 1.696s1.095 1.01 1.696 1.444q.9.65 1.909 1.153l1.64-1.64q.193-.193.426-.27t.504-.04l3.954.543q.406.059.668.359t.262.727"
/>
</svg>
</div>
</button>
<button
aria-label="Threads"
aria-labelledby="«rge»"
class="_icon-button_1pz9o_8"
data-kind="primary"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="0"
>
<div
class="_indicator-icon_zr2a0_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
class=""
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M4 3h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H6l-2.293 2.293c-.63.63-1.707.184-1.707-.707V5a2 2 0 0 1 2-2m3 7h10q.424 0 .712-.287A.97.97 0 0 0 18 9a.97.97 0 0 0-.288-.713A.97.97 0 0 0 17 8H7a.97.97 0 0 0-.713.287A.97.97 0 0 0 6 9q0 .424.287.713Q6.576 10 7 10m0 4h6q.424 0 .713-.287A.97.97 0 0 0 14 13a.97.97 0 0 0-.287-.713A.97.97 0 0 0 13 12H7a.97.97 0 0 0-.713.287A.97.97 0 0 0 6 13q0 .424.287.713Q6.576 14 7 14"
/>
</svg>
</div>
</button>
<button
aria-label="Room info"
aria-labelledby="«rgj»"
class="_icon-button_1pz9o_8"
data-kind="primary"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="0"
>
<div
class="_indicator-icon_zr2a0_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
class=""
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 17q.424 0 .713-.288A.97.97 0 0 0 13 16v-4a.97.97 0 0 0-.287-.713A.97.97 0 0 0 12 11a.97.97 0 0 0-.713.287A.97.97 0 0 0 11 12v4q0 .424.287.712.288.288.713.288m0-8q.424 0 .713-.287A.97.97 0 0 0 13 8a.97.97 0 0 0-.287-.713A.97.97 0 0 0 12 7a.97.97 0 0 0-.713.287A.97.97 0 0 0 11 8q0 .424.287.713Q11.576 9 12 9m0 13a9.7 9.7 0 0 1-3.9-.788 10.1 10.1 0 0 1-3.175-2.137q-1.35-1.35-2.137-3.175A9.7 9.7 0 0 1 2 12q0-2.075.788-3.9a10.1 10.1 0 0 1 2.137-3.175q1.35-1.35 3.175-2.137A9.7 9.7 0 0 1 12 2q2.075 0 3.9.788a10.1 10.1 0 0 1 3.175 2.137q1.35 1.35 2.137 3.175A9.7 9.7 0 0 1 22 12a9.7 9.7 0 0 1-.788 3.9 10.1 10.1 0 0 1-2.137 3.175q-1.35 1.35-3.175 2.137A9.7 9.7 0 0 1 12 22"
/>
</svg>
</div>
</button>
<div
class="_typography_6v6n8_153 _font-body-sm-medium_6v6n8_41"
>
<div
aria-label="2 members"
aria-labelledby="«rgo»"
class="mx_AccessibleButton mx_FacePile"
role="button"
tabindex="0"
>
<div
class="_stacked-avatars_1qbcf_102"
>
<span
class="_avatar_1qbcf_8 mx_BaseAvatar _avatar-imageless_1qbcf_52"
data-color="2"
data-testid="avatar-img"
data-type="round"
role="presentation"
style="--cpd-avatar-size: 20px;"
>
u
</span>
<span
class="_avatar_1qbcf_8 mx_BaseAvatar _avatar-imageless_1qbcf_52"
data-color="3"
data-testid="avatar-img"
data-type="round"
role="presentation"
style="--cpd-avatar-size: 20px;"
>
u
</span>
</div>
2
</div>
</div>
</header>
<div
class="mx_RoomView_body"
>
<div
class="mx_LargeLoader"
>
<div
class="mx_Spinner"
>
<div
aria-label="Loading…"
class="mx_Spinner_icon"
data-testid="spinner"
role="progressbar"
style="width: 45px; height: 45px;"
/>
</div>
<div
class="mx_LargeLoader_text"
>
We're creating a room with @user:example.com
</div>
</div>
</div>
</div>
</div>
`;
exports[`RoomView for a local room in state ERROR should match the snapshot 1`] = `
<div>
<div
class="mx_RoomView mx_RoomView--local"
@@ -410,6 +191,225 @@ exports[`RoomView for a local room in state ERROR should match the snapshot 1`]
</div>
</div>
</header>
<div
class="mx_RoomView_body"
>
<div
class="mx_LargeLoader"
>
<div
class="mx_Spinner"
>
<div
aria-label="Loading…"
class="mx_Spinner_icon"
data-testid="spinner"
role="progressbar"
style="width: 45px; height: 45px;"
/>
</div>
<div
class="mx_LargeLoader_text"
>
We're creating a room with @user:example.com
</div>
</div>
</div>
</div>
</div>
`;
exports[`RoomView for a local room in state ERROR should match the snapshot 1`] = `
<div>
<div
class="mx_RoomView mx_RoomView--local"
>
<header
class="flex mx_RoomHeader light-panel"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-3x); --mx-flex-wrap: nowrap;"
>
<button
aria-label="Open room settings"
aria-live="off"
class="_avatar_1qbcf_8 mx_BaseAvatar _avatar-imageless_1qbcf_52"
data-color="3"
data-testid="avatar-img"
data-type="round"
role="button"
style="--cpd-avatar-size: 40px;"
tabindex="-1"
>
u
</button>
<button
aria-label="Room info"
class="mx_RoomHeader_infoWrapper"
tabindex="0"
>
<div
class="mx_RoomHeader_info box-flex"
style="--mx-box-flex: 1;"
>
<div
aria-level="1"
class="_typography_6v6n8_153 _font-body-lg-semibold_6v6n8_74 mx_RoomHeader_heading"
dir="auto"
role="heading"
>
<span
class="mx_RoomHeader_truncated mx_lineClamp"
>
@user:example.com
</span>
</div>
</div>
</button>
<button
aria-disabled="false"
aria-label="Video call"
class="_icon-button_1pz9o_8"
data-kind="primary"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="0"
>
<div
class="_indicator-icon_zr2a0_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
aria-labelledby="«ri0»"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 4h10a2 2 0 0 1 2 2v4.286l3.35-2.871a1 1 0 0 1 1.65.76v7.65a1 1 0 0 1-1.65.76L18 13.715V18a2 2 0 0 1-2 2H6a4 4 0 0 1-4-4V8a4 4 0 0 1 4-4"
/>
</svg>
</div>
</button>
<button
aria-disabled="false"
aria-label="Voice call"
aria-labelledby="«ri5»"
class="_icon-button_1pz9o_8"
data-kind="primary"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="0"
>
<div
class="_indicator-icon_zr2a0_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m20.958 16.374.039 3.527q0 .427-.33.756-.33.33-.756.33a16 16 0 0 1-6.57-1.105 16.2 16.2 0 0 1-5.563-3.663 16.1 16.1 0 0 1-3.653-5.573 16.3 16.3 0 0 1-1.115-6.56q0-.427.33-.757T4.095 3l3.528.039a1.07 1.07 0 0 1 1.085.93l.543 3.954q.039.271-.039.504a1.1 1.1 0 0 1-.271.426l-1.64 1.64q.505 1.008 1.154 1.909c.433.6 1.444 1.696 1.444 1.696s1.095 1.01 1.696 1.444q.9.65 1.909 1.153l1.64-1.64q.193-.193.426-.27t.504-.04l3.954.543q.406.059.668.359t.262.727"
/>
</svg>
</div>
</button>
<button
aria-label="Threads"
aria-labelledby="«ria»"
class="_icon-button_1pz9o_8"
data-kind="primary"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="0"
>
<div
class="_indicator-icon_zr2a0_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
class=""
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M4 3h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H6l-2.293 2.293c-.63.63-1.707.184-1.707-.707V5a2 2 0 0 1 2-2m3 7h10q.424 0 .712-.287A.97.97 0 0 0 18 9a.97.97 0 0 0-.288-.713A.97.97 0 0 0 17 8H7a.97.97 0 0 0-.713.287A.97.97 0 0 0 6 9q0 .424.287.713Q6.576 10 7 10m0 4h6q.424 0 .713-.287A.97.97 0 0 0 14 13a.97.97 0 0 0-.287-.713A.97.97 0 0 0 13 12H7a.97.97 0 0 0-.713.287A.97.97 0 0 0 6 13q0 .424.287.713Q6.576 14 7 14"
/>
</svg>
</div>
</button>
<button
aria-label="Room info"
aria-labelledby="«rif»"
class="_icon-button_1pz9o_8"
data-kind="primary"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="0"
>
<div
class="_indicator-icon_zr2a0_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
class=""
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 17q.424 0 .713-.288A.97.97 0 0 0 13 16v-4a.97.97 0 0 0-.287-.713A.97.97 0 0 0 12 11a.97.97 0 0 0-.713.287A.97.97 0 0 0 11 12v4q0 .424.287.712.288.288.713.288m0-8q.424 0 .713-.287A.97.97 0 0 0 13 8a.97.97 0 0 0-.287-.713A.97.97 0 0 0 12 7a.97.97 0 0 0-.713.287A.97.97 0 0 0 11 8q0 .424.287.713Q11.576 9 12 9m0 13a9.7 9.7 0 0 1-3.9-.788 10.1 10.1 0 0 1-3.175-2.137q-1.35-1.35-2.137-3.175A9.7 9.7 0 0 1 2 12q0-2.075.788-3.9a10.1 10.1 0 0 1 2.137-3.175q1.35-1.35 3.175-2.137A9.7 9.7 0 0 1 12 2q2.075 0 3.9.788a10.1 10.1 0 0 1 3.175 2.137q1.35 1.35 2.137 3.175A9.7 9.7 0 0 1 22 12a9.7 9.7 0 0 1-.788 3.9 10.1 10.1 0 0 1-2.137 3.175q-1.35 1.35-3.175 2.137A9.7 9.7 0 0 1 12 22"
/>
</svg>
</div>
</button>
<div
class="_typography_6v6n8_153 _font-body-sm-medium_6v6n8_41"
>
<div
aria-label="2 members"
aria-labelledby="«rik»"
class="mx_AccessibleButton mx_FacePile"
role="button"
tabindex="0"
>
<div
class="_stacked-avatars_1qbcf_102"
>
<span
class="_avatar_1qbcf_8 mx_BaseAvatar _avatar-imageless_1qbcf_52"
data-color="2"
data-testid="avatar-img"
data-type="round"
role="presentation"
style="--cpd-avatar-size: 20px;"
>
u
</span>
<span
class="_avatar_1qbcf_8 mx_BaseAvatar _avatar-imageless_1qbcf_52"
data-color="3"
data-testid="avatar-img"
data-type="round"
role="presentation"
style="--cpd-avatar-size: 20px;"
>
u
</span>
</div>
2
</div>
</div>
</header>
<main
aria-label="Room content"
class="mx_RoomView_body"
@@ -583,7 +583,7 @@ exports[`RoomView for a local room in state NEW should match the snapshot 1`] =
style="--cpd-icon-button-size: 100%;"
>
<svg
aria-labelledby="«rbo»"
aria-labelledby="«rca»"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
@@ -599,7 +599,7 @@ exports[`RoomView for a local room in state NEW should match the snapshot 1`] =
<button
aria-disabled="false"
aria-label="Voice call"
aria-labelledby="«rbt»"
aria-labelledby="«rcf»"
class="_icon-button_1pz9o_8"
data-kind="primary"
role="button"
@@ -625,7 +625,7 @@ exports[`RoomView for a local room in state NEW should match the snapshot 1`] =
</button>
<button
aria-label="Threads"
aria-labelledby="«rc2»"
aria-labelledby="«rck»"
class="_icon-button_1pz9o_8"
data-kind="primary"
role="button"
@@ -652,7 +652,7 @@ exports[`RoomView for a local room in state NEW should match the snapshot 1`] =
</button>
<button
aria-label="Room info"
aria-labelledby="«rc7»"
aria-labelledby="«rcp»"
class="_icon-button_1pz9o_8"
data-kind="primary"
role="button"
@@ -682,7 +682,7 @@ exports[`RoomView for a local room in state NEW should match the snapshot 1`] =
>
<div
aria-label="2 members"
aria-labelledby="«rcc»"
aria-labelledby="«rcu»"
class="mx_AccessibleButton mx_FacePile"
role="button"
tabindex="0"
@@ -800,6 +800,8 @@ exports[`RoomView for a local room in state NEW should match the snapshot 1`] =
class="mx_MessageComposer_e2eIconWrapper"
>
<svg
aria-label="Messages in this room are not end-to-end encrypted"
aria-labelledby="«rd7»"
class="mx_E2EIcon mx_MessageComposer_e2eIcon"
color="var(--cpd-color-icon-info-primary)"
fill="currentColor"
@@ -982,7 +984,7 @@ exports[`RoomView for a local room in state NEW that is encrypted should match t
style="--cpd-icon-button-size: 100%;"
>
<svg
aria-labelledby="«rdu»"
aria-labelledby="«rem»"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
@@ -998,7 +1000,7 @@ exports[`RoomView for a local room in state NEW that is encrypted should match t
<button
aria-disabled="false"
aria-label="Voice call"
aria-labelledby="«re3»"
aria-labelledby="«rer»"
class="_icon-button_1pz9o_8"
data-kind="primary"
role="button"
@@ -1024,7 +1026,7 @@ exports[`RoomView for a local room in state NEW that is encrypted should match t
</button>
<button
aria-label="Threads"
aria-labelledby="«re8»"
aria-labelledby="«rf0»"
class="_icon-button_1pz9o_8"
data-kind="primary"
role="button"
@@ -1051,7 +1053,7 @@ exports[`RoomView for a local room in state NEW that is encrypted should match t
</button>
<button
aria-label="Room info"
aria-labelledby="«red»"
aria-labelledby="«rf5»"
class="_icon-button_1pz9o_8"
data-kind="primary"
role="button"
@@ -1081,7 +1083,7 @@ exports[`RoomView for a local room in state NEW that is encrypted should match t
>
<div
aria-label="2 members"
aria-labelledby="«rei»"
aria-labelledby="«rfa»"
class="mx_AccessibleButton mx_FacePile"
role="button"
tabindex="0"
@@ -1194,6 +1196,8 @@ exports[`RoomView for a local room in state NEW that is encrypted should match t
class="mx_MessageComposer_e2eIconWrapper"
>
<svg
aria-label="Messages in this room are not end-to-end encrypted"
aria-labelledby="«rfj»"
class="mx_E2EIcon mx_MessageComposer_e2eIcon"
color="var(--cpd-color-icon-info-primary)"
fill="currentColor"
@@ -1462,7 +1466,7 @@ exports[`RoomView should not display the timeline when the room encryption is lo
style="--cpd-icon-button-size: 100%;"
>
<svg
aria-labelledby="«r2c»"
aria-labelledby="«r2i»"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
@@ -1478,7 +1482,7 @@ exports[`RoomView should not display the timeline when the room encryption is lo
<button
aria-disabled="false"
aria-label="Voice call"
aria-labelledby="«r2h»"
aria-labelledby="«r2n»"
class="_icon-button_1pz9o_8"
data-kind="primary"
role="button"
@@ -1504,7 +1508,7 @@ exports[`RoomView should not display the timeline when the room encryption is lo
</button>
<button
aria-label="Threads"
aria-labelledby="«r2m»"
aria-labelledby="«r2s»"
class="_icon-button_1pz9o_8"
data-kind="primary"
role="button"
@@ -1531,7 +1535,7 @@ exports[`RoomView should not display the timeline when the room encryption is lo
</button>
<button
aria-label="Room info"
aria-labelledby="«r2r»"
aria-labelledby="«r31»"
class="_icon-button_1pz9o_8"
data-kind="primary"
role="button"
@@ -1561,7 +1565,7 @@ exports[`RoomView should not display the timeline when the room encryption is lo
>
<div
aria-label="0 members"
aria-labelledby="«r30»"
aria-labelledby="«r36»"
class="mx_AccessibleButton mx_FacePile"
role="button"
tabindex="0"
@@ -1674,7 +1678,7 @@ exports[`RoomView should not display the timeline when the room encryption is lo
style="--cpd-icon-button-size: 100%;"
>
<svg
aria-labelledby="«r2c»"
aria-labelledby="«r2i»"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
@@ -1690,7 +1694,7 @@ exports[`RoomView should not display the timeline when the room encryption is lo
<button
aria-disabled="false"
aria-label="Voice call"
aria-labelledby="«r2h»"
aria-labelledby="«r2n»"
class="_icon-button_1pz9o_8"
data-kind="primary"
role="button"
@@ -1716,7 +1720,7 @@ exports[`RoomView should not display the timeline when the room encryption is lo
</button>
<button
aria-label="Threads"
aria-labelledby="«r2m»"
aria-labelledby="«r2s»"
class="_icon-button_1pz9o_8"
data-kind="primary"
role="button"
@@ -1743,7 +1747,7 @@ exports[`RoomView should not display the timeline when the room encryption is lo
</button>
<button
aria-label="Room info"
aria-labelledby="«r2r»"
aria-labelledby="«r31»"
class="_icon-button_1pz9o_8"
data-kind="primary"
role="button"
@@ -1773,7 +1777,7 @@ exports[`RoomView should not display the timeline when the room encryption is lo
>
<div
aria-label="0 members"
aria-labelledby="«r30»"
aria-labelledby="«r36»"
class="mx_AccessibleButton mx_FacePile"
role="button"
tabindex="0"
@@ -1841,7 +1845,7 @@ exports[`RoomView should not display the timeline when the room encryption is lo
tabindex="0"
>
<div
aria-labelledby="«r3e»"
aria-labelledby="«r3k»"
class="mx_E2EIcon mx_E2EIcon_verified mx_MessageComposer_e2eIcon"
data-testid="e2e-icon"
>
@@ -2052,7 +2056,7 @@ exports[`RoomView video rooms should render joined video room view 1`] = `
</button>
<button
aria-label="Chat"
aria-labelledby="«r7c»"
aria-labelledby="«r7i»"
class="_icon-button_1pz9o_8"
data-kind="primary"
role="button"
@@ -2079,7 +2083,7 @@ exports[`RoomView video rooms should render joined video room view 1`] = `
</button>
<button
aria-label="Threads"
aria-labelledby="«r7h»"
aria-labelledby="«r7n»"
class="_icon-button_1pz9o_8"
data-kind="primary"
role="button"
@@ -2106,7 +2110,7 @@ exports[`RoomView video rooms should render joined video room view 1`] = `
</button>
<button
aria-label="Room info"
aria-labelledby="«r7m»"
aria-labelledby="«r7s»"
class="_icon-button_1pz9o_8"
data-kind="primary"
role="button"
@@ -2136,7 +2140,7 @@ exports[`RoomView video rooms should render joined video room view 1`] = `
>
<div
aria-label="0 members"
aria-labelledby="«r7r»"
aria-labelledby="«r81»"
class="mx_AccessibleButton mx_FacePile"
role="button"
tabindex="0"
@@ -2212,7 +2216,7 @@ exports[`RoomView video rooms should render joined video room view 1`] = `
</p>
</div>
<button
aria-labelledby="«r84»"
aria-labelledby="«r8a»"
class="_icon-button_1pz9o_8"
data-kind="secondary"
data-testid="base-card-close-button"
@@ -2271,6 +2275,8 @@ exports[`RoomView video rooms should render joined video room view 1`] = `
class="mx_MessageComposer_e2eIconWrapper"
>
<svg
aria-label="Messages in this room are not end-to-end encrypted"
aria-labelledby="«r8j»"
class="mx_E2EIcon mx_MessageComposer_e2eIcon"
color="var(--cpd-color-icon-info-primary)"
fill="currentColor"

View File

@@ -14,7 +14,12 @@ import BaseDialog from "../../../../../src/components/views/dialogs/BaseDialog.t
describe("BaseDialog", () => {
it("calls onFinished when Escape is pressed", async () => {
const onFinished = jest.fn();
render(<BaseDialog onFinished={onFinished} />);
const { container } = render(<BaseDialog onFinished={onFinished} />);
// Autolock's autofocus in the empty dialog is focusing on the close button and bringing up the tooltip
// So we either need to call escape twice(one for the tooltip and one for the dialog) or focus
// on the dialog first.
const dialog = container.querySelector('[role="dialog"]') as HTMLElement;
dialog?.focus();
await userEvent.keyboard("{Escape}");
expect(onFinished).toHaveBeenCalled();
});

View File

@@ -0,0 +1,46 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { fireEvent, render } from "jest-matrix-react";
import { IntegrationsDisabledDialog } from "../../../../../src/components/views/dialogs/IntegrationsDisabledDialog.tsx";
import defaultDispatcher from "../../../../../src/dispatcher/dispatcher.ts";
import { Action } from "../../../../../src/dispatcher/actions.ts";
import { UserTab } from "../../../../../src/components/views/dialogs/UserTab.ts";
describe("<IntegrationsDisabledDialog />", () => {
const onFinished = jest.fn();
afterEach(() => {
jest.restoreAllMocks();
});
function renderComponent() {
return render(<IntegrationsDisabledDialog onFinished={onFinished} />);
}
it("should render as expected", () => {
const { asFragment } = renderComponent();
expect(asFragment()).toMatchSnapshot();
});
it("should do nothing on clicking OK", () => {
const { getByText } = renderComponent();
fireEvent.click(getByText("OK"));
expect(onFinished).toHaveBeenCalled();
});
it("should open the correct user settings tab on clicking Settings", () => {
jest.spyOn(defaultDispatcher, "dispatch").mockImplementation(() => {});
const { getByText } = renderComponent();
fireEvent.click(getByText("Settings"));
expect(onFinished).toHaveBeenCalled();
expect(defaultDispatcher.dispatch).toHaveBeenCalledWith({
action: Action.ViewUserSettings,
initialTabId: UserTab.Security,
});
});
});

View File

@@ -13,6 +13,7 @@ exports[`<ChangelogDialog /> should fetch github proxy url for each repo with ol
class="mx_QuestionDialog mx_Dialog_fixedWidth"
data-focus-lock-disabled="false"
role="dialog"
tabindex="-1"
>
<div
class="mx_Dialog_header"

View File

@@ -13,6 +13,7 @@ exports[`ConfirmRejectInviteDialog can reject with options selected 1`] = `
class="mx_DeclineAndBlockInviteDialog mx_Dialog_fixedWidth"
data-focus-lock-disabled="false"
role="dialog"
tabindex="-1"
>
<div
class="mx_Dialog_header"

View File

@@ -13,6 +13,7 @@ exports[`ConfirmUserActionDialog renders 1`] = `
class="mx_ConfirmUserActionDialog mx_Dialog_fixedWidth"
data-focus-lock-disabled="false"
role="dialog"
tabindex="-1"
>
<div
class="mx_Dialog_header"

View File

@@ -12,6 +12,7 @@ exports[`DevtoolsDialog renders the devtools dialog 1`] = `
class="mx_QuestionDialog mx_Dialog_fixedWidth"
data-focus-lock-disabled="false"
role="dialog"
tabindex="-1"
>
<div
class="mx_Dialog_header"
@@ -33,6 +34,7 @@ exports[`DevtoolsDialog renders the devtools dialog 1`] = `
>
Room ID: !id
<div
aria-describedby="«r2»"
aria-label="Copy"
class="mx_AccessibleButton mx_CopyableText_copyButton"
role="button"

View File

@@ -7,6 +7,7 @@ exports[`<ExportDialog /> renders export dialog 1`] = `
class="mx_ExportDialog false mx_Dialog_fixedWidth"
data-focus-lock-disabled="false"
role="dialog"
tabindex="-1"
>
<div
class="mx_Dialog_header"

View File

@@ -13,6 +13,7 @@ exports[`FeedbackDialog should respect feedback config 1`] = `
class="mx_QuestionDialog mx_FeedbackDialog mx_Dialog_fixedWidth"
data-focus-lock-disabled="false"
role="dialog"
tabindex="-1"
>
<div
class="mx_Dialog_header"

View File

@@ -0,0 +1,68 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<IntegrationsDisabledDialog /> should render as expected 1`] = `
<DocumentFragment>
<div
data-focus-guard="true"
style="width: 1px; height: 0px; padding: 0px; overflow: hidden; position: fixed; top: 1px; left: 1px;"
tabindex="0"
/>
<div
aria-labelledby="mx_BaseDialog_title"
class="mx_IntegrationsDisabledDialog mx_Dialog_fixedWidth"
data-focus-lock-disabled="false"
role="dialog"
tabindex="-1"
>
<div
class="mx_Dialog_header"
>
<h1
class="mx_Heading_h3 mx_Dialog_title"
id="mx_BaseDialog_title"
>
Integrations are disabled
</h1>
</div>
<div
class="mx_IntegrationsDisabledDialog_content"
>
<p>
Enable 'Manage integrations' in Settings to do this.
</p>
</div>
<div
class="mx_Dialog_buttons"
>
<span
class="mx_Dialog_buttons_row"
>
<button
data-testid="dialog-cancel-button"
type="button"
>
OK
</button>
<button
class="mx_Dialog_primary"
data-testid="dialog-primary-button"
type="button"
>
Settings
</button>
</span>
</div>
<div
aria-label="Close dialog"
class="mx_AccessibleButton mx_Dialog_cancelButton"
role="button"
tabindex="0"
/>
</div>
<div
data-focus-guard="true"
style="width: 1px; height: 0px; padding: 0px; overflow: hidden; position: fixed; top: 1px; left: 1px;"
tabindex="0"
/>
</DocumentFragment>
`;

View File

@@ -13,6 +13,7 @@ exports[`LogoutDialog Prompts user to go to settings if there is a backup on the
class="mx_Dialog_fixedWidth"
data-focus-lock-disabled="false"
role="dialog"
tabindex="-1"
>
<div
class="mx_Dialog_header"
@@ -73,6 +74,7 @@ exports[`LogoutDialog Prompts user to go to settings if there is a backup on the
</details>
</div>
<div
aria-describedby="«rq»"
aria-label="Close dialog"
class="mx_AccessibleButton mx_Dialog_cancelButton"
role="button"
@@ -100,6 +102,7 @@ exports[`LogoutDialog Prompts user to go to settings if there is no backup on th
class="mx_Dialog_fixedWidth"
data-focus-lock-disabled="false"
role="dialog"
tabindex="-1"
>
<div
class="mx_Dialog_header"
@@ -160,6 +163,7 @@ exports[`LogoutDialog Prompts user to go to settings if there is no backup on th
</details>
</div>
<div
aria-describedby="«r10»"
aria-label="Close dialog"
class="mx_AccessibleButton mx_Dialog_cancelButton"
role="button"
@@ -187,6 +191,7 @@ exports[`LogoutDialog shows a regular dialog when crypto is disabled 1`] = `
class="mx_QuestionDialog mx_Dialog_fixedWidth"
data-focus-lock-disabled="false"
role="dialog"
tabindex="-1"
>
<div
class="mx_Dialog_header"

View File

@@ -12,6 +12,7 @@ exports[`<ManageRestrictedJoinRuleDialog /> should list spaces which are not par
class="mx_ManageRestrictedJoinRuleDialog"
data-focus-lock-disabled="false"
role="dialog"
tabindex="-1"
>
<div
class="mx_Dialog_header"
@@ -189,6 +190,7 @@ exports[`<ManageRestrictedJoinRuleDialog /> should render empty state 1`] = `
class="mx_ManageRestrictedJoinRuleDialog"
data-focus-lock-disabled="false"
role="dialog"
tabindex="-1"
>
<div
class="mx_Dialog_header"

View File

@@ -13,6 +13,7 @@ exports[`ManualDeviceKeyVerificationDialog should render correctly 1`] = `
class="mx_QuestionDialog mx_Dialog_fixedWidth"
data-focus-lock-disabled="false"
role="dialog"
tabindex="-1"
>
<div
class="mx_Dialog_header"

View File

@@ -12,6 +12,7 @@ exports[`<MessageEditHistory /> should match the snapshot 1`] = `
class="mx_MessageEditHistoryDialog mx_Dialog_fixedWidth"
data-focus-lock-disabled="false"
role="dialog"
tabindex="-1"
>
<div
class="mx_Dialog_header"
@@ -102,6 +103,7 @@ exports[`<MessageEditHistory /> should match the snapshot 1`] = `
</div>
</div>
<div
aria-describedby="«r2»"
aria-label="Close dialog"
class="mx_AccessibleButton mx_Dialog_cancelButton"
role="button"
@@ -128,6 +130,7 @@ exports[`<MessageEditHistory /> should support events with 1`] = `
class="mx_MessageEditHistoryDialog mx_Dialog_fixedWidth"
data-focus-lock-disabled="false"
role="dialog"
tabindex="-1"
>
<div
class="mx_Dialog_header"
@@ -315,6 +318,7 @@ exports[`<MessageEditHistory /> should support events with 1`] = `
</div>
</div>
<div
aria-describedby="«r8»"
aria-label="Close dialog"
class="mx_AccessibleButton mx_Dialog_cancelButton"
role="button"

View File

@@ -13,6 +13,7 @@ exports[`ReportRoomDialog displays admin message 1`] = `
class="mx_ReportRoomDialog mx_Dialog_fixedWidth"
data-focus-lock-disabled="false"
role="dialog"
tabindex="-1"
>
<div
class="mx_Dialog_header"

View File

@@ -13,6 +13,7 @@ exports[`<ServerPickerDialog /> should render dialog 1`] = `
class="mx_ServerPickerDialog"
data-focus-lock-disabled="false"
role="dialog"
tabindex="-1"
>
<div
class="mx_Dialog_header"

View File

@@ -13,6 +13,7 @@ exports[`ShareDialog should not render the QR code if disabled 1`] = `
class="mx_ShareDialog"
data-focus-lock-disabled="false"
role="dialog"
tabindex="-1"
>
<div
class="mx_Dialog_header"
@@ -143,6 +144,7 @@ exports[`ShareDialog should not render the socials if disabled 1`] = `
class="mx_ShareDialog"
data-focus-lock-disabled="false"
role="dialog"
tabindex="-1"
>
<div
class="mx_Dialog_header"
@@ -229,6 +231,7 @@ exports[`ShareDialog should render a share dialog for a matrix event 1`] = `
class="mx_ShareDialog"
data-focus-lock-disabled="false"
role="dialog"
tabindex="-1"
>
<div
class="mx_Dialog_header"
@@ -402,6 +405,7 @@ exports[`ShareDialog should render a share dialog for a room 1`] = `
class="mx_ShareDialog"
data-focus-lock-disabled="false"
role="dialog"
tabindex="-1"
>
<div
class="mx_Dialog_header"
@@ -574,6 +578,7 @@ exports[`ShareDialog should render a share dialog for a room member 1`] = `
class="mx_ShareDialog"
data-focus-lock-disabled="false"
role="dialog"
tabindex="-1"
>
<div
class="mx_Dialog_header"
@@ -719,6 +724,7 @@ exports[`ShareDialog should render a share dialog for an URL 1`] = `
class="mx_ShareDialog"
data-focus-lock-disabled="false"
role="dialog"
tabindex="-1"
>
<div
class="mx_Dialog_header"

View File

@@ -12,6 +12,7 @@ exports[`<UnpinAllDialog /> should render 1`] = `
class="mx_UnpinAllDialog"
data-focus-lock-disabled="false"
role="dialog"
tabindex="-1"
>
<div
class="mx_Dialog_header"

View File

@@ -12,6 +12,7 @@ exports[`<UntrustedDeviceDialog /> should display the dialog for the device of a
class="mx_UntrustedDeviceDialog mx_Dialog_fixedWidth"
data-focus-lock-disabled="false"
role="dialog"
tabindex="-1"
>
<div
class="mx_Dialog_header"
@@ -91,6 +92,7 @@ exports[`<UntrustedDeviceDialog /> should display the dialog for the device of t
class="mx_UntrustedDeviceDialog mx_Dialog_fixedWidth"
data-focus-lock-disabled="false"
role="dialog"
tabindex="-1"
>
<div
class="mx_Dialog_header"

View File

@@ -12,6 +12,7 @@ exports[`CreateSecretStorageDialog handles the happy path 1`] = `
class="mx_CreateSecretStorageDialog"
data-focus-lock-disabled="false"
role="dialog"
tabindex="-1"
>
<div
class="mx_Dialog_header"
@@ -140,6 +141,7 @@ exports[`CreateSecretStorageDialog handles the happy path 2`] = `
class="mx_CreateSecretStorageDialog"
data-focus-lock-disabled="false"
role="dialog"
tabindex="-1"
>
<div
class="mx_Dialog_header"
@@ -231,6 +233,7 @@ exports[`CreateSecretStorageDialog when there is an error fetching the backup ve
class="mx_CreateSecretStorageDialog"
data-focus-lock-disabled="false"
role="dialog"
tabindex="-1"
>
<div
class="mx_Dialog_header"

View File

@@ -12,6 +12,7 @@ exports[`ExportE2eKeysDialog renders 1`] = `
class="mx_exportE2eKeysDialog mx_Dialog_fixedWidth"
data-focus-lock-disabled="false"
role="dialog"
tabindex="-1"
>
<div
class="mx_Dialog_header"

View File

@@ -12,6 +12,7 @@ exports[`ImportE2eKeysDialog renders 1`] = `
class="mx_importE2eKeysDialog mx_Dialog_fixedWidth"
data-focus-lock-disabled="false"
role="dialog"
tabindex="-1"
>
<div
class="mx_Dialog_header"

View File

@@ -12,6 +12,7 @@ exports[`<RestoreKeyBackupDialog /> should display an error when recovery key is
class="mx_RestoreKeyBackupDialog mx_Dialog_fixedWidth"
data-focus-lock-disabled="false"
role="dialog"
tabindex="-1"
>
<div
class="mx_Dialog_header"
@@ -112,6 +113,7 @@ exports[`<RestoreKeyBackupDialog /> should not raise an error when recovery is v
class="mx_RestoreKeyBackupDialog mx_Dialog_fixedWidth"
data-focus-lock-disabled="false"
role="dialog"
tabindex="-1"
>
<div
class="mx_Dialog_header"
@@ -211,6 +213,7 @@ exports[`<RestoreKeyBackupDialog /> should render 1`] = `
class="mx_RestoreKeyBackupDialog mx_Dialog_fixedWidth"
data-focus-lock-disabled="false"
role="dialog"
tabindex="-1"
>
<div
class="mx_Dialog_header"
@@ -283,6 +286,7 @@ exports[`<RestoreKeyBackupDialog /> should render 1`] = `
</div>
</div>
<div
aria-describedby="«r2»"
aria-label="Close dialog"
class="mx_AccessibleButton mx_Dialog_cancelButton"
role="button"
@@ -309,6 +313,7 @@ exports[`<RestoreKeyBackupDialog /> should restore key backup when Recovery key
class="mx_RestoreKeyBackupDialog mx_Dialog_fixedWidth"
data-focus-lock-disabled="false"
role="dialog"
tabindex="-1"
>
<div
class="mx_Dialog_header"
@@ -374,6 +379,7 @@ exports[`<RestoreKeyBackupDialog /> should restore key backup when passphrase is
class="mx_RestoreKeyBackupDialog mx_Dialog_fixedWidth"
data-focus-lock-disabled="false"
role="dialog"
tabindex="-1"
>
<div
class="mx_Dialog_header"
@@ -439,6 +445,7 @@ exports[`<RestoreKeyBackupDialog /> should restore key backup when the key is ca
class="mx_RestoreKeyBackupDialog mx_Dialog_fixedWidth"
data-focus-lock-disabled="false"
role="dialog"
tabindex="-1"
>
<div
class="mx_Dialog_header"
@@ -478,6 +485,7 @@ exports[`<RestoreKeyBackupDialog /> should restore key backup when the key is ca
</div>
</div>
<div
aria-describedby="«rk»"
aria-label="Close dialog"
class="mx_AccessibleButton mx_Dialog_cancelButton"
role="button"
@@ -504,6 +512,7 @@ exports[`<RestoreKeyBackupDialog /> should restore key backup when the key is in
class="mx_RestoreKeyBackupDialog mx_Dialog_fixedWidth"
data-focus-lock-disabled="false"
role="dialog"
tabindex="-1"
>
<div
class="mx_Dialog_header"
@@ -543,6 +552,7 @@ exports[`<RestoreKeyBackupDialog /> should restore key backup when the key is in
</div>
</div>
<div
aria-describedby="«rq»"
aria-label="Close dialog"
class="mx_AccessibleButton mx_Dialog_cancelButton"
role="button"

View File

@@ -340,8 +340,8 @@ exports[`AppTile for a pinned widget should render permission request 1`] = `
<span>
Using this widget may share data
<div
aria-describedby="«r2j»"
aria-labelledby="«r2i»"
aria-describedby="«r2n»"
aria-labelledby="«r2m»"
class="mx_TextWithTooltip_target mx_TextWithTooltip_target--helpIcon"
>
<svg

View File

@@ -21,6 +21,7 @@ exports[`<ImageView /> renders correctly 1`] = `
class="mx_ImageView_toolbar"
>
<div
aria-describedby="«r2»"
aria-label="Zoom out"
class="mx_AccessibleButton mx_ImageView_button mx_ImageView_button_zoomOut"
role="button"

View File

@@ -32,6 +32,7 @@ exports[`<LocationViewDialog /> renders map correctly 1`] = `
class="mx_ZoomButtons"
>
<div
aria-describedby="«r6»"
aria-label="Zoom in"
class="mx_AccessibleButton mx_ZoomButtons_button"
data-testid="map-zoom-in-button"

View File

@@ -151,7 +151,7 @@ describe("CallEvent", () => {
}),
);
defaultDispatcher.unregister(dispatcherRef);
await act(() => call.start());
act(() => call.setConnectionState(ConnectionState.Connected));
// Test that the leave button works
fireEvent.click(screen.getByRole("button", { name: "Leave" }));

View File

@@ -48,6 +48,7 @@ describe("HideActionButton", () => {
beforeEach(() => {
cli = getMockClientWithEventEmitter({
getRoom: jest.fn(),
getUserId: jest.fn(),
});
});
afterEach(() => {

View File

@@ -35,10 +35,11 @@ jest.mock("matrix-encrypt-attachment", () => ({
}));
describe("<MImageBody/>", () => {
const userId = "@user:server";
const ourUserId = "@user:server";
const senderUserId = "@other_use:server";
const deviceId = "DEADB33F";
const cli = getMockClientWithEventEmitter({
...mockClientMethodsUser(userId),
...mockClientMethodsUser(ourUserId),
...mockClientMethodsServer(),
...mockClientMethodsDevice(deviceId),
...mockClientMethodsCrypto(),
@@ -62,7 +63,7 @@ describe("<MImageBody/>", () => {
const encryptedMediaEvent = new MatrixEvent({
event_id: "$foo:bar",
room_id: "!room:server",
sender: userId,
sender: senderUserId,
type: EventType.RoomMessage,
content: {
body: "alt for a test image",
@@ -201,7 +202,7 @@ describe("<MImageBody/>", () => {
const event = new MatrixEvent({
room_id: "!room:server",
sender: userId,
sender: senderUserId,
type: EventType.RoomMessage,
content: {
body: "alt for a test image",
@@ -254,7 +255,7 @@ describe("<MImageBody/>", () => {
const event = new MatrixEvent({
room_id: "!room:server",
sender: userId,
sender: senderUserId,
type: EventType.RoomMessage,
content: {
body: "alt for a test image",
@@ -281,7 +282,7 @@ describe("<MImageBody/>", () => {
it("should show banner on hover", async () => {
const event = new MatrixEvent({
room_id: "!room:server",
sender: userId,
sender: senderUserId,
type: EventType.RoomMessage,
content: {
body: "alt for a test image",

View File

@@ -8,7 +8,7 @@ Please see LICENSE files in the repository root for full details.
import React from "react";
import { EventType, getHttpUriForMxc, type IContent, type MatrixClient, MatrixEvent } from "matrix-js-sdk/src/matrix";
import { fireEvent, render, screen, type RenderResult } from "jest-matrix-react";
import { fireEvent, render, screen } from "jest-matrix-react";
import fetchMock from "fetch-mock-jest";
import { type MockedObject } from "jest-mock";
@@ -34,7 +34,8 @@ jest.mock("matrix-encrypt-attachment", () => ({
}));
describe("MVideoBody", () => {
const userId = "@user:server";
const ourUserId = "@user:server";
const senderUserId = "@other_use:server";
const deviceId = "DEADB33F";
const thumbUrl = "https://server/_matrix/media/v3/download/server/encrypted-poster";
@@ -42,7 +43,7 @@ describe("MVideoBody", () => {
beforeEach(() => {
cli = getMockClientWithEventEmitter({
...mockClientMethodsUser(userId),
...mockClientMethodsUser(ourUserId),
...mockClientMethodsServer(),
...mockClientMethodsDevice(deviceId),
...mockClientMethodsCrypto(),
@@ -67,7 +68,7 @@ describe("MVideoBody", () => {
const encryptedMediaEvent = new MatrixEvent({
room_id: "!room:server",
sender: userId,
sender: senderUserId,
type: EventType.RoomMessage,
event_id: "$foo:bar",
content: {
@@ -86,10 +87,47 @@ describe("MVideoBody", () => {
},
});
it("does not crash when given a portrait image", () => {
it("does not crash when given portrait dimensions", () => {
// Check for an unreliable crash caused by a fractional-sized
// image dimension being used for a CanvasImageData.
const { asFragment } = makeMVideoBody(720, 1280);
const content: IContent = {
info: {
"w": 720,
"h": 1280,
"mimetype": "video/mp4",
"size": 2495675,
"thumbnail_file": {
url: "",
key: { alg: "", key_ops: [], kty: "", k: "", ext: true },
iv: "",
hashes: {},
v: "",
},
"thumbnail_info": { mimetype: "" },
"xyz.amorgan.blurhash": "TrGl6bofof~paxWC?bj[oL%2fPj]",
},
url: "http://example.com",
};
const event = new MatrixEvent({
content,
});
const defaultProps: IBodyProps = {
mxEvent: event,
highlights: [],
highlightLink: "",
onMessageAllowed: jest.fn(),
permalinkCreator: {} as RoomPermalinkCreator,
mediaEventHelper: { media: { isEncrypted: false } } as MediaEventHelper,
};
const { asFragment } = render(
<MatrixClientContext.Provider value={cli}>
<MVideoBody {...defaultProps} />
</MatrixClientContext.Provider>,
withClientContextRenderOptions(cli),
);
expect(asFragment()).toMatchSnapshot();
// If we get here, we did not crash.
});
@@ -153,50 +191,39 @@ describe("MVideoBody", () => {
expect(fetchMock).toHaveFetched(thumbUrl);
});
it("should download video if we were the sender", async () => {
fetchMock.getOnce(thumbUrl, { status: 200 });
const ourEncryptedMediaEvent = new MatrixEvent({
room_id: "!room:server",
sender: ourUserId,
type: EventType.RoomMessage,
event_id: "$foo:bar",
content: {
body: "alt for a test video",
info: {
duration: 420,
w: 40,
h: 50,
thumbnail_file: {
url: "mxc://server/encrypted-poster",
},
},
file: {
url: "mxc://server/encrypted-image",
},
},
});
const { asFragment } = render(
<MVideoBody
mxEvent={ourEncryptedMediaEvent}
mediaEventHelper={new MediaEventHelper(ourEncryptedMediaEvent)}
/>,
withClientContextRenderOptions(cli),
);
expect(fetchMock).toHaveFetched(thumbUrl);
expect(asFragment()).toMatchSnapshot();
});
});
});
function makeMVideoBody(w: number, h: number): RenderResult {
const content: IContent = {
info: {
"w": w,
"h": h,
"mimetype": "video/mp4",
"size": 2495675,
"thumbnail_file": {
url: "",
key: { alg: "", key_ops: [], kty: "", k: "", ext: true },
iv: "",
hashes: {},
v: "",
},
"thumbnail_info": { mimetype: "" },
"xyz.amorgan.blurhash": "TrGl6bofof~paxWC?bj[oL%2fPj]",
},
url: "http://example.com",
};
const event = new MatrixEvent({
content,
});
const defaultProps: IBodyProps = {
mxEvent: event,
highlights: [],
highlightLink: "",
onMessageAllowed: jest.fn(),
permalinkCreator: {} as RoomPermalinkCreator,
mediaEventHelper: { media: { isEncrypted: false } } as MediaEventHelper,
};
const mockClient = getMockClientWithEventEmitter({
mxcUrlToHttp: jest.fn(),
getRoom: jest.fn(),
});
return render(
<MatrixClientContext.Provider value={mockClient}>
<MVideoBody {...defaultProps} />
</MatrixClientContext.Provider>,
);
}

View File

@@ -1,6 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`MVideoBody does not crash when given a portrait image 1`] = `
exports[`MVideoBody does not crash when given portrait dimensions 1`] = `
<DocumentFragment>
<span
class="mx_MVideoBody"
@@ -48,3 +48,28 @@ exports[`MVideoBody should show poster for encrypted media before downloading it
</span>
</DocumentFragment>
`;
exports[`MVideoBody with video previews/thumbnails disabled should download video if we were the sender 1`] = `
<DocumentFragment>
<span
class="mx_MVideoBody"
>
<div
class="mx_MVideoBody_container"
style="max-width: 40px; max-height: 50px;"
>
<video
class="mx_MVideoBody"
controls=""
controlslist="nodownload"
poster="https://server/_matrix/media/v3/download/server/encrypted-poster"
preload="none"
title="alt for a test video"
/>
<div
style="width: 40px; height: 50px;"
/>
</div>
</span>
</DocumentFragment>
`;

View File

@@ -215,6 +215,7 @@ exports[`<RoomSummaryCard /> has button to edit topic 1`] = `
aria-hidden="true"
class="_input_19o42_24"
id="«rv»"
role="switch"
type="checkbox"
/>
<div
@@ -939,6 +940,7 @@ exports[`<RoomSummaryCard /> renders the room summary 1`] = `
aria-hidden="true"
class="_input_19o42_24"
id="«r5»"
role="switch"
type="checkbox"
/>
<div
@@ -1701,6 +1703,7 @@ exports[`<RoomSummaryCard /> renders the room topic in the summary 1`] = `
aria-hidden="true"
class="_input_19o42_24"
id="«ri»"
role="switch"
type="checkbox"
/>
<div

View File

@@ -52,7 +52,6 @@ import * as ShieldUtils from "../../../../../../src/utils/ShieldUtils";
import { Container, WidgetLayoutStore } from "../../../../../../src/stores/widgets/WidgetLayoutStore";
import MatrixClientContext from "../../../../../../src/contexts/MatrixClientContext";
import { _t } from "../../../../../../src/languageHandler";
import * as UseCall from "../../../../../../src/hooks/useCall";
import { SdkContextClass } from "../../../../../../src/contexts/SDKContext";
import WidgetStore, { type IApp } from "../../../../../../src/stores/WidgetStore";
import { UIFeature } from "../../../../../../src/settings/UIFeature";
@@ -96,6 +95,10 @@ describe("RoomHeader", () => {
setCardSpy = jest.spyOn(RightPanelStore.instance, "setCard");
jest.spyOn(ShieldUtils, "shieldStatusForRoom").mockResolvedValue(ShieldUtils.E2EStatus.Normal);
// Mock CallStore.instance.getCall to return null by default
// Individual tests can override this when they need a specific Call object
jest.spyOn(CallStore.instance, "getCall").mockReturnValue(null);
});
afterEach(() => {
@@ -555,7 +558,8 @@ describe("RoomHeader", () => {
it("join button is shown if there is an ongoing call", async () => {
mockRoomMembers(room, 3);
jest.spyOn(UseCall, "useParticipantCount").mockReturnValue(3);
// Mock CallStore to return a call with 3 participants
jest.spyOn(CallStore.instance, "getCall").mockReturnValue(createMockCall(ROOM_ID, 3));
render(<RoomHeader room={room} />, getWrapper());
const joinButton = getByLabelText(document.body, "Join");
expect(joinButton).not.toHaveAttribute("aria-disabled", "true");
@@ -563,7 +567,8 @@ describe("RoomHeader", () => {
it("join button is disabled if there is an other ongoing call", async () => {
mockRoomMembers(room, 3);
jest.spyOn(UseCall, "useParticipantCount").mockReturnValue(3);
// Mock CallStore to return a call with 3 participants
jest.spyOn(CallStore.instance, "getCall").mockReturnValue(createMockCall(ROOM_ID, 3));
jest.spyOn(CallStore.prototype, "connectedCalls", "get").mockReturnValue(
new Set([{ roomId: "some_other_room" } as Call]),
);
@@ -583,7 +588,8 @@ describe("RoomHeader", () => {
it("close lobby button is shown if there is an ongoing call but we are viewing the lobby", async () => {
mockRoomMembers(room, 3);
jest.spyOn(UseCall, "useParticipantCount").mockReturnValue(3);
// Mock CallStore to return a call with 3 participants
jest.spyOn(CallStore.instance, "getCall").mockReturnValue(createMockCall(ROOM_ID, 3));
jest.spyOn(SdkContextClass.instance.roomViewStore, "isViewingCall").mockReturnValue(true);
render(<RoomHeader room={room} />, getWrapper());
@@ -789,6 +795,34 @@ describe("RoomHeader", () => {
});
});
/**
* Creates a mock Call object with stable participants to prevent React dependency errors
*/
function createMockCall(roomId: string = "!1:example.org", participantCount: number = 0): Call {
const participants = new Map();
// Create mock participants with devices
for (let i = 0; i < participantCount; i++) {
const mockMember = {
userId: `@user-${i}:example.org`,
name: `Member ${i}`,
} as RoomMember;
const deviceSet = new Set([`device-${i}`]);
participants.set(mockMember, deviceSet);
}
return {
roomId,
participants,
widget: { id: "test-widget" },
connectionState: "disconnected",
on: jest.fn(),
off: jest.fn(),
emit: jest.fn(),
} as unknown as Call;
}
/**
*
* @param count the number of users to create

View File

@@ -37,6 +37,7 @@ describe("<RoomListItemView />", () => {
onFocus: jest.fn(),
roomIndex: 0,
roomCount: 1,
listIsScrolling: false,
};
return render(<RoomListItemView {...defaultProps} {...props} />, withClientContextRenderOptions(matrixClient));
@@ -128,6 +129,7 @@ describe("<RoomListItemView />", () => {
onFocus={jest.fn()}
roomIndex={0}
roomCount={1}
listIsScrolling={false}
/>,
);
@@ -191,4 +193,26 @@ describe("<RoomListItemView />", () => {
await user.keyboard("{Escape}");
expect(screen.queryByRole("menu")).toBeNull();
});
test("should not render context menu when list is scrolling", async () => {
const user = userEvent.setup();
mocked(useRoomListItemViewModel).mockReturnValue({
...defaultValue,
showContextMenu: true,
});
renderRoomListItem({
listIsScrolling: true,
});
const button = screen.getByRole("option", { name: `Open room ${room.name}` });
await user.pointer([{ target: button }, { keys: "[MouseRight]", target: button }]);
// Context menu should not appear when scrolling
expect(screen.queryByRole("menu")).toBeNull();
// But the room item itself should still be rendered
expect(button).toBeInTheDocument();
});
});

View File

@@ -46,6 +46,7 @@ import { UIComponent } from "../../../../../src/settings/UIFeature";
import { MessagePreviewStore } from "../../../../../src/stores/room-list/MessagePreviewStore";
import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg";
import SettingsStore from "../../../../../src/settings/SettingsStore";
import { ConnectionState } from "../../../../../src/models/Call";
jest.mock("../../../../../src/customisations/helpers/UIComponents", () => ({
shouldShowComponent: jest.fn(),
@@ -215,7 +216,7 @@ describe("RoomTile", () => {
it("tracks connection state", async () => {
renderRoomTile();
screen.getByText("Video");
await act(() => call.start());
act(() => call.setConnectionState(ConnectionState.Connected));
screen.getByText("Joined");
await act(() => call.disconnect());
screen.getByText("Video");

View File

@@ -185,7 +185,7 @@ exports[`<PinnedEventTile /> should render the menu with all the options 1`] = `
aria-label="Open menu"
aria-labelledby="radix-«r10»"
aria-orientation="vertical"
class="_menu_19sse_8"
class="_menu_1glhz_8"
data-align="start"
data-orientation="vertical"
data-radix-menu-content=""
@@ -376,7 +376,7 @@ exports[`<PinnedEventTile /> should render the menu without unpin and delete 1`]
aria-label="Open menu"
aria-labelledby="radix-«rl»"
aria-orientation="vertical"
class="_menu_19sse_8"
class="_menu_1glhz_8"
data-align="start"
data-orientation="vertical"
data-radix-menu-content=""

View File

@@ -70,12 +70,12 @@ describe("<LayoutSwitcher />", () => {
await SettingsStore.setValue("useCompactLayout", null, SettingLevel.DEVICE, true);
await renderLayoutSwitcher();
expect(screen.getByRole("checkbox", { name: "Show compact text and messages" })).toBeChecked();
expect(screen.getByRole("switch", { name: "Show compact text and messages" })).toBeChecked();
});
it("should change the setting when toggled", async () => {
await renderLayoutSwitcher();
act(() => screen.getByRole("checkbox", { name: "Show compact text and messages" }).click());
act(() => screen.getByRole("switch", { name: "Show compact text and messages" }).click());
await waitFor(() => expect(SettingsStore.getValue("useCompactLayout")).toBe(true));
});
@@ -83,7 +83,7 @@ describe("<LayoutSwitcher />", () => {
it("should be disabled when the modern layout is not enabled", async () => {
await SettingsStore.setValue("layout", null, SettingLevel.DEVICE, Layout.Bubble);
await renderLayoutSwitcher();
expect(screen.getByRole("checkbox", { name: "Show compact text and messages" })).toBeDisabled();
expect(screen.getByRole("switch", { name: "Show compact text and messages" })).toBeDisabled();
});
});
});

View File

@@ -56,24 +56,24 @@ describe("<ThemeChoicePanel />", () => {
describe("system theme", () => {
it("should disable Match system theme", async () => {
render(<ThemeChoicePanel />);
expect(screen.getByRole("checkbox", { name: "Match system theme" })).not.toBeChecked();
expect(screen.getByRole("switch", { name: "Match system theme" })).not.toBeChecked();
});
it("should enable Match system theme", async () => {
await enableSystemTheme(true);
render(<ThemeChoicePanel />);
expect(screen.getByRole("checkbox", { name: "Match system theme" })).toBeChecked();
expect(screen.getByRole("switch", { name: "Match system theme" })).toBeChecked();
});
it("should change the system theme when clicked", async () => {
jest.spyOn(SettingsStore, "setValue");
render(<ThemeChoicePanel />);
act(() => screen.getByRole("checkbox", { name: "Match system theme" }).click());
act(() => screen.getByRole("switch", { name: "Match system theme" }).click());
// The system theme should be enabled
expect(screen.getByRole("checkbox", { name: "Match system theme" })).toBeChecked();
expect(screen.getByRole("switch", { name: "Match system theme" })).toBeChecked();
expect(SettingsStore.setValue).toHaveBeenCalledWith("use_system_theme", null, "device", true);
});
});

View File

@@ -418,6 +418,7 @@ exports[`<LayoutSwitcher /> should render 1`] = `
class="_input_19o42_24"
id="radix-«rr»"
name="compactLayout"
role="switch"
title=""
type="checkbox"
/>

View File

@@ -34,6 +34,7 @@ exports[`<ThemeChoicePanel /> custom theme should display custom theme 1`] = `
class="_input_19o42_24"
id="radix-«r28»"
name="systemTheme"
role="switch"
title=""
type="checkbox"
/>
@@ -312,6 +313,7 @@ exports[`<ThemeChoicePanel /> custom theme should render the custom theme sectio
class="_input_19o42_24"
id="radix-«r10»"
name="systemTheme"
role="switch"
title=""
type="checkbox"
/>
@@ -590,6 +592,7 @@ exports[`<ThemeChoicePanel /> renders the theme choice UI 1`] = `
class="_input_19o42_24"
id="radix-«r0»"
name="systemTheme"
role="switch"
title=""
type="checkbox"
/>

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