Compare commits

...

44 Commits

Author SHA1 Message Date
RiotRobot
bb5bf5a462 v1.12.6 2025-12-03 17:21:30 +00:00
ElementRobot
916c5a0232 Add option to pick call options for voice calls. (#31407) (#31413)
* Add option to pick call options for voice calls.

* hook on the right thing

* Fix wrong call being disabled

* update snaps

* Add tests for menus

* more snaps

* snap snap

(cherry picked from commit a352a3838e)

Co-authored-by: Will Hunt <2072976+Half-Shot@users.noreply.github.com>
2025-12-03 16:29:41 +00:00
RiotRobot
f7e6cb6129 v1.12.5 2025-12-02 15:04:27 +00:00
RiotRobot
9dc9b169ab Upgrade dependency to matrix-js-sdk@39.3.0 2025-12-02 14:46:48 +00:00
RiotRobot
dae90a059f v1.12.5-rc.0 2025-11-25 14:35:44 +00:00
RiotRobot
2f727430e1 Upgrade dependency to matrix-js-sdk@39.3.0-rc.0 2025-11-25 14:12:52 +00:00
ElementRobot
4392aa1ed0 [create-pull-request] automated change (#31315)
Co-authored-by: t3chguy <2403652+t3chguy@users.noreply.github.com>
2025-11-25 06:23:04 +00:00
Florian Duros
a721c5f4ea chore: update element-hq/element-web-module-api to 1.6.0 (#31309) 2025-11-24 18:30:59 +00:00
David Langley
79f1176b92 Fix location sharing flake by hiding MapLibre info link from screenshots (#31290)
* Hide MapLibre info link from screenshots

* Update Reply-to-the-location-on-ThreadView-linux.png
2025-11-24 16:38:02 +00:00
Michael Telatynski
92bb15fbba Update Emojibase to v17 (#31307)
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2025-11-24 12:08:11 +00:00
Matthew Hodgson
7aa7793640 Return to using Fira Code as the default monospace font (#31302)
* Return to using Fira Code as the default monospace font.

because Inconsolata lacks Box Drawings and Geometric Shapes glyphs.
Fixes https://github.com/element-hq/element-web/issues/31289
Partially reverts https://github.com/matrix-org/matrix-react-sdk/pull/3008

* fix quotes

* fix tests

* fix quotes

* appease prettier

* fix snapshot tests, hopefully

* more snapshots
2025-11-24 11:52:32 +00:00
ElementRobot
f282be05ca [create-pull-request] automated change (#31295)
Co-authored-by: t3chguy <2403652+t3chguy@users.noreply.github.com>
2025-11-24 09:10:02 +00:00
ElementRobot
744922cbcc [create-pull-request] automated change (#31270)
Co-authored-by: t3chguy <2403652+t3chguy@users.noreply.github.com>
2025-11-24 09:07:40 +00:00
David Langley
7183d91930 Improve viewSomethingBehindModal logic (#31301) 2025-11-21 17:04:52 +00:00
byteplow
cdedcc0b5a Adds tooltip for compose menu (#31122)
* Adds tooltip for compose menu button

* fix tests

* prettier

* tweak aria attributes
2025-11-21 16:42:44 +00:00
Richard van der Hoff
b679693702 Documentation for initialisation/login code (#31297)
* Documentation in MatrixChat and Lifecycle

* State transition diagram for `View`
2025-11-21 14:58:03 +00:00
Florian Duros
fbb43d5e61 Fix message edition and reply when multiple rooms at displayed the same moment (#31280)
* feat: implement `ExtrasApi#setRoomIdsForSpace`

* fix: message reply with multiple room views

* fix: message edition when multiple rooms are displayed

* test: check that the view room action is not dispatch when replying

* test: check that the view room action is not dispatch when editing

* refactor: use `ExtraApis#getVisibleRoomBySpaceKey` instead of  `ExtraApis#setRoomIdsForSpace`

* test: update tests to use `getVisibleRoomBySpaceKey`
2025-11-21 14:51:23 +00:00
Florian Duros
a79f6e7aa5 Add option to hide pinned message banner in room view (#31296)
* feat: add `hidePinnedMessageBanner` to room view

* test: add test for `hidePinnedMessageBanner`
2025-11-21 13:45:22 +00:00
David Langley
81c375007e Fix location sharing dialog screenshot flake (#31288)
* FIx location screenshot flake

* const

* Use the map marker to click on the map.
2025-11-21 13:14:42 +00:00
Hubert Chathi
aee24be1b4 Key storage out of sync: reset key backup when needed (#31279)
* add function to pause device listener

* add function to check if key backup key missing both locally and in 4s

* reset backup if backup key missing both locally and in 4s

* fixup! add function to check if key backup key missing both locally and in 4s

* Drop KEY_STORAGE_OUT_OF_SYNC_STORE in favour of checking cross-signing

Check if cross-signing needs resetting, because that seems to be what
KEY_STORAGE_OUT_OF_SYNC_STORE is actually trying to do.

* add a function for resetting key backup and waiting until it's ready

* trigger key storage out of sync toast when missing backup key locally

and fetch it when user enters their recovery key

* reset backup when needed if user forgets recovery key

* rename function as suggested in code review
2025-11-20 20:25:31 +00:00
Richard van der Hoff
1285b73be6 Documentation and symbolic constants for dispatcher actions (#31278)
* Remove unreachable code

`view_last_screen` is never used.

* Remove unused action `user_activity_started`

Nothing listens to this, so it's pointless.

* Symbolic constant for `Action.UserActivity`

* Define symbolic constants for more `Action`s

Constants for some actions that are emitted by `Lifecycle`
2025-11-20 18:18:04 +00:00
Richard van der Hoff
c203f02731 Rename callback on E2eSetup component (#31274)
* Rename callback on E2eSetup component

`BaseDialog.onFinished` is unused when `hasCancel=false`, so this callback is
only used when the user clicks cancel. For clarity, rename it.

* Test for cancellation behaviour
2025-11-20 18:17:51 +00:00
Matthew Hodgson
64130a018b update twemoji to not monochromise emoji with BLACK in their name (#31281) 2025-11-20 11:29:35 +00:00
Matthew Hodgson
e2fc1574bf upgrade to twemoji 17.0.2 and fix #14695 (#31267)
* upgrade to twemoji 17.0.2 and fix #14695

See 356e12591c

* add u3030 (wavy-dash) too
2025-11-19 17:45:12 +00:00
David Langley
de0492b786 Use 22.18 (#31273) 2025-11-19 17:36:00 +00:00
Hubert Chathi
0a46edaaff Remove obsolete checks that the server supports cross-signing (#31275)
We already depend on an API version that includes cross-signing
2025-11-19 17:28:08 +00:00
ElementRobot
dd89cee328 [create-pull-request] automated change (#31271)
Co-authored-by: t3chguy <2403652+t3chguy@users.noreply.github.com>
2025-11-19 06:30:34 +00:00
Michael Telatynski
29ff8a6199 Fix invalid events crashing entire room rather than just their tile (#31256)
* Fix invalid events crashing entire room rather than just their tile

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

* Add test

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

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2025-11-18 17:33:49 +00:00
RiotRobot
184e6e3f29 Merge branch 'master' into develop 2025-11-18 14:41:20 +00:00
RiotRobot
7a01cdae0a v1.12.4 2025-11-18 14:36:41 +00:00
RiotRobot
06656a6472 Upgrade dependency to matrix-js-sdk@39.2.0 2025-11-18 14:29:25 +00:00
R Midhun Suresh
5de9d5d24f Bump version (#31263) 2025-11-18 12:32:49 +00:00
Florian Duros
0eff1caab2 Add options to hide right panel in room view (#31252)
* feat: add options to hide right panel in room view

This option is added for the module API.

* test: add test for hideRightPanel=true of room view

* test: update snapshot ids
2025-11-18 10:05:53 +00:00
R Midhun Suresh
b7acbe65c1 Remove contain property (#31259)
This was telling the browser that the children of this div will never go
out of the bounding rect of the div. This is incorrect because the
expand button is supposed to sit at the edge of the space panel with
half of it outside the bounding rect.
2025-11-18 09:37:09 +00:00
ElementRobot
5736635a65 [create-pull-request] automated change (#31258)
Co-authored-by: t3chguy <2403652+t3chguy@users.noreply.github.com>
2025-11-18 06:22:43 +00:00
Andrew Ferrazzutti
fcd23b48e0 Delayed event management: split endpoints, no auth (#31183)
* Delayed event management: split endpoints, no auth

Use the new js-sdk client methods for calling the dedicated,
unauthenticated endpoints for each of the cancel/restart/send actions
for updating a delayed event.

Note that these methods are compatible with homeservers that support
only the original endpoint where the update action is in the request
body.

* REPLACEME: pull in dependant js-sdk branch

see matrix-org/matrix-js-sdk#5066

* Format with Prettier

* Update matrix-js-sdk
2025-11-17 16:18:25 +00:00
David Baker
250d6571fe Fix pill buttons in dialogs (#31246)
* Fix pill buttons in dialogs

Add the magic class that makes the buttons not be broken by the
dialog button styles.

* Update snapshot
2025-11-17 15:14:34 +00:00
Will Hunt
f3a880f1c3 Support using Element Call for voice calls in DMs (#30817)
* Add voiceOnly options.

* tweaks

* Nearly working demo

* Lots of minor fixes

* Better working version

* remove unused payload

* bits and pieces

* Cleanup based on new hints

* Simple refactor for skipLobby (and remove returnToLobby)

* Tidyup

* Remove unused tests

* Update tests for voice calls

* Add video room support.

* Add a test for video rooms

* tidy

* remove console log line

* lint and tests

* Bunch of fixes

* Fixes

* Use correct title

* make linter happier

* Update tests

* cleanup

* Drop only

* update snaps

* Document

* lint

* Update snapshots

* Remove duplicate test

* add brackets

* fix jest
2025-11-17 11:50:22 +00:00
Michael Telatynski
3d683ec5c6 Fix gen-workflow-mermaid to be compatible with Node 22.18+ (#31250)
* Fix gen-workflow-mermaid to be compatible with Node 22.18+

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

* Iterate

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

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2025-11-17 10:33:45 +00:00
ElementRobot
81f1841aea [create-pull-request] automated change (#31249)
Co-authored-by: t3chguy <2403652+t3chguy@users.noreply.github.com>
2025-11-17 09:15:38 +00:00
ElementRobot
e62125e61f [create-pull-request] automated change (#31247)
Co-authored-by: t3chguy <2403652+t3chguy@users.noreply.github.com>
2025-11-15 06:22:04 +00:00
RiotRobot
842edc6577 v1.12.4-rc.0 2025-11-11 14:45:49 +00:00
ElementRobot
faadcf902e Install shared components during EW install (#31196) (#31208)
* fix: install shared components during EW install

* chore: ignore `@element-hq/web-shared-components` for knip

* chore: remove shared-components operation in layered.sh

EW `yarn install` is also installing shared-componenents dependencies.
`link:` in `package.json` works nearly like `yarn link`.

* Iterate



---------



(cherry picked from commit e883b05206)

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
Co-authored-by: Florian Duros <florianduros@element.io>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2025-11-11 14:18:14 +00:00
RiotRobot
191f951303 Upgrade dependency to matrix-js-sdk@39.2.0-rc.0 2025-11-11 12:46:14 +00:00
122 changed files with 2773 additions and 593 deletions

View File

@@ -1,3 +1,71 @@
Changes in [1.12.6](https://github.com/element-hq/element-web/releases/tag/v1.12.6) (2025-12-03)
================================================================================================
This release fixes a bug where 1:1 calling was incorrectly not available if no Element Call focus was set.
## 🐛 Bug Fixes
* Add option to pick call options for voice calls. ([#31413](https://github.com/element-hq/element-web/pull/31413)).
Changes in [1.12.5](https://github.com/element-hq/element-web/releases/tag/v1.12.5) (2025-12-02)
================================================================================================
## ✨ Features
* Update Emojibase to v17 ([#31307](https://github.com/element-hq/element-web/pull/31307)). Contributed by @t3chguy.
* Adds tooltip for compose menu ([#31122](https://github.com/element-hq/element-web/pull/31122)). Contributed by @byteplow.
* Add option to hide pinned message banner in room view ([#31296](https://github.com/element-hq/element-web/pull/31296)). Contributed by @florianduros.
* update twemoji to not monochromise emoji with BLACK in their name ([#31281](https://github.com/element-hq/element-web/pull/31281)). Contributed by @ara4n.
* upgrade to twemoji 17.0.2 and fix #14695 ([#31267](https://github.com/element-hq/element-web/pull/31267)). Contributed by @ara4n.
* Add options to hide right panel in room view ([#31252](https://github.com/element-hq/element-web/pull/31252)). Contributed by @florianduros.
* Delayed event management: split endpoints, no auth ([#31183](https://github.com/element-hq/element-web/pull/31183)). Contributed by @AndrewFerr.
* Support using Element Call for voice calls in DMs ([#30817](https://github.com/element-hq/element-web/pull/30817)). Contributed by @Half-Shot.
* Improve screen reader accessibility of auth pages ([#31236](https://github.com/element-hq/element-web/pull/31236)). Contributed by @t3chguy.
* Add posthog tracking for key backup toasts ([#31195](https://github.com/element-hq/element-web/pull/31195)). Contributed by @Half-Shot.
## 🐛 Bug Fixes
* Return to using Fira Code as the default monospace font ([#31302](https://github.com/element-hq/element-web/pull/31302)). Contributed by @ara4n.
* Fix case of home screen being displayed erroneously ([#31301](https://github.com/element-hq/element-web/pull/31301)). Contributed by @langleyd.
* Fix message edition and reply when multiple rooms at displayed the same moment ([#31280](https://github.com/element-hq/element-web/pull/31280)). Contributed by @florianduros.
* Key storage out of sync: reset key backup when needed ([#31279](https://github.com/element-hq/element-web/pull/31279)). Contributed by @uhoreg.
* Fix invalid events crashing entire room rather than just their tile ([#31256](https://github.com/element-hq/element-web/pull/31256)). Contributed by @t3chguy.
* Fix expand button of space panel getting cut off at the edges ([#31259](https://github.com/element-hq/element-web/pull/31259)). Contributed by @MidhunSureshR.
* Fix pill buttons in dialogs ([#31246](https://github.com/element-hq/element-web/pull/31246)). Contributed by @dbkr.
* Fix blank sections at the top and bottom of the member list when scrolling ([#31198](https://github.com/element-hq/element-web/pull/31198)). Contributed by @langleyd.
* Fix emoji category selection with keyboard ([#31162](https://github.com/element-hq/element-web/pull/31162)). Contributed by @langleyd.
Changes in [1.12.4](https://github.com/element-hq/element-web/releases/tag/v1.12.4) (2025-11-18)
================================================================================================
## ✨ Features
* Apply aria-hidden to emoji in SAS verification ([#31204](https://github.com/element-hq/element-web/pull/31204)). Contributed by @t3chguy.
* Add options to hide header and composer of room view for the module api ([#31095](https://github.com/element-hq/element-web/pull/31095)). Contributed by @florianduros.
* Experimental Module API Additions ([#30863](https://github.com/element-hq/element-web/pull/30863)). Contributed by @dbkr.
* Change polls to use fieldset/legend markup ([#31160](https://github.com/element-hq/element-web/pull/31160)). Contributed by @langleyd.
* Use compound Button styles for Jitsi button ([#31159](https://github.com/element-hq/element-web/pull/31159)). Contributed by @Half-Shot.
* Add FocusLock to emoji picker ([#31146](https://github.com/element-hq/element-web/pull/31146)). Contributed by @langleyd.
* Move room name, avatar, and topic to IOpts. ([#30981](https://github.com/element-hq/element-web/pull/30981)). Contributed by @kaylendog.
* Add a devtool for looking at users and their devices ([#30983](https://github.com/element-hq/element-web/pull/30983)). Contributed by @uhoreg.
## 🐛 Bug Fixes
* Fix room list handling of membership changes ([#31197](https://github.com/element-hq/element-web/pull/31197)). Contributed by @t3chguy.
* Fix room list unable to be resized when displayed after a module ([#31186](https://github.com/element-hq/element-web/pull/31186)). Contributed by @florianduros.
* Inhibit keyboard highlights in dialogs when effector is not in focus ([#31181](https://github.com/element-hq/element-web/pull/31181)). Contributed by @t3chguy.
* Strip mentions from forwarded messages ([#30884](https://github.com/element-hq/element-web/pull/30884)). Contributed by @twassman.
* Don't allow pin or edit of messages with a send status ([#31158](https://github.com/element-hq/element-web/pull/31158)). Contributed by @langleyd.
* Hide room header buttons if the room hasn't been created yet. ([#31092](https://github.com/element-hq/element-web/pull/31092)). Contributed by @Half-Shot.
* Fix screen readers not indicating the emoji picker search field is focused. ([#31128](https://github.com/element-hq/element-web/pull/31128)). Contributed by @langleyd.
* Fix emoji picker highlight missing when not active element ([#31148](https://github.com/element-hq/element-web/pull/31148)). Contributed by @t3chguy.
* Add relevant aria attribute for selected emoji in the emoji picker ([#31125](https://github.com/element-hq/element-web/pull/31125)). Contributed by @t3chguy.
* Fix tooltips within context menu portals being unreliable ([#31129](https://github.com/element-hq/element-web/pull/31129)). Contributed by @t3chguy.
* Avoid excessive re-render of room list and member list ([#31131](https://github.com/element-hq/element-web/pull/31131)). Contributed by @florianduros.
* Make emoji picker height responsive. ([#31130](https://github.com/element-hq/element-web/pull/31130)). Contributed by @langleyd.
* Emoji Picker: Focused emoji does not move with the arrow keys ([#30893](https://github.com/element-hq/element-web/pull/30893)). Contributed by @langleyd.
* Fix audio player seek bar position ([#31127](https://github.com/element-hq/element-web/pull/31127)). Contributed by @florianduros.
* Add aria label to emoji picker search ([#31126](https://github.com/element-hq/element-web/pull/31126)). Contributed by @langleyd.
Changes in [1.12.3](https://github.com/element-hq/element-web/releases/tag/v1.12.3) (2025-11-04)
================================================================================================
## 🦖 Deprecations

View File

@@ -1,6 +1,6 @@
{
"name": "element-web",
"version": "1.12.3",
"version": "1.12.6",
"description": "Element: the future of secure communication",
"author": "New Vector Ltd.",
"repository": {
@@ -82,13 +82,13 @@
},
"dependencies": {
"@babel/runtime": "^7.12.5",
"@element-hq/element-web-module-api": "1.5.0",
"@element-hq/element-web-module-api": "1.6.0",
"@element-hq/web-shared-components": "link:packages/shared-components",
"@fontsource/inconsolata": "^5",
"@fontsource/fira-code": "^5",
"@fontsource/inter": "^5",
"@formatjs/intl-segmenter": "^11.5.7",
"@matrix-org/analytics-events": "^0.30.0",
"@matrix-org/emojibase-bindings": "^1.3.4",
"@matrix-org/emojibase-bindings": "^1.5.0",
"@matrix-org/react-sdk-module-api": "^2.4.0",
"@matrix-org/spec": "^1.7.0",
"@sentry/browser": "^10.0.0",
@@ -109,7 +109,7 @@
"diff-dom": "^5.0.0",
"diff-match-patch": "^1.0.5",
"domutils": "^3.2.2",
"emojibase-regex": "15.3.2",
"emojibase-regex": "^17.0.0",
"escape-html": "^1.0.3",
"file-saver": "^2.0.5",
"filesize": "11.0.13",
@@ -131,8 +131,8 @@
"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-widget-api": "^1.10.0",
"matrix-js-sdk": "39.3.0",
"matrix-widget-api": "^1.14.0",
"memoize-one": "^6.0.0",
"mime": "^4.0.4",
"oidc-client-ts": "^3.0.1",
@@ -313,7 +313,7 @@
"relativePaths": true
},
"engines": {
"node": ">=24"
"node": ">=22.18"
},
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
}

View File

@@ -1,6 +1,6 @@
{
"name": "@element-hq/web-shared-components",
"version": "0.0.0-test.7",
"version": "0.0.0-test.8",
"description": "Shared components for Element",
"author": "New Vector Ltd.",
"repository": {

View File

@@ -53,7 +53,13 @@ export function Pill({ className, children, label, onClick, ...props }: PropsWit
{label}
</span>
{onClick && (
<IconButton aria-describedby={id} size="16px" onClick={onClick} aria-label={_t("action|delete")}>
<IconButton
aria-describedby={id}
size="16px"
onClick={onClick}
aria-label={_t("action|delete")}
className="mx_Dialog_nonDialogButton"
>
<CloseIcon color="var(--cpd-color-icon-tertiary)" />
</IconButton>
)}

View File

@@ -18,7 +18,7 @@ exports[`Pill renders the pill 1`] = `
<button
aria-describedby="_r_0_"
aria-label="Delete"
class="_icon-button_1pz9o_8"
class="_icon-button_1pz9o_8 mx_Dialog_nonDialogButton"
data-kind="primary"
role="button"
style="--cpd-icon-button-size: 16px;"

View File

@@ -49,7 +49,7 @@ test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => {
/**
* Take snapshots of mx_EventTile_last on each layout, outputting log for reference/debugging.
* @param detail The snapshot name. Used for outputting logs too.
* @param monospace This changes the font used to render the UI from a default one to Inconsolata. Set to false by default.
* @param monospace This changes the font used to render the UI from a default one to Fira Code. Set to false by default.
*/
const takeSnapshots = async (page: Page, app: ElementAppPage, detail: string, monospace = false) => {
// Check that the audio player is rendered and its button becomes visible
@@ -65,7 +65,7 @@ test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => {
if (monospace) {
// Assert that the monospace timer is visible
await expect(locator.locator("[role='timer']")).toHaveCSS("font-family", "Inconsolata");
await expect(locator.locator("[role='timer']")).toHaveCSS("font-family", '"Fira Code"');
}
};
@@ -73,7 +73,7 @@ test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => {
// Enable system font and monospace setting
await app.settings.setValue("useBundledEmojiFont", null, SettingLevel.DEVICE, false);
await app.settings.setValue("useSystemFont", null, SettingLevel.DEVICE, true);
await app.settings.setValue("systemFont", null, SettingLevel.DEVICE, "Inconsolata");
await app.settings.setValue("systemFont", null, SettingLevel.DEVICE, "Fira Code");
}
// Check the status of the seek bar

View File

@@ -49,7 +49,10 @@ test.describe("Encryption state after registration", () => {
"Pa$sW0rD!",
);
await page.getByRole("navigation", { name: "Room list" }).getByRole("button", { name: "Add" }).click();
await page
.getByRole("navigation", { name: "Room list" })
.getByRole("button", { name: "New conversation" })
.click();
await page.getByRole("menuitem", { name: "New room" }).click();
await page.getByRole("textbox", { name: "Name" }).fill("test room");
await page.getByRole("button", { name: "Create room" }).click();
@@ -78,7 +81,10 @@ test.describe("Key backup reset from elsewhere", () => {
await page.getByRole("button", { name: "Continue" }).click();
await registerAccountMas(page, mailpitClient, testUsername, `${testUsername}@email.com`, testPassword);
await page.getByRole("navigation", { name: "Room list" }).getByRole("button", { name: "Add" }).click();
await page
.getByRole("navigation", { name: "Room list" })
.getByRole("button", { name: "New conversation" })
.click();
await page.getByRole("menuitem", { name: "New room" }).click();
await page.getByRole("textbox", { name: "Name" }).fill("test room");
await page.getByRole("button", { name: "Create room" }).click();

View File

@@ -21,7 +21,7 @@ const checkDMRoom = async (page: Page) => {
};
const startDMWithBob = async (page: Page, bob: Bot) => {
await page.getByRole("navigation", { name: "Room list" }).getByRole("button", { name: "Add" }).click();
await page.getByRole("navigation", { name: "Room list" }).getByRole("button", { name: "New conversation" }).click();
await page.getByRole("menuitem", { name: "Start chat" }).click();
await page.getByTestId("invite-dialog-input").fill(bob.credentials.userId);
await page.getByRole("option", { name: bob.credentials.displayName }).click();

View File

@@ -23,7 +23,10 @@ test.describe("Key storage out of sync toast", () => {
await deleteCachedSecrets(page);
// We won't be prompted for crypto setup unless we have an e2e room, so make one
await page.getByRole("navigation", { name: "Room list" }).getByRole("button", { name: "Add" }).click();
await page
.getByRole("navigation", { name: "Room list" })
.getByRole("button", { name: "New conversation" })
.click();
await page.getByRole("menuitem", { name: "New room" }).click();
await page.getByRole("textbox", { name: "Name" }).fill("Test room");
await page.getByRole("button", { name: "Create room" }).click();
@@ -68,7 +71,10 @@ test.describe("'Turn on key storage' toast", () => {
await logIntoElementAndVerify(page, credentials, recoveryKey.encodedPrivateKey);
// We won't be prompted for crypto setup unless we have an e2e room, so make one
await page.getByRole("navigation", { name: "Room list" }).getByRole("button", { name: "Add" }).click();
await page
.getByRole("navigation", { name: "Room list" })
.getByRole("button", { name: "New conversation" })
.click();
await page.getByRole("menuitem", { name: "New room" }).click();
await page.getByRole("textbox", { name: "Name" }).fill("Test room");
await page.getByRole("button", { name: "Create room" }).click();

View File

@@ -438,7 +438,7 @@ export async function sendMessageInCurrentRoom(page: Page, message: string): Pro
* @param isEncrypted - Whether the room should be encrypted
*/
export async function createRoom(page: Page, roomName: string, isEncrypted: boolean): Promise<void> {
await page.getByRole("navigation", { name: "Room list" }).getByRole("button", { name: "Add" }).click();
await page.getByRole("navigation", { name: "Room list" }).getByRole("button", { name: "New conversation" }).click();
await page.getByRole("menuitem", { name: "New room" }).click();
const dialog = page.locator(".mx_Dialog");

View File

@@ -73,7 +73,10 @@ test.describe("Invite dialog", function () {
"should support inviting a user to Direct Messages",
{ tag: "@screenshot" },
async ({ page, app, user, bot }) => {
await page.getByRole("navigation", { name: "Room list" }).getByRole("button", { name: "Add" }).click();
await page
.getByRole("navigation", { name: "Room list" })
.getByRole("button", { name: "New conversation" })
.click();
await page.getByRole("menuitem", { name: "Start chat" }).click();
const other = page.locator(".mx_InviteDialog_other");

View File

@@ -30,7 +30,7 @@ test.describe("Header section of the room list", () => {
const roomListHeader = getHeaderSection(page);
await expect(roomListHeader).toMatchScreenshot("room-list-header.png");
const composeMenu = roomListHeader.getByRole("button", { name: "Add" });
const composeMenu = roomListHeader.getByRole("button", { name: "New conversation" });
await composeMenu.click();
await expect(page.getByRole("menu")).toMatchScreenshot("room-list-header-compose-menu.png");
@@ -55,7 +55,7 @@ test.describe("Header section of the room list", () => {
await expect(roomListHeader).toMatchScreenshot("room-list-space-header.png");
await expect(roomListHeader.getByRole("heading", { name: "MySpace" })).toBeVisible();
await expect(roomListHeader.getByRole("button", { name: "Add" })).toBeVisible();
await expect(roomListHeader.getByRole("button", { name: "New conversation" })).toBeVisible();
const spaceMenu = roomListHeader.getByRole("button", { name: "Open space menu" });
await spaceMenu.click();

View File

@@ -315,7 +315,10 @@ test.describe("Room list", () => {
});
test("should be a video room", { tag: "@screenshot" }, async ({ page, app, user }) => {
await page.getByRole("navigation", { name: "Room list" }).getByRole("button", { name: "Add" }).click();
await page
.getByRole("navigation", { name: "Room list" })
.getByRole("button", { name: "New conversation" })
.click();
await page.getByRole("menuitem", { name: "New video room" }).click();
await page.getByRole("textbox", { name: "Name" }).fill("video room");
await page.getByRole("button", { name: "Create video room" }).click();

View File

@@ -46,24 +46,21 @@ test.describe("Location sharing", { tag: "@no-firefox" }, () => {
await submitShareLocation(page);
await page.locator(".mx_RoomView_body .mx_EventTile .mx_MLocationBody").click({
position: {
x: 225,
y: 150,
},
});
await page.getByRole("button", { name: "Map marker" }).click();
// Wait for map to load
await expect(page.getByRole("region", { name: "Map" })).toMatchScreenshot(
const dialog = page.getByRole("dialog");
// wait for the dialog to be visible
await expect(dialog).toBeVisible();
// screenshot the map within the dialog
await expect(dialog.getByRole("region", { name: "Map" })).toMatchScreenshot(
"location-pin-drop-message-map.png",
);
// clicking location tile opens maximised map
await expect(page.getByRole("dialog")).toBeVisible();
await app.closeDialog();
await expect(page.locator(".mx_Marker")).toBeVisible();
await expect(page.getByRole("button", { name: "Map marker" })).toBeVisible();
},
);

View File

@@ -164,7 +164,7 @@ test.describe("RightPanel", () => {
css: `
/* Use monospace font for consistent mask width */
.mx_UserInfo_profile_mxid {
font-family: Inconsolata !important;
font-family: "Fira Code" !important;
}
`,
});

View File

@@ -45,7 +45,7 @@ test.describe("Create Room", () => {
);
test("should allow us to start a chat and show encryption state", async ({ page, user, app }) => {
await page.getByRole("button", { name: "Add", exact: true }).click();
await page.getByRole("button", { name: "New conversation", exact: true }).click();
await page.getByRole("menuitem", { name: "Start chat" }).click();
await page.getByTestId("invite-dialog-input").fill(user.userId);

View File

@@ -373,7 +373,7 @@ test.describe("Threads", () => {
// Exclude timestamp, read marker, and maplibregl-map from snapshots
const css =
".mx_MessageTimestamp, .mx_MessagePanel_myReadMarker, .maplibregl-map { visibility: hidden !important; }";
".mx_MessageTimestamp, .mx_MessagePanel_myReadMarker, .maplibregl-map, .maplibregl-ctrl-attrib { visibility: hidden !important; }";
let locator = page.locator(".mx_RoomView_body");
// User sends message

View File

@@ -24,7 +24,7 @@ test.describe("UserView", () => {
css: `
/* Use monospace font for consistent mask width */
.mx_UserInfo_profile_mxid {
font-family: Inconsolata !important;
font-family: "Fira Code" !important;
}
`,
});

View File

@@ -9,7 +9,7 @@ import type { EventType, Preset } from "matrix-js-sdk/src/matrix";
import { SettingLevel } from "../../../src/settings/SettingLevel";
import { test, expect } from "../../element-web-test";
import type { Credentials } from "../../plugins/homeserver";
import type { Bot } from "../../pages/bot";
import { Bot } from "../../pages/bot";
function assertCommonCallParameters(
url: URLSearchParams,
@@ -27,27 +27,28 @@ function assertCommonCallParameters(
expect(hash.get("preload")).toEqual("false");
}
async function sendRTCState(bot: Bot, roomId: string, notification?: "ring" | "notification") {
async function sendRTCState(bot: Bot, roomId: string, notification?: "ring" | "notification", intent?: string) {
const resp = await bot.sendStateEvent(
roomId,
"org.matrix.msc3401.call.member",
{
application: "m.call",
call_id: "",
device_id: "OiDFxsZrjz",
expires: 180000000,
foci_preferred: [
"application": "m.call",
"call_id": "",
"m.call.intent": intent,
"device_id": "OiDFxsZrjz",
"expires": 180000000,
"foci_preferred": [
{
livekit_alias: roomId,
livekit_service_url: "https://example.org",
type: "livekit",
},
],
focus_active: {
"focus_active": {
focus_selection: "oldest_membership",
type: "livekit",
},
scope: "m.room",
"scope": "m.room",
},
`_@${bot.credentials.userId}_OiDFxsZrjz_m.call`,
);
@@ -64,6 +65,7 @@ async function sendRTCState(bot: Bot, roomId: string, notification?: "ring" | "n
event_id: resp.event_id,
rel_type: "org.matrix.msc4075.rtc.notification.parent",
},
"m.call.intent": intent,
"notification_type": notification,
"sender_ts": 1758611895996,
});
@@ -103,15 +105,21 @@ test.describe("Element Call", () => {
});
test.describe("Group Chat", () => {
let charlie: Bot;
test.use({
room: async ({ page, app, user, bot }, use) => {
const roomId = await app.client.createRoom({ name: "TestRoom", invite: [bot.credentials.userId] });
room: async ({ page, app, user, homeserver, bot }, use) => {
charlie = new Bot(page, homeserver, { displayName: "Charlie" });
await charlie.prepareClient();
const roomId = await app.client.createRoom({
name: "TestRoom",
invite: [bot.credentials.userId, charlie.credentials.userId],
});
await use({ roomId });
},
});
test("should be able to start a video call", async ({ page, user, room, app }) => {
await app.viewRoomById(room.roomId);
await expect(page.getByText("Bob joined the room")).toBeVisible();
await expect(page.getByText("Bob and one other were invited and joined")).toBeVisible();
await page.getByRole("button", { name: "Video call" }).click();
await page.getByRole("menuitem", { name: "Element Call" }).click();
@@ -126,9 +134,16 @@ test.describe("Element Call", () => {
expect(hash.get("skipLobby")).toEqual(null);
});
test("should NOT be able to start a voice call", async ({ page, user, room, app }) => {
// Voice calls do not exist in group rooms
await app.viewRoomById(room.roomId);
await expect(page.getByText("Bob and one other were invited and joined")).toBeVisible();
await expect(page.getByRole("button", { name: "Voice call" })).not.toBeVisible();
});
test("should be able to skip lobby by holding down shift", async ({ page, user, bot, room, app }) => {
await app.viewRoomById(room.roomId);
await expect(page.getByText("Bob joined the room")).toBeVisible();
await expect(page.getByText("Bob and one other were invited and joined")).toBeVisible();
await page.getByRole("button", { name: "Video call" }).click();
await page.keyboard.down("Shift");
@@ -147,8 +162,8 @@ test.describe("Element Call", () => {
test("should be able to join a call in progress", async ({ page, user, bot, room, app }) => {
await app.viewRoomById(room.roomId);
// Allow bob to create a call
await expect(page.getByText("Bob and one other were invited and joined")).toBeVisible();
await app.client.setPowerLevel(room.roomId, bot.credentials.userId, 50);
await expect(page.getByText("Bob joined the room")).toBeVisible();
// Fake a start of a call
await sendRTCState(bot, room.roomId);
const button = page.getByTestId("join-call-button");
@@ -156,7 +171,6 @@ test.describe("Element Call", () => {
// And test joining
await button.click();
const frameUrlStr = await page.locator("iframe").getAttribute("src");
console.log(frameUrlStr);
await expect(frameUrlStr).toBeDefined();
const url = new URL(frameUrlStr);
const hash = new URLSearchParams(url.hash.slice(1));
@@ -168,29 +182,29 @@ test.describe("Element Call", () => {
[true, false].forEach((skipLobbyToggle) => {
test(
`should be able to join a call via incoming call toast (skipLobby=${skipLobbyToggle})`,
`should be able to join a call via incoming video call toast (skipLobby=${skipLobbyToggle})`,
{ tag: ["@screenshot"] },
async ({ page, user, bot, room, app }) => {
await app.viewRoomById(room.roomId);
// Allow bob to create a call
await expect(page.getByText("Bob and one other were invited and joined")).toBeVisible();
await app.client.setPowerLevel(room.roomId, bot.credentials.userId, 50);
await expect(page.getByText("Bob joined the room")).toBeVisible();
// Fake a start of a call
await sendRTCState(bot, room.roomId, "notification");
await sendRTCState(bot, room.roomId, "notification", "video");
const toast = page.locator(".mx_Toast_toast");
const button = toast.getByRole("button", { name: "Join" });
if (skipLobbyToggle) {
await toast.getByRole("switch").check();
await expect(toast).toMatchScreenshot("incoming-call-group-video-toast-checked.png");
await expect(toast).toMatchScreenshot(`incoming-call-group-video-toast-checked.png`);
} else {
await toast.getByRole("switch").uncheck();
await expect(toast).toMatchScreenshot("incoming-call-group-video-toast-unchecked.png");
await expect(toast).toMatchScreenshot(`incoming-call-group-video-toast-unchecked.png`);
}
// And test joining
await button.click();
const frameUrlStr = await page.locator("iframe").getAttribute("src");
console.log(frameUrlStr);
await expect(frameUrlStr).toBeDefined();
const url = new URL(frameUrlStr);
const hash = new URLSearchParams(url.hash.slice(1));
@@ -201,6 +215,34 @@ test.describe("Element Call", () => {
},
);
});
test(
`should be able to join a call via incoming voice call toast`,
{ tag: ["@screenshot"] },
async ({ page, user, bot, room, app }) => {
await app.viewRoomById(room.roomId);
// Allow bob to create a call
await expect(page.getByText("Bob and one other were invited and joined")).toBeVisible();
await app.client.setPowerLevel(room.roomId, bot.credentials.userId, 50);
// Fake a start of a call
await sendRTCState(bot, room.roomId, "notification", "audio");
const toast = page.locator(".mx_Toast_toast");
const button = toast.getByRole("button", { name: "Join" });
await expect(toast).toMatchScreenshot(`incoming-call-group-voice-toast.png`);
// And test joining
await button.click();
const frameUrlStr = await page.locator("iframe").getAttribute("src");
await expect(frameUrlStr).toBeDefined();
const url = new URL(frameUrlStr);
const hash = new URLSearchParams(url.hash.slice(1));
assertCommonCallParameters(url.searchParams, hash, user, room);
expect(hash.get("intent")).toEqual("join_existing");
expect(hash.get("skipLobby")).toEqual("true");
},
);
});
test.describe("DMs", () => {
@@ -253,7 +295,6 @@ test.describe("Element Call", () => {
test("should be able to join a call in progress", async ({ page, user, bot, room, app }) => {
await app.viewRoomById(room.roomId);
// Allow bob to create a call
await expect(page.getByText("Bob joined the room")).toBeVisible();
// Fake a start of a call
await sendRTCState(bot, room.roomId);
@@ -262,7 +303,6 @@ test.describe("Element Call", () => {
// And test joining
await button.click();
const frameUrlStr = await page.locator("iframe").getAttribute("src");
console.log(frameUrlStr);
await expect(frameUrlStr).toBeDefined();
const url = new URL(frameUrlStr);
const hash = new URLSearchParams(url.hash.slice(1));
@@ -278,24 +318,31 @@ test.describe("Element Call", () => {
{ tag: ["@screenshot"] },
async ({ page, user, bot, room, app }) => {
await app.viewRoomById(room.roomId);
// Allow bob to create a call
await expect(page.getByText("Bob joined the room")).toBeVisible();
// Fake a start of a call
await sendRTCState(bot, room.roomId, "ring");
await sendRTCState(bot, room.roomId, "ring", "video");
const toast = page.locator(".mx_Toast_toast");
const button = toast.getByRole("button", { name: "Join" });
const button = toast.getByRole("button", { name: "Accept" });
if (skipLobbyToggle) {
await toast.getByRole("switch").check();
await expect(toast).toMatchScreenshot("incoming-call-dm-video-toast-checked.png");
} else {
await toast.getByRole("switch").uncheck();
await expect(toast).toMatchScreenshot("incoming-call-dm-video-toast-unchecked.png");
}
await expect(toast).toMatchScreenshot(
`incoming-call-dm-video-toast-${skipLobbyToggle ? "checked" : "unchecked"}.png`,
{
// Hide UserId
css: `
.mx_IncomingCallToast_AvatarWithDetails span:nth-child(2) {
opacity: 0;
}
`,
},
);
// And test joining
await button.click();
const frameUrlStr = await page.locator("iframe").getAttribute("src");
console.log(frameUrlStr);
await expect(frameUrlStr).toBeDefined();
const url = new URL(frameUrlStr);
const hash = new URLSearchParams(url.hash.slice(1));
@@ -306,6 +353,39 @@ test.describe("Element Call", () => {
},
);
});
test(
`should be able to join a call via incoming voice call toast`,
{ tag: ["@screenshot"] },
async ({ page, user, bot, room, app }) => {
await app.viewRoomById(room.roomId);
await expect(page.getByText("Bob joined the room")).toBeVisible();
// Fake a start of a call
await sendRTCState(bot, room.roomId, "ring", "audio");
const toast = page.locator(".mx_Toast_toast");
const button = toast.getByRole("button", { name: "Accept" });
await expect(toast).toMatchScreenshot(`incoming-call-dm-voice-toast.png`, {
// Hide UserId
css: `
.mx_IncomingCallToast_AvatarWithDetails span:nth-child(2) {
opacity: 0;
}
`,
});
// And test joining
await button.click();
const frameUrlStr = await page.locator("iframe").getAttribute("src");
await expect(frameUrlStr).toBeDefined();
const url = new URL(frameUrlStr);
const hash = new URLSearchParams(url.hash.slice(1));
assertCommonCallParameters(url.searchParams, hash, user, room);
expect(hash.get("intent")).toEqual("join_existing_dm_voice");
expect(hash.get("skipLobby")).toEqual("true");
},
);
});
test.describe("Video Rooms", () => {
@@ -318,7 +398,10 @@ test.describe("Element Call", () => {
},
});
test("should be able to create and join a video room", async ({ page, user }) => {
await page.getByRole("navigation", { name: "Room list" }).getByRole("button", { name: "Add" }).click();
await page
.getByRole("navigation", { name: "Room list" })
.getByRole("button", { name: "New conversation" })
.click();
await page.getByRole("menuitem", { name: "New video room" }).click();
await page.getByRole("textbox", { name: "Name" }).fill("Test room");
await page.getByRole("button", { name: "Create video room" }).click();

View File

@@ -144,7 +144,7 @@ export const expect = baseExpect.extend<Expectations>({
}
/* Use monospace font for timestamp for consistent mask width */
.mx_MessageTimestamp {
font-family: Inconsolata !important;
font-family: "Fira Code" !important;
}
`;

View File

@@ -53,7 +53,10 @@ export class ElementAppPage {
*/
public async openCreateRoomDialog(roomKindname: "New room" | "New video room" = "New room"): Promise<Locator> {
await this.page.getByRole("navigation", { name: "Room list" }).getByRole("button", { name: "Add" }).click();
await this.page
.getByRole("navigation", { name: "Room list" })
.getByRole("button", { name: "New conversation" })
.click();
await this.page.getByRole("menuitem", { name: roomKindname }).click();
return this.page.locator(".mx_CreateRoomDialog");
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 260 KiB

After

Width:  |  Height:  |  Size: 260 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -10,7 +10,7 @@ import {
type StartedPostgreSqlContainer,
} from "@element-hq/element-web-playwright-common/lib/testcontainers";
const TAG = "main@sha256:85206c66691961e4472178900308885874486984c22de79febd5c34875a62f59";
const TAG = "main@sha256:cebb2d1064e942e03713bcc00f96a9c6f345698dafc28be471ab5084bef97033";
/**
* MatrixAuthenticationServiceContainer which freezes the docker digest to

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:32927e7fc8685e4d065d4ec38a89a2cab6aa0fbcd7c669053f56aa7831d71acc";
const TAG = "develop@sha256:21d2595edd0f3172fe57b9a65e511632e3a9f9ab7bba3ef61965f4cab870107d";
/**
* SynapseContainer which freezes the docker digest to stabilise tests,

View File

@@ -18,9 +18,6 @@ Please see LICENSE files in the repository root for full details.
flex-direction: column;
max-width: 50%;
position: relative;
/* Contain the amount of layers rendered by constraining what actually needs re-layering via css */
contain: layout paint;
}
.mx_LeftPanel_wrapper,

View File

@@ -25,6 +25,10 @@ Please see LICENSE files in the repository root for full details.
mask-image: url("$(res)/img/element-icons/call/video-call.svg");
}
&.mx_LiveContentSummary_text_voice::before {
mask-image: url("$(res)/img/element-icons/call/voice-call.svg");
}
&.mx_LiveContentSummary_text_active {
color: $accent;

View File

@@ -45,7 +45,7 @@
color: var(--cpd-color-text-secondary);
.mx_KeyPanel_key {
font-family: Inconsolata, monospace;
font-family: "Fira Code", monospace;
/*
* From figma https://www.figma.com/design/qTWRfItpO3RdCjnTKPu4mL/Settings?node-id=375-77471&t=t7lozYrSI1AVZZ3U-4
*/

View File

@@ -15,7 +15,7 @@ $font-family:
"Noto Color Emoji";
$monospace-font-family:
"Inconsolata", var(--emoji-font-family), "Apple Color Emoji", "Segoe UI Emoji", "Courier", monospace,
"Fira Code", var(--emoji-font-family), "Apple Color Emoji", "Segoe UI Emoji", "Courier", monospace,
"Noto Color Emoji";
/* unified palette */

View File

@@ -15,7 +15,7 @@ $font-family:
"Noto Color Emoji";
$monospace-font-family:
"Inconsolata", var(--emoji-font-family), "Apple Color Emoji", "Segoe UI Emoji", "Courier", monospace,
"Fira Code", var(--emoji-font-family), "Apple Color Emoji", "Segoe UI Emoji", "Courier", monospace,
"Noto Color Emoji";
/* Colors from Figma Compound https://www.figma.com/file/X4XTH9iS2KGJ2wFKDqkyed/Compound?node-id=559%3A120 */

View File

@@ -5,7 +5,7 @@ import path from "node:path";
import YAML from "yaml";
import parseArgs from "minimist";
import cronstrue from "cronstrue";
import { partition } from "lodash";
import _ from "lodash";
const argv = parseArgs<{
debug: boolean;
@@ -81,7 +81,7 @@ class Graph<T extends Node> {
public removeNode(node: T): Edge<T>[] {
if (!this.nodes.has(node.id)) return [];
this.nodes.delete(node.id);
const [removedEdges, keptEdges] = partition(
const [removedEdges, keptEdges] = _.partition(
this.edges,
([source, destination]) => source === node || destination === node,
);
@@ -384,6 +384,7 @@ class MermaidFlowchartPrinter {
private static INDENT = 4;
private currentIndent = 0;
private text = "";
private readonly markdown: boolean;
public readonly idGenerator = new IdGenerator();
private print(text: string): void {
@@ -400,11 +401,8 @@ class MermaidFlowchartPrinter {
this.currentIndent += delta * MermaidFlowchartPrinter.INDENT;
}
public constructor(
direction: "TD" | "TB" | "BT" | "RL" | "LR",
title?: string,
private readonly markdown = false,
) {
public constructor(direction: "TD" | "TB" | "BT" | "RL" | "LR", title?: string, markdown = false) {
this.markdown = markdown;
if (this.markdown) {
this.print("```mermaid");
}

View File

@@ -88,7 +88,7 @@ export default abstract class BasePlatform {
protected onAction(payload: ActionPayload): void {
switch (payload.action) {
case "on_client_not_viable":
case Action.ClientNotViable:
case Action.OnLoggedOut:
this.setNotificationCount(0);
break;

View File

@@ -1,4 +1,5 @@
/*
Copyright 2025 Element Creations Ltd.
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
@@ -144,6 +145,25 @@ export default class DeviceListener {
this.client = undefined;
}
/**
* Pause the device listener while a function runs.
*
* This can be done if the function makes several changes that would trigger
* multiple events, to suppress warning toasts until the process is
* finished.
*/
public async whilePaused(fn: () => Promise<void>): Promise<void> {
const client = this.client;
try {
this.stop();
await fn();
} finally {
if (client) {
this.start(client);
}
}
}
/**
* Dismiss notifications about our own unverified devices
*
@@ -177,6 +197,67 @@ export default class DeviceListener {
await this.client?.setAccountData(RECOVERY_ACCOUNT_DATA_KEY, { enabled: false });
}
/**
* If a `Kind.KEY_STORAGE_OUT_OF_SYNC` condition from {@link doRecheck}
* requires a reset of cross-signing keys.
*
* We will reset cross-signing keys if both our local cache and 4S don't
* have all cross-signing keys.
*
* In theory, if the set of keys in our cache and in 4S are different, and
* we have a complete set between the two, we could be OK, but that
* should be exceptionally rare, and is more complicated to detect.
*/
public async keyStorageOutOfSyncNeedsCrossSigningReset(forgotRecovery: boolean): Promise<boolean> {
const crypto = this.client?.getCrypto();
if (!crypto) {
return false;
}
const crossSigningStatus = await crypto.getCrossSigningStatus();
const allCrossSigningSecretsCached =
crossSigningStatus.privateKeysCachedLocally.masterKey &&
crossSigningStatus.privateKeysCachedLocally.selfSigningKey &&
crossSigningStatus.privateKeysCachedLocally.userSigningKey;
if (forgotRecovery) {
return !allCrossSigningSecretsCached;
} else {
return !allCrossSigningSecretsCached && !crossSigningStatus.privateKeysInSecretStorage;
}
}
/**
* If a `Kind.KEY_STORAGE_OUT_OF_SYNC` condition from {@link doRecheck}
* requires a reset of key backup.
*
* If the user has their recovery key, we need to reset backup if:
* - the user hasn't disabled backup,
* - we don't have the backup key cached locally, *and*
* - we don't have the backup key stored in 4S.
* (The user should already have a key backup created at this point,
* otherwise `doRecheck` would have triggered a `Kind.TURN_ON_KEY_STORAGE`
* condition.)
*
* If the user has forgotten their recovery key, we need to reset backup if:
* - the user hasn't disabled backup, and
* - we don't have the backup key locally.
*/
public async keyStorageOutOfSyncNeedsBackupReset(forgotRecovery: boolean): Promise<boolean> {
const crypto = this.client?.getCrypto();
if (!crypto) {
return false;
}
const shouldHaveBackup = !(await this.recheckBackupDisabled(this.client!));
const backupKeyCached = (await crypto.getSessionBackupPrivateKey()) !== null;
const backupKeyStored = await this.client!.isKeyBackupKeyStored();
if (forgotRecovery) {
return shouldHaveBackup && !backupKeyCached;
} else {
return shouldHaveBackup && !backupKeyCached && !backupKeyStored;
}
}
private async ensureDeviceIdsAtStartPopulated(): Promise<void> {
if (this.ourDeviceIdsAtStart === null) {
this.ourDeviceIdsAtStart = await this.getDeviceIds();
@@ -318,12 +399,6 @@ export default class DeviceListener {
const cli = this.client;
// cross-signing support was added to Matrix in MSC1756, which landed in spec v1.1
if (!(await cli.isVersionSupported("v1.1"))) {
logSpan.debug("cross-signing not supported");
return;
}
const crypto = cli.getCrypto();
if (!crypto) {
logSpan.debug("crypto not enabled");
@@ -363,7 +438,10 @@ export default class DeviceListener {
// said we are OK with that.
const keyBackupIsOk = keyBackupUploadActive || backupDisabled;
const allSystemsReady = isCurrentDeviceTrusted && allCrossSigningSecretsCached && keyBackupIsOk && recoveryIsOk;
const backupKeyCached = (await crypto.getSessionBackupPrivateKey()) !== null;
const allSystemsReady =
isCurrentDeviceTrusted && allCrossSigningSecretsCached && keyBackupIsOk && recoveryIsOk && backupKeyCached;
await this.reportCryptoSessionStateToAnalytics(cli);
@@ -407,15 +485,22 @@ export default class DeviceListener {
}
} else {
// If we get here, then we are verified, have key backup, and
// 4S, but crypto.isSecretStorageReady returned false, which
// means that 4S doesn't have all the secrets.
logSpan.warn("4S is missing secrets", {
// 4S, but allSystemsReady is false, which means that either
// secretStorageStatus.ready is false (which means that 4S
// doesn't have all the secrets), or we don't have the backup
// key cached locally.
logSpan.warn("4S is missing secrets or backup key not cached", {
crossSigningReady,
secretStorageStatus,
allCrossSigningSecretsCached,
isCurrentDeviceTrusted,
backupKeyCached,
});
showSetupEncryptionToast(SetupKind.KEY_STORAGE_OUT_OF_SYNC_STORE);
// We use the right toast variant based on whether the backup
// key is missing locally. If any of the cross-signing keys are
// missing locally, that is handled by the
// `!allCrossSigningSecretsCached` branch above.
showSetupEncryptionToast(SetupKind.KEY_STORAGE_OUT_OF_SYNC);
}
} else {
logSpan.info("Not yet ready, but shouldShowSetupEncryptionToast==false");

View File

@@ -599,6 +599,9 @@ async function abortLogin(): Promise<void> {
}
/** Attempt to restore the session from localStorage or indexeddb.
*
* If the credentials are found, and the session is successfully restored,
* emits {@link Action.OnLoggedIn}, {@link Action.WillStartClient} and {@link Action.StartedClient}.
*
* @returns true if a session was found; false if no existing session was found.
*
@@ -787,6 +790,8 @@ async function createOidcTokenRefresher(credentials: IMatrixClientCreds): Promis
* optionally clears localstorage, persists new credentials
* to localstorage, starts the new client.
*
* Emits {@link Action.OnLoggedIn}, {@link Action.WillStartClient} and {@link Action.StartedClient}.
*
* @param {IMatrixClientCreds} credentials The credentials to use
* @param {Boolean} clearStorageEnabled True to clear storage before starting the new client
* @param {Boolean} isFreshLogin True if this is a fresh login, false if it is previous session being restored
@@ -1001,7 +1006,7 @@ export function softLogout(): void {
// Ensure that we dispatch a view change **before** stopping the client so
// so that React components unmount first. This avoids React soft crashes
// that can occur when components try to use a null client.
dis.dispatch({ action: "on_client_not_viable" }); // generic version of on_logged_out
dis.dispatch({ action: Action.ClientNotViable }); // generic version of on_logged_out
stopMatrixClient(/*unsetClient=*/ false);
// DO NOT CALL LOGOUT. A soft logout preserves data, logout does not.
@@ -1019,6 +1024,12 @@ export function isLoggingOut(): boolean {
* Starts the matrix client and all other react-sdk services that
* listen for events while a session is logged in.
*
* By the time this method is called, we have successfully logged in if necessary, and the client has been set up with
* the access token.
*
* Emits {@link Acction.WillStartClient} before starting the client, and {@link Action.ClientStarted} when the client has
* been started.
*
* @param client the matrix client to start
* @param startSyncing - `true` to actually start syncing the client.
* @param clientPegOpts - Options to pass through to {@link MatrixClientPeg.start}.
@@ -1034,7 +1045,7 @@ async function startMatrixClient(
// to add listeners for the 'sync' event so otherwise we'd have
// a race condition (and we need to dispatch synchronously for this
// to work).
dis.dispatch({ action: "will_start_client" }, true);
dis.dispatch({ action: Action.WillStartClient }, true);
// reset things first just in case
SdkContextClass.instance.typingStore.reset();
@@ -1080,7 +1091,7 @@ async function startMatrixClient(
// dispatch that we finished starting up to wire up any other bits
// of the matrix client that cannot be set prior to starting up.
dis.dispatch({ action: "client_started" });
dis.dispatch({ action: Action.ClientStarted });
if (isSoftLogout()) {
softLogout();

View File

@@ -15,6 +15,7 @@ import { MatrixClientPeg } from "./MatrixClientPeg";
import dis from "./dispatcher/dispatcher";
import Timer from "./utils/Timer";
import { type ActionPayload } from "./dispatcher/payloads";
import { Action } from "./dispatcher/actions.ts";
// Time in ms after that a user is considered as unavailable/away
const UNAVAILABLE_TIME_MS = 3 * 60 * 1000; // 3 mins
@@ -61,7 +62,7 @@ class Presence {
}
private onAction = (payload: ActionPayload): void => {
if (payload.action === "user_activity") {
if (payload.action === Action.UserActivity) {
this.setState(SetPresence.Online);
this.unavailableTimer?.restart();
}

View File

@@ -939,7 +939,13 @@ for (const evType of ElementCallEventType.names) {
*/
export function hasText(ev: MatrixEvent, client: MatrixClient, showHiddenEvents?: boolean): boolean {
const handler = (ev.isState() ? stateHandlers : handlers)[ev.getType()];
return Boolean(handler?.(ev, client, false, showHiddenEvents));
try {
return Boolean(handler?.(ev, client, false, showHiddenEvents));
} catch (e) {
console.error(`Error encountered when trying to render event type=${ev.getType()} id=${ev.getId()}`, e);
// Returning true if we have a handler so we can show an error tile rather than no tile at all
return !!handler;
}
}
/**

View File

@@ -8,6 +8,7 @@ Please see LICENSE files in the repository root for full details.
import dis from "./dispatcher/dispatcher";
import Timer from "./utils/Timer";
import { Action } from "./dispatcher/actions.ts";
// important these are larger than the timeouts of timers
// used with UserActivity.timeWhileActive*,
@@ -190,11 +191,9 @@ export default class UserActivity {
this.lastScreenY = event.screenY;
}
dis.dispatch({ action: "user_activity" });
dis.dispatch({ action: Action.UserActivity });
if (!this.activeNowTimeout.isRunning()) {
this.activeNowTimeout.start();
dis.dispatch({ action: "user_activity_start" });
UserActivity.runTimersUntilTimeout(this.attachedActiveNowTimers, this.activeNowTimeout);
} else {
this.activeNowTimeout.restart();

View File

@@ -6,7 +6,76 @@ 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.
*/
/** constants for MatrixChat.state.view */
/**
* Constants for MatrixChat.state.view.
*
* The `View` is the primary state machine of the application: it has different states for the various setup flows
* that the user may find themselves in. Once we have a functioning client, we can transition to the `LOGGED_IN` state
* which is the "normal" state of the application.
*
* An incomplete state transition diagram follows.
*
* (initial state)
* ┌─────────────────┐ Lock held by other instance ┌─────────────────┐
* │ LOADING │─────────────────────────────►│ CONFIRM_LOCK_ │
* │ │◄─────────────────────────────│ THEFT │
* └─────────────────┘ Lock theft confirmed └─────────────────┘
* Session recovered │ │ │
* ┌──────────────┘ │ └────────────────┐
* │ ┌─────────────┘ │ No previous session
* │ │ Token/OIDC login succeeded │
* │ │ ▼
* │ │ ┌─────────────────┐
* │ │ │ WELCOME │ (from all other states
* │ │ │ │ except LOCK_STOLEN)
* │ │ └─────────────────┘ │
* │ │ "Create Account" │ │ "Sign in" │ Client logged out
* │ │ ┌────────────────────────┘ │ │
* │ │ │ │ ┌────────────────────┘
* │ │ │ │ │
* │ │ ▼ "Create an ▼ ▼ "Forgot
* │ │ ┌─────────────────┐ account" ┌─────────────────┐ password" ┌─────────────────┐
* │ │ │ REGISTER │◄───────────────│ LOGIN │───────────────►│ FORGOT_PASSWORD │
* │ │ │ │───────────────►│ │◄───────────────│ │
* │ │ └─────────────────┘ "Sign in here" └─────────────────┘ Complete / └─────────────────┘
* │ │ │ │ "Sign in instead" ▲
* │ │ └────────────────────────────────┐ │ │
* │ └────────────────────────────────────────┐ │ │ │
* │ ▼ ▼ ▼ │
* │ ┌──────────────────┐ │
* │ │ (postLoginSetup) │ │
* │ └──────────────────┘ │
* │ ┌────────────────────────────────────┘ │ │ │
* │ │ E2EE not enabled ┌─────────────┘ └──────┐ │
* │ │ │ Account has │ Account lacks │
* │ │ │ cross-signing │ cross-signing │
* │ │ │ keys │ keys │
* │ │ Client started and ▼ ▼ │
* │ │ force_verification ┌─────────────────┐ ┌─────────────────┐ │
* │ │ pending │ COMPLETE_ │ │ E2E_SETUP │ │
* │ │ ┌─────────────────►│ SECURITY │ │ │ │
* │ │ │ └─────────────────┘ └─────────────────┘ │ "Forgotten
* │ │ │ ┌───────────────────────┘ │ │ your
* │ │ │ │ ┌───────────────────────────────────────────────┘ │ password?"
* │ │ │ │ │ │
* │ │ │ │ │ (from all other states │
* │ │ │ │ │ except LOCK_STOLEN) │
* │ │ │ │ │ └──────────────┐ │
* ▼ ▼ │ ▼ ▼ Soft logout error ▼ │
* ┌─────────────────┐ ┌─────────────────┐
* │ LOGGED_IN │ Re-authentication succeeded │ SOFT_LOGOUT │
* │ │◄────────────────────────────────────────────────────────│ │
* └─────────────────┘ └─────────────────┘
*
* (from all other states)
* │
* │ Session lock stolen
* ▼
* ┌─────────────────┐
* │ LOCK_STOLEN │
* │ │
* └─────────────────┘
*/
enum Views {
// a special initial state which is only used at startup, while we are
// trying to re-animate a matrix client or register as a guest.

View File

@@ -18,6 +18,7 @@ import { MatrixClientPeg } from "../../MatrixClientPeg";
import MatrixClientContext from "../../contexts/MatrixClientContext";
import AutoHideScrollbar from "./AutoHideScrollbar";
import { type ActionPayload } from "../../dispatcher/payloads";
import { Action } from "../../dispatcher/actions.ts";
interface IProps {
// URL to request embedded page content from
@@ -109,7 +110,7 @@ export default class EmbeddedPage extends React.PureComponent<IProps, IState> {
private onAction = (payload: ActionPayload): void => {
// HACK: Workaround for the context's MatrixClient not being set up at render time.
if (payload.action === "client_started") {
if (payload.action === Action.ClientStarted) {
this.forceUpdate();
}
};

View File

@@ -202,7 +202,10 @@ interface IState {
hideToSRUsers: boolean;
syncError: Error | null;
serverConfig?: ValidatedServerConfig;
/** Has our MatrixClient started? */
ready: boolean;
threepidInvite?: IThreepidInvite;
roomOobData?: object;
pendingInitialSync?: boolean;
@@ -225,7 +228,13 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
private firstSyncPromise: PromiseWithResolvers<void>;
private screenAfterLogin?: IScreen;
/** True if we have successfully completed an OIDC or token login.
*
* XXX it's unclear if this is ever cleared, so what happens if the user logs out and then logs back in?
*/
private tokenLogin?: boolean;
// What to focus on next component update, if anything
private focusNext: FocusNextType;
private subTitleStatus: string;
@@ -386,6 +395,26 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
await Lifecycle.onSessionLockStolen();
}
/**
* Perform actions that are specific to a user that has just logged in (compare {@link onLoggedIn}, which, despite
* its name, is called when an already-logged-in client is restored at session startup).
*
* Called when:
*
* - We successfully completed an OIDC or token login, via {@link initSession}.
* - The {@link Login} or {@link Register} components notify us that we successfully completed a non-OIDC login or
* registration.
*
* In both cases, {@link Action.OnLoggedIn} will already have been emitted, but the call to {@link onLoggedIn} will
* have been suppressed (by either {@link tokenLogin} being set, or the view being set to {@link Views.LOGIN} or
* {@link Views.REGISTER}).
*
* {@link onWillStartClient} and {@link onClientStarted} will already have been called (but not necessarily
* completed).
*
* This method either calls {@link onLiggedIn} directly, or switches to {@link Views.E2E_SETUP} or
* {@link Views.COMPLETE_SECURITY}, which will later call {@link onCompleteSecurityE2eSetupFinished}.
*/
private async postLoginSetup(): Promise<void> {
const cli = MatrixClientPeg.safeGet();
const cryptoEnabled = Boolean(cli.getCrypto());
@@ -427,10 +456,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
} else {
this.setStateForNewView({ view: Views.COMPLETE_SECURITY });
}
} else if (
(await cli.doesServerSupportUnstableFeature("org.matrix.e2e_cross_signing")) &&
!(await shouldSkipSetupEncryption(cli))
) {
} else if (!(await shouldSkipSetupEncryption(cli))) {
// if cross-signing is not yet set up, do so now if possible.
InitialCryptoSetupStore.sharedInstance().startInitialCryptoSetup(
cli,
@@ -606,6 +632,10 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
);
}
private isLoggedInViewPageDisplayed(): boolean {
return this.loggedInView.current !== null && this.state.page_type !== undefined;
}
private setStateForNewView(state: Partial<IState>): void {
if (state.view === undefined) {
throw new Error("setStateForNewView with no view!");
@@ -815,13 +845,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
}
break;
}
case "view_last_screen":
// This function does what we want, despite the name. The idea is that it shows
// the last room we were looking at or some reasonable default/guess. We don't
// have to worry about email invites or similar being re-triggered because the
// function will have cleared that state and not execute that path.
this.showScreenAfterLogin();
break;
case "hide_left_panel":
this.setState(
{
@@ -859,13 +882,13 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
this.onLoggedIn();
}
break;
case "on_client_not_viable":
case Action.ClientNotViable:
this.onSoftLogout();
break;
case Action.OnLoggedOut:
this.onLoggedOut();
break;
case "will_start_client":
case Action.WillStartClient:
this.setState({ ready: false }, () => {
// if the client is about to start, we are, by definition, not ready.
// Set ready to false now, then it'll be set to true when the sync
@@ -873,7 +896,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
this.onWillStartClient();
});
break;
case "client_started":
case Action.ClientStarted:
// No need to make this handler async to wait for the result of this
this.onClientStarted().catch((e) => {
logger.error("Exception in onClientStarted", e);
@@ -1078,7 +1101,8 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
this.viewWelcome();
return;
}
if (!this.state.currentRoomId && !this.state.currentUserId) {
if (!this.state.currentRoomId && !this.state.currentUserId && !this.isLoggedInViewPageDisplayed()) {
this.viewHome();
}
}
@@ -1379,7 +1403,15 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
}
/**
* Called when a new logged in session has started
* Called when a new logged in session has started.
*
* Called:
*
* - on {@link Action.OnLoggedIn}, but only when we don't expect a separate call to {@link postLoginSetup}.
* - from {@link postLoginSetup}, when we don't have crypto setup tasks to perform after the login.
*
* It's never actually called if we have crypto setup tasks to perform after login (which we normally do, unless
* crypto is disabled.) XXX: is this a bug or a feature?
*/
private async onLoggedIn(): Promise<void> {
ThemeController.isLogin = false;
@@ -1389,6 +1421,16 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
await this.onShowPostLoginScreen();
}
/**
* Show the first screen after the application is successfully loaded in a logged-in state.
*
* Called:
*
* - by {@link onLoggedIn}
* - by {@link onCompleteSecurityE2eSetupFinished}
*
* In other words, whenever we think we have completed the login and E2E setup tasks.
*/
private async onShowPostLoginScreen(): Promise<void> {
this.setStateForNewView({ view: Views.LOGGED_IN });
// If a specific screen is set to be shown after login, show that above
@@ -1815,7 +1857,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
// if we weren't already coming at this from an existing screen
// and we're logged in, then explicitly default to home.
// if we're not logged in, then the login flow will do the right thing.
if (!this.state.currentRoomId && !this.state.currentUserId) {
if (!this.state.currentRoomId && !this.state.currentUserId && !this.isLoggedInViewPageDisplayed()) {
this.viewHome();
}
} else if (screen === "settings") {
@@ -2053,7 +2095,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
PerformanceMonitor.instance.stop(PerformanceEntryNames.REGISTER);
};
// complete security / e2e setup has finished
/** Called when {@link Views.E2E_SETUP} or {@link Views.COMPLETE_SECURITY} have completed. */
private onCompleteSecurityE2eSetupFinished = async (): Promise<void> => {
const forceVerify = await this.shouldForceVerification();
if (forceVerify) {
@@ -2104,7 +2146,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
} else if (this.state.view === Views.COMPLETE_SECURITY) {
view = <CompleteSecurity onFinished={this.onCompleteSecurityE2eSetupFinished} />;
} else if (this.state.view === Views.E2E_SETUP) {
view = <E2eSetup onFinished={this.onCompleteSecurityE2eSetupFinished} />;
view = <E2eSetup onCancelled={this.onCompleteSecurityE2eSetupFinished} />;
} else if (this.state.view === Views.LOGGED_IN) {
// `ready` and `view==LOGGED_IN` may be set before `page_type` (because the
// latter is set via the dispatcher). If we don't yet have a `page_type`,

View File

@@ -175,6 +175,16 @@ interface IRoomProps extends RoomViewProps {
* If true, hide the composer
*/
hideComposer?: boolean;
/*
* If true, hide the right panel
*/
hideRightPanel?: boolean;
/**
* If true, hide the pinned messages banner
*/
hidePinnedMessageBanner?: boolean;
}
export { MainSplitContentType };
@@ -1197,7 +1207,13 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
case Action.EditEvent: {
// Quit early if we're trying to edit events in wrong rendering context
if (payload.timelineRenderingType !== this.state.timelineRenderingType) return;
if (payload.event && payload.event.getRoomId() !== this.state.roomId) {
const roomId: string | undefined = payload.event?.getRoomId();
if (payload.event && roomId !== this.state.roomId) {
// if the room is displayed in a module, we don't want to change the room view
if (roomId && this.roomViewStore.isRoomDisplayedInModule(roomId)) return;
// If the event is in a different room (e.g. because the event to be edited is being displayed
// in the results of an all-rooms search), we need to view that room first.
defaultDispatcher.dispatch<ViewRoomPayload>({
@@ -2459,7 +2475,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
</AuxPanel>
);
const pinnedMessageBanner = (
const pinnedMessageBanner = !this.props.hidePinnedMessageBanner && (
<PinnedMessageBanner room={this.state.room} permalinkCreator={this.permalinkCreator} />
);
@@ -2557,7 +2573,8 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
);
}
const showRightPanel = !isRoomEncryptionLoading && this.state.room && this.state.showRightPanel;
const showRightPanel =
!this.props.hideRightPanel && !isRoomEncryptionLoading && this.state.room && this.state.showRightPanel;
const rightPanel = showRightPanel ? (
<RightPanel

View File

@@ -13,15 +13,19 @@ import CompleteSecurityBody from "../../views/auth/CompleteSecurityBody";
import { InitialCryptoSetupDialog } from "../../views/dialogs/security/InitialCryptoSetupDialog";
interface IProps {
onFinished: () => void;
/** Callback which is called if the crypto setup failed, and the user clicked the 'cancel' button */
onCancelled: () => void;
}
/**
* An {@link AuthPage} which shows the {@link InitialCryptoSetupDialog}.
*/
export default class E2eSetup extends React.Component<IProps> {
public render(): React.ReactNode {
return (
<AuthPage>
<CompleteSecurityBody>
<InitialCryptoSetupDialog onFinished={this.props.onFinished} />
<InitialCryptoSetupDialog onCancelled={this.props.onCancelled} />
</CompleteSecurityBody>
</AuthPage>
);

View File

@@ -29,16 +29,6 @@ export interface UserInfoVerificationSectionState {
verifySelectedUser: () => Promise<void>;
}
const useHomeserverSupportsCrossSigning = (cli: MatrixClient): boolean => {
return useAsyncMemo<boolean>(
async () => {
return cli.doesServerSupportUnstableFeature("org.matrix.e2e_cross_signing");
},
[cli],
false,
);
};
const useHasCrossSigningKeys = (cli: MatrixClient, member: User, canVerify: boolean): boolean | undefined => {
return useAsyncMemo(async () => {
if (!canVerify) return undefined;
@@ -56,8 +46,6 @@ export const useUserInfoVerificationViewModel = (
): UserInfoVerificationSectionState => {
const cli = useContext(MatrixClientContext);
const homeserverSupportsCrossSigning = useHomeserverSupportsCrossSigning(cli);
const userTrust = useAsyncMemo<UserVerificationStatus | undefined>(
async () => cli.getCrypto()?.getUserVerificationStatus(member.userId),
[member.userId],
@@ -67,13 +55,7 @@ export const useUserInfoVerificationViewModel = (
const hasUserVerificationStatus = Boolean(userTrust);
const isUserVerified = Boolean(userTrust?.isVerified());
const isMe = member.userId === cli.getUserId();
const canVerify =
hasUserVerificationStatus &&
homeserverSupportsCrossSigning &&
!isUserVerified &&
!isMe &&
devices &&
devices.length > 0;
const canVerify = hasUserVerificationStatus && !isUserVerified && !isMe && devices && devices.length > 0;
const hasCrossSigningKeys = useHasCrossSigningKeys(cli, member as User, canVerify);
const verifySelectedUser = (): Promise<void> => verifyUser(cli, member as User);

View File

@@ -7,6 +7,7 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { type Room, RoomEvent } from "matrix-js-sdk/src/matrix";
import { CallType } from "matrix-js-sdk/src/webrtc/call";
import dispatcher from "../../../dispatcher/dispatcher";
import type { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
@@ -19,7 +20,7 @@ import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
import { useEventEmitter, useEventEmitterState, useTypedEventEmitter } from "../../../hooks/useEventEmitter";
import { DefaultTagID } from "../../../stores/room-list/models";
import { useCall, useConnectionState, useParticipantCount } from "../../../hooks/useCall";
import { type ConnectionState } from "../../../models/Call";
import { CallEvent, type ConnectionState } from "../../../models/Call";
import { NotificationStateEvents } from "../../../stores/notifications/NotificationState";
import DMRoomMap from "../../../utils/DMRoomMap";
import { MessagePreviewStore } from "../../../stores/room-list/MessagePreviewStore";
@@ -67,6 +68,10 @@ export interface RoomListItemViewState {
* Whether there are participants in the call.
*/
hasParticipantInCall: boolean;
/**
* Whether the call is a voice or video call.
*/
callType: CallType | undefined;
/**
* Pre-rendered and translated preview for the latest message in the room, or undefined
* if no preview should be shown.
@@ -123,10 +128,10 @@ export function useRoomListItemViewModel(room: Room): RoomListItemViewState {
// EC video call or video room
const call = useCall(room.roomId);
const connectionState = useConnectionState(call);
const hasParticipantInCall = useParticipantCount(call) > 0;
const participantCount = useParticipantCount(call);
const callConnectionState = call ? connectionState : null;
const showNotificationDecoration = hasVisibleNotification || hasParticipantInCall;
const showNotificationDecoration = hasVisibleNotification || participantCount > 0;
// Actions
@@ -138,6 +143,9 @@ export function useRoomListItemViewModel(room: Room): RoomListItemViewState {
});
}, [room]);
const [callType, setCallType] = useState<CallType>(CallType.Video);
useTypedEventEmitter(call ?? undefined, CallEvent.CallTypeChanged, setCallType);
return {
name,
notificationState,
@@ -148,9 +156,10 @@ export function useRoomListItemViewModel(room: Room): RoomListItemViewState {
isBold,
isVideoRoom,
callConnectionState,
hasParticipantInCall,
hasParticipantInCall: participantCount > 0,
messagePreview,
showNotificationDecoration,
callType: call ? callType : undefined,
};
}

View File

@@ -12,6 +12,7 @@ import { logger } from "matrix-js-sdk/src/logger";
import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext";
import DeviceListener, { BACKUP_DISABLED_ACCOUNT_DATA_KEY } from "../../../../DeviceListener";
import { useEventEmitterAsyncState } from "../../../../hooks/useEventEmitter";
import { resetKeyBackupAndWait } from "../../../../utils/crypto/resetKeyBackup";
interface KeyStoragePanelState {
/**
@@ -75,63 +76,58 @@ export function useKeyStoragePanelViewModel(): KeyStoragePanelState {
async (enable: boolean) => {
setPendingValue(enable);
try {
// stop the device listener since enabling or (especially) disabling key storage must be
// pause the device listener since enabling or (especially) disabling key storage must be
// done with a sequence of API calls that will put the account in a slightly different
// state each time, so suppress any warning toasts until the process is finished (when
// we'll turn it back on again.)
DeviceListener.sharedInstance().stop();
const crypto = matrixClient.getCrypto();
if (!crypto) {
logger.error("Can't change key backup status: no crypto module available");
return;
}
if (enable) {
const childLogger = logger.getChild("[enable key storage]");
childLogger.info("User requested enabling key storage");
let currentKeyBackup = await crypto.checkKeyBackupAndEnable();
if (currentKeyBackup) {
logger.info(
`Existing key backup is present. version: ${currentKeyBackup.backupInfo.version}`,
currentKeyBackup.trustInfo,
);
// Check if the current key backup can be used. Either of these properties causes the key backup to be used.
if (currentKeyBackup.trustInfo.trusted || currentKeyBackup.trustInfo.matchesDecryptionKey) {
logger.info("Existing key backup can be used");
// state each time, so suppress any warning toasts until the process is finished
await DeviceListener.sharedInstance().whilePaused(async () => {
const crypto = matrixClient.getCrypto();
if (!crypto) {
logger.error("Can't change key backup status: no crypto module available");
return;
}
if (enable) {
const childLogger = logger.getChild("[enable key storage]");
childLogger.info("User requested enabling key storage");
let currentKeyBackup = await crypto.checkKeyBackupAndEnable();
if (currentKeyBackup) {
logger.info(
`Existing key backup is present. version: ${currentKeyBackup.backupInfo.version}`,
currentKeyBackup.trustInfo,
);
// Check if the current key backup can be used. Either of these properties causes the key backup to be used.
if (currentKeyBackup.trustInfo.trusted || currentKeyBackup.trustInfo.matchesDecryptionKey) {
logger.info("Existing key backup can be used");
} else {
logger.warn("Existing key backup cannot be used, creating new backup");
// There aren't any *usable* backups, so we need to create a new one.
currentKeyBackup = null;
}
} else {
logger.warn("Existing key backup cannot be used, creating new backup");
// There aren't any *usable* backups, so we need to create a new one.
currentKeyBackup = null;
logger.info("No existing key backup versions are present, creating new backup");
}
// If there is no usable key backup on the server, create one.
// `resetKeyBackup` will delete any existing backup, so we only do this if there is no usable backup.
if (currentKeyBackup === null) {
await resetKeyBackupAndWait(crypto);
}
// Set the flag so that EX no longer thinks the user wants backup disabled
await matrixClient.setAccountData(BACKUP_DISABLED_ACCOUNT_DATA_KEY, { disabled: false });
} else {
logger.info("No existing key backup versions are present, creating new backup");
logger.info("User requested disabling key backup");
// This method will delete the key backup as well as server side recovery keys and other
// server-side crypto data.
await crypto.disableKeyStorage();
// Set a flag to say that the user doesn't want key backup.
// Element X uses this to determine whether to set up automatically,
// so this will stop EX turning it back on spontaneously.
await matrixClient.setAccountData(BACKUP_DISABLED_ACCOUNT_DATA_KEY, { disabled: true });
}
// If there is no usable key backup on the server, create one.
// `resetKeyBackup` will delete any existing backup, so we only do this if there is no usable backup.
if (currentKeyBackup === null) {
await crypto.resetKeyBackup();
// resetKeyBackup fires this off in the background without waiting, so we need to do it
// explicitly and wait for it, otherwise it won't be enabled yet when we check again.
await crypto.checkKeyBackupAndEnable();
}
// Set the flag so that EX no longer thinks the user wants backup disabled
await matrixClient.setAccountData(BACKUP_DISABLED_ACCOUNT_DATA_KEY, { disabled: false });
} else {
logger.info("User requested disabling key backup");
// This method will delete the key backup as well as server side recovery keys and other
// server-side crypto data.
await crypto.disableKeyStorage();
// Set a flag to say that the user doesn't want key backup.
// Element X uses this to determine whether to set up automatically,
// so this will stop EX turning it back on spontaneously.
await matrixClient.setAccountData(BACKUP_DISABLED_ACCOUNT_DATA_KEY, { disabled: true });
}
});
} finally {
setPendingValue(undefined);
DeviceListener.sharedInstance().start(matrixClient);
}
},
[setPendingValue, matrixClient],

View File

@@ -16,23 +16,21 @@ import Spinner from "../../elements/Spinner";
import { InitialCryptoSetupStore, useInitialCryptoSetupStatus } from "../../../../stores/InitialCryptoSetupStore";
interface Props {
onFinished: (success?: boolean) => void;
/** Callback which is called if the crypto setup failed, and the user clicked the 'cancel' button */
onCancelled: () => void;
}
/*
* Walks the user through the process of creating a cross-signing keys.
/**
* Walks the user through the process of creating cross-signing keys.
*
* In most cases, only a spinner is shown, but for more
* complex auth like SSO, the user may need to complete some steps to proceed.
*/
export const InitialCryptoSetupDialog: React.FC<Props> = ({ onFinished }) => {
export const InitialCryptoSetupDialog: React.FC<Props> = ({ onCancelled }) => {
const onRetryClick = useCallback(() => {
InitialCryptoSetupStore.sharedInstance().retry();
}, []);
const onCancelClick = useCallback(() => {
onFinished(false);
}, [onFinished]);
const status = useInitialCryptoSetupStatus(InitialCryptoSetupStore.sharedInstance());
let content;
@@ -44,7 +42,7 @@ export const InitialCryptoSetupDialog: React.FC<Props> = ({ onFinished }) => {
<DialogButtons
primaryButton={_t("action|retry")}
onPrimaryButtonClick={onRetryClick}
onCancel={onCancelClick}
onCancel={onCancelled}
/>
</div>
</div>
@@ -60,7 +58,6 @@ export const InitialCryptoSetupDialog: React.FC<Props> = ({ onFinished }) => {
return (
<BaseDialog
className="mx_CreateCrossSigningDialog"
onFinished={onFinished}
title={_t("encryption|bootstrap_title")}
hasCancel={false}
fixedWidth={false}

View File

@@ -10,12 +10,10 @@ import React, { type FC } from "react";
import classNames from "classnames";
import { _t } from "../../../languageHandler";
import { type Call } from "../../../models/Call";
import { useParticipantCount } from "../../../hooks/useCall";
export enum LiveContentType {
Video,
// More coming soon
Voice,
}
interface Props {
@@ -33,6 +31,7 @@ export const LiveContentSummary: FC<Props> = ({ type, text, active, participantC
<span
className={classNames("mx_LiveContentSummary_text", {
mx_LiveContentSummary_text_video: type === LiveContentType.Video,
mx_LiveContentSummary_text_voice: type === LiveContentType.Voice,
mx_LiveContentSummary_text_active: active,
})}
>
@@ -51,16 +50,3 @@ export const LiveContentSummary: FC<Props> = ({ type, text, active, participantC
)}
</span>
);
interface LiveContentSummaryWithCallProps {
call: Call;
}
export const LiveContentSummaryWithCall: FC<LiveContentSummaryWithCallProps> = ({ call }) => (
<LiveContentSummary
type={LiveContentType.Video}
text={_t("common|video")}
active={false}
participantCount={useParticipantCount(call)}
/>
);

View File

@@ -12,6 +12,8 @@ import NotificationOffIcon from "@vector-im/compound-design-tokens/assets/web/ic
import VideoCallIcon from "@vector-im/compound-design-tokens/assets/web/icons/video-call-solid";
import EmailIcon from "@vector-im/compound-design-tokens/assets/web/icons/email-solid";
import { UnreadCounter, Unread } from "@vector-im/compound-web";
import VoiceCallIcon from "@vector-im/compound-design-tokens/assets/web/icons/voice-call-solid";
import { CallType } from "matrix-js-sdk/src/webrtc/call";
import { Flex } from "@element-hq/web-shared-components";
import { type RoomNotificationState } from "../../../stores/notifications/RoomNotificationState";
@@ -24,9 +26,9 @@ interface NotificationDecorationProps extends HTMLProps<HTMLDivElement> {
*/
notificationState: RoomNotificationState;
/**
* Whether the room has a video call.
* Whether the room has a voice or video call.
*/
hasVideoCall: boolean;
callType?: CallType;
}
/**
@@ -34,7 +36,7 @@ interface NotificationDecorationProps extends HTMLProps<HTMLDivElement> {
*/
export function NotificationDecoration({
notificationState,
hasVideoCall,
callType,
...props
}: NotificationDecorationProps): JSX.Element | null {
// Listen to the notification state and update the component when it changes
@@ -58,7 +60,7 @@ export function NotificationDecoration({
muted: notificationState.muted,
}));
if (!hasAnyNotificationOrActivity && !muted && !hasVideoCall) return null;
if (!hasAnyNotificationOrActivity && !muted && !callType) return null;
return (
<Flex
@@ -69,7 +71,12 @@ export function NotificationDecoration({
data-testid="notification-decoration"
>
{isUnsentMessage && <ErrorIcon width="20px" height="20px" fill="var(--cpd-color-icon-critical-primary)" />}
{hasVideoCall && <VideoCallIcon width="20px" height="20px" fill="var(--cpd-color-icon-accent-primary)" />}
{callType === CallType.Video && (
<VideoCallIcon width="20px" height="20px" fill="var(--cpd-color-icon-accent-primary)" />
)}
{callType === CallType.Voice && (
<VoiceCallIcon width="20px" height="20px" fill="var(--cpd-color-icon-accent-primary)" />
)}
{invited && <EmailIcon width="20px" height="20px" fill="var(--cpd-color-icon-accent-primary)" />}
{isMention && <MentionIcon width="20px" height="20px" fill="var(--cpd-color-icon-accent-primary)" />}
{(isMention || isNotification) && <UnreadCounter count={count || null} />}

View File

@@ -1,4 +1,5 @@
/*
Copyright (C) 2025 Element Creations Ltd
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
@@ -6,7 +7,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 React, { type JSX, useCallback, useMemo, useState } from "react";
import React, { type JSX, useCallback, useState } from "react";
import { Text, Button, IconButton, Menu, MenuItem, Tooltip } from "@vector-im/compound-web";
import VideoCallIcon from "@vector-im/compound-design-tokens/assets/web/icons/video-call-solid";
import VoiceCallIcon from "@vector-im/compound-design-tokens/assets/web/icons/voice-call-solid";
@@ -29,7 +30,6 @@ import { _t } from "../../../../languageHandler.tsx";
import { getPlatformCallTypeProps, useRoomCall } from "../../../../hooks/room/useRoomCall.tsx";
import { useRoomThreadNotifications } from "../../../../hooks/room/useRoomThreadNotifications.ts";
import { useGlobalNotificationState } from "../../../../hooks/useGlobalNotificationState.ts";
import SdkConfig from "../../../../SdkConfig.ts";
import { useFeatureEnabled } from "../../../../hooks/useSettings.ts";
import { useEncryptionStatus } from "../../../../hooks/useEncryptionStatus.ts";
import { E2EStatus } from "../../../../utils/ShieldUtils.ts";
@@ -78,16 +78,6 @@ function RoomHeaderButtons({
showVoiceCallButton,
showVideoCallButton,
} = useRoomCall(room);
const groupCallsEnabled = useFeatureEnabled("feature_group_calls");
/**
* A special mode where only Element Call is used. In this case we want to
* hide the voice call button
*/
const useElementCallExclusively = useMemo(() => {
return SdkConfig.get("element_call").use_exclusively && groupCallsEnabled;
}, [groupCallsEnabled]);
const threadNotifications = useRoomThreadNotifications(room);
const globalNotificationState = useGlobalNotificationState();
@@ -101,6 +91,11 @@ function RoomHeaderButtons({
[callOptions, videoCallClick],
);
const voiceClick = useCallback(
(ev: React.MouseEvent) => voiceCallClick(ev, callOptions[0]),
[callOptions, voiceCallClick],
);
const toggleCallButton = (
<Tooltip label={isViewingCall ? _t("voip|minimise_call") : _t("voip|maximise_call")}>
<IconButton onClick={toggleCall}>
@@ -126,35 +121,50 @@ function RoomHeaderButtons({
</Tooltip>
);
const callIconWithTooltip = (
const videoCallIconWithTooltip = (
<Tooltip label={videoCallDisabledReason ?? _t("voip|video_call")}>
<VideoCallIcon />
</Tooltip>
);
const [menuOpen, setMenuOpen] = useState(false);
const voiceCallIconWithTooltip = (
<Tooltip label={videoCallDisabledReason ?? _t("voip|voice_call")}>
<VoiceCallIcon />
</Tooltip>
);
const onOpenChange = useCallback(
const [videoMenuOpen, setVideoMenuOpen] = useState(false);
const onVideoOpenChange = useCallback(
(newOpen: boolean) => {
if (!videoCallDisabledReason) setMenuOpen(newOpen);
if (!videoCallDisabledReason) setVideoMenuOpen(newOpen);
},
[videoCallDisabledReason],
);
const [voiceMenuOpen, setVoiceMenuOpen] = useState(false);
const onVoiceOpenChange = useCallback(
(newOpen: boolean) => {
if (!voiceCallDisabledReason) setVoiceMenuOpen(newOpen);
},
[voiceCallDisabledReason],
);
const startVideoCallButton = (
<>
{/* Can be either a menu or just a button depending on the number of call options.*/}
{callOptions.length > 1 ? (
<Menu
open={menuOpen}
onOpenChange={onOpenChange}
open={videoMenuOpen}
onOpenChange={onVideoOpenChange}
title={_t("voip|video_call_using")}
trigger={
<IconButton
disabled={!!videoCallDisabledReason}
aria-label={videoCallDisabledReason ?? _t("voip|video_call")}
>
{callIconWithTooltip}
{videoCallIconWithTooltip}
</IconButton>
}
side="left"
@@ -170,7 +180,7 @@ function RoomHeaderButtons({
children={children}
className="mx_RoomHeader_videoCallOption"
onClick={(ev) => {
setMenuOpen(false);
setVideoMenuOpen(false);
videoCallClick(ev, option);
}}
Icon={VideoCallIcon}
@@ -185,25 +195,61 @@ function RoomHeaderButtons({
aria-label={videoCallDisabledReason ?? _t("voip|video_call")}
onClick={videoClick}
>
{callIconWithTooltip}
{videoCallIconWithTooltip}
</IconButton>
)}
</>
);
let voiceCallButton: JSX.Element | undefined = (
<Tooltip label={voiceCallDisabledReason ?? _t("voip|voice_call")}>
<IconButton
// We need both: isViewingCall and isConnectedToCall
// - in the Lobby we are viewing a call but are not connected to it.
// - in pip view we are connected to the call but not viewing it.
disabled={!!voiceCallDisabledReason || isViewingCall || isConnectedToCall}
aria-label={voiceCallDisabledReason ?? _t("voip|voice_call")}
onClick={(ev) => voiceCallClick(ev, callOptions[0])}
>
<VoiceCallIcon />
</IconButton>
</Tooltip>
const startVoiceCallButton = (
<>
{/* Can be either a menu or just a button depending on the number of call options.*/}
{callOptions.length > 1 ? (
<Menu
open={voiceMenuOpen}
onOpenChange={onVoiceOpenChange}
title={_t("voip|voice_call_using")}
trigger={
<IconButton
disabled={!!voiceCallDisabledReason}
aria-label={voiceCallDisabledReason ?? _t("voip|voice_call")}
>
{voiceCallIconWithTooltip}
</IconButton>
}
side="left"
align="start"
>
{callOptions.map((option) => {
const { label, children } = getPlatformCallTypeProps(option);
return (
<MenuItem
key={option}
label={label}
aria-label={label}
children={children}
className="mx_RoomHeader_videoCallOption"
onClick={(ev) => {
setVoiceMenuOpen(false);
voiceCallClick(ev, option);
}}
Icon={VoiceCallIcon}
onSelect={() => {} /* Dummy handler since we want the click event.*/}
/>
);
})}
</Menu>
) : (
<IconButton
disabled={!!voiceCallDisabledReason}
aria-label={voiceCallDisabledReason ?? _t("voip|voice_call")}
onClick={voiceClick}
>
{voiceCallIconWithTooltip}
</IconButton>
)}
</>
);
const closeLobbyButton = (
<Tooltip label={_t("voip|close_lobby")}>
<IconButton onClick={toggleCall}>
@@ -212,15 +258,19 @@ function RoomHeaderButtons({
</Tooltip>
);
let videoCallButton: JSX.Element | undefined = startVideoCallButton;
let voiceCallButton: JSX.Element | undefined = startVoiceCallButton;
if (isConnectedToCall) {
videoCallButton = toggleCallButton;
voiceCallButton = undefined;
} else if (isViewingCall) {
videoCallButton = closeLobbyButton;
voiceCallButton = undefined;
}
if (!showVideoCallButton) {
videoCallButton = undefined;
}
if (!showVoiceCallButton) {
voiceCallButton = undefined;
}
@@ -258,7 +308,7 @@ function RoomHeaderButtons({
) : (
<>
{!isVideoRoom && videoCallButton}
{!useElementCallExclusively && !isVideoRoom && voiceCallButton}
{!isVideoRoom && voiceCallButton}
</>
)}

View File

@@ -70,10 +70,10 @@ export function RoomListHeaderView(): JSX.Element {
<ComposeMenu vm={vm} />
) : (
<IconButton
aria-label={_t("action|start_chat")}
onClick={(e) => vm.createChatRoom(e.nativeEvent)}
tooltip={_t("action|new_conversation")}
>
<ComposeIcon color="var(--cpd-color-icon-secondary)" />
<ComposeIcon color="var(--cpd-color-icon-secondary)" aria-hidden />
</IconButton>
)}
</div>
@@ -163,8 +163,8 @@ function ComposeMenu({ vm }: ComposeMenuProps): JSX.Element {
side="right"
align="start"
trigger={
<IconButton aria-label={_t("action|add")}>
<ComposeIcon color="var(--cpd-color-icon-secondary)" />
<IconButton tooltip={_t("action|new_conversation")}>
<ComposeIcon color="var(--cpd-color-icon-secondary)" aria-hidden />
</IconButton>
}
>

View File

@@ -132,7 +132,7 @@ export const RoomListItemView = memo(function RoomListItemView({
<NotificationDecoration
notificationState={vm.notificationState}
aria-hidden={true}
hasVideoCall={vm.hasParticipantInCall}
callType={vm.callType}
/>
)}
</>

View File

@@ -1,4 +1,5 @@
/*
* Copyright 2025 Element Creations Ltd.
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
@@ -29,7 +30,8 @@ import { initialiseDehydrationIfEnabled } from "../../../../utils/device/dehydra
import { withSecretStorageKeyCache } from "../../../../SecurityManager";
import { EncryptionCardButtons } from "./EncryptionCardButtons";
import { logErrorAndShowErrorDialog } from "../../../../utils/ErrorUtils.tsx";
import { RECOVERY_ACCOUNT_DATA_KEY } from "../../../../DeviceListener";
import DeviceListener, { RECOVERY_ACCOUNT_DATA_KEY } from "../../../../DeviceListener";
import { resetKeyBackupAndWait } from "../../../../utils/crypto/resetKeyBackup";
/**
* The possible states of the component.
@@ -123,14 +125,27 @@ export function ChangeRecoveryKey({
if (!crypto) return onFinish();
try {
// We need to enable the cache to avoid to prompt the user to enter the new key
// when we will try to access the secret storage during the bootstrap
await withSecretStorageKeyCache(async () => {
await crypto.bootstrapSecretStorage({
setupNewSecretStorage: true,
createSecretStorageKey: async () => recoveryKey,
const deviceListener = DeviceListener.sharedInstance();
// we need to call keyStorageOutOfSyncNeedsBackupReset here because
// deviceListener.whilePaused() sets its client to undefined, so
// keyStorageOutOfSyncNeedsBackupReset won't be able to check
// the backup state.
const needsBackupReset = await deviceListener.keyStorageOutOfSyncNeedsBackupReset(true);
await deviceListener.whilePaused(async () => {
// We need to enable the cache to avoid to prompt the user to enter the new key
// when we will try to access the secret storage during the bootstrap
await withSecretStorageKeyCache(async () => {
await crypto.bootstrapSecretStorage({
setupNewSecretStorage: true,
createSecretStorageKey: async () => recoveryKey,
});
// Reset the key backup if needed
if (needsBackupReset) {
await resetKeyBackupAndWait(crypto);
}
await initialiseDehydrationIfEnabled(matrixClient, { createNewKey: true });
});
await initialiseDehydrationIfEnabled(matrixClient, { createNewKey: true });
});
// Record the fact that the user explicitly enabled recovery.

View File

@@ -121,7 +121,7 @@ export enum Action {
UpdateSystemFont = "update_system_font",
/**
* Changes room based on payload parameters. Should be used with JoinRoomPayload.
* Changes room based on payload parameters. Should be used with ViewRoomPayload.
*/
ViewRoom = "view_room",
@@ -316,16 +316,39 @@ export enum Action {
*/
ShowRoomTopic = "show_room_topic",
/**
* Fired when the client is no longer viable to use: specifically, that we have been "soft-logged out".
*/
ClientNotViable = "client_not_viable",
/**
* Fired when the client was logged out. No additional payload information required.
*/
OnLoggedOut = "on_logged_out",
/**
* Fired when the client was logged in. No additional payload information required.
* Fired when the client was logged in, or has otherwise been set up with authentication data (e.g., by loading the
* access token from local storage). Note that this does not necessarily mean that a login action has happened,
* just that authentication creds have been set up.
*
* No additional payload information required.
*/
OnLoggedIn = "on_logged_in",
/**
* Fired when the client is about to be started, shortly after {@link OnLoggedIn}.
*
* No additional payload information required.
*/
WillStartClient = "will_start_client",
/**
* Fired when the client has started, shortly after {@link WillStartClient}.
*
* No additional payload information required.
*/
ClientStarted = "client_started",
/**
* Overwrites the existing login with fresh session credentials. Use with a OverwriteLoginPayload.
*/
@@ -380,4 +403,10 @@ export enum Action {
* Open the create room dialog
*/
CreateRoom = "view_create_room",
/**
* The `UserActivity` tracker determined that there was some activity from the user (typically a mouse movement
* or keyboard event).
*/
UserActivity = "user_activity",
}

View File

@@ -39,6 +39,7 @@ interface BaseViewRoomPayload extends Pick<ActionPayload, "action"> {
clear_search?: boolean; // Whether to clear the room list search
view_call?: boolean; // Whether to view the call or call lobby for the room
skipLobby?: boolean; // Whether to skip the call lobby when showing the call (only supported for element calls)
voiceOnly?: boolean; // Whether the call is voice only (only supported for element calls)
opts?: JoinRoomPayload["opts"];
deferred_action?: ActionPayload; // Action to fire after MatrixChat handles this ViewRoom action

View File

@@ -142,11 +142,6 @@ export const useRoomCall = (
// If there are multiple options, the user will be prompted to choose.
const callOptions = useMemo((): PlatformCallType[] => {
const options: PlatformCallType[] = [];
if (memberCount <= 2) {
options.push(PlatformCallType.LegacyCall);
} else if (mayEditWidgets || hasJitsiWidget) {
options.push(PlatformCallType.JitsiCall);
}
if (groupCallsEnabled) {
if (hasGroupCall || mayCreateElementCalls) {
options.push(PlatformCallType.ElementCall);
@@ -155,6 +150,11 @@ export const useRoomCall = (
return [PlatformCallType.ElementCall];
}
}
if (memberCount <= 2) {
options.push(PlatformCallType.LegacyCall);
} else if (mayEditWidgets || hasJitsiWidget) {
options.push(PlatformCallType.JitsiCall);
}
if (hasGroupCall && WidgetType.CALL.matches(groupCall.widget.type)) {
// only allow joining the ongoing Element call if there is one.
return [PlatformCallType.ElementCall];
@@ -231,7 +231,7 @@ export const useRoomCall = (
if (widget && promptPinWidget) {
WidgetLayoutStore.instance.moveToContainer(room, widget, Container.Top);
} else {
placeCall(room, CallType.Voice, callPlatformType, evt?.shiftKey || undefined);
placeCall(room, CallType.Voice, callPlatformType, evt?.shiftKey || undefined, true);
}
},
[promptPinWidget, room, widget],
@@ -244,7 +244,7 @@ export const useRoomCall = (
} else {
// If we have pressed shift then always skip the lobby, otherwise `undefined` will defer
// to the defaults of the call implementation.
placeCall(room, CallType.Video, callPlatformType, evt?.shiftKey || undefined);
placeCall(room, CallType.Video, callPlatformType, evt?.shiftKey || undefined, false);
}
},
[widget, promptPinWidget, room],
@@ -279,7 +279,13 @@ export const useRoomCall = (
const roomDoesNotExist = room instanceof LocalRoom && room.state !== LocalRoomState.CREATED;
// We hide the voice call button if it'd have the same effect as the video call button
let hideVoiceCallButton = isManagedHybridWidgetEnabled(room) || !callOptions.includes(PlatformCallType.LegacyCall);
let hideVoiceCallButton =
isManagedHybridWidgetEnabled(room) ||
// Disable voice calls if Legacy calls are disabled
(!callOptions.includes(PlatformCallType.LegacyCall) &&
// Disable voice calls in ECall if the room is a group (we only present video calls for groups of users)
(!callOptions.includes(PlatformCallType.ElementCall) || memberCount > 2));
let hideVideoCallButton = false;
// We hide both buttons if:
// - they require widgets but widgets are disabled

View File

@@ -4094,9 +4094,11 @@
"user_busy_description": "Die angerufene Person ist momentan beschäftigt.",
"user_is_presenting": "%(sharerName)s präsentiert",
"video_call": "Videoanruf",
"video_call_incoming": "Eingehender Videoanruf",
"video_call_started": "Videoanruf hat begonnen",
"video_call_using": "Videoanruf mit:",
"voice_call": "Sprachanruf",
"voice_call_incoming": "Eingehender Anruf",
"you_are_presenting": "Du präsentierst"
},
"web_default_device_name": "%(appName)s: %(browserName)s auf %(osName)s",

View File

@@ -93,6 +93,7 @@
"maximise": "Maximise",
"mention": "Mention",
"minimise": "Minimise",
"new_conversation": "New conversation",
"new_room": "New room",
"new_video_room": "New video room",
"next": "Next",
@@ -603,6 +604,7 @@
"video": "Video",
"video_room": "Video room",
"view_message": "View message",
"voice": "Voice",
"warning": "Warning"
},
"composer": {
@@ -4096,9 +4098,12 @@
"user_busy_description": "The user you called is busy.",
"user_is_presenting": "%(sharerName)s is presenting",
"video_call": "Video call",
"video_call_incoming": "Incoming video call",
"video_call_started": "Video call started",
"video_call_using": "Video call using:",
"voice_call": "Voice call",
"voice_call_incoming": "Incoming voice call",
"voice_call_using": "Voice call using:",
"you_are_presenting": "You are presenting"
},
"web_default_device_name": "%(appName)s: %(browserName)s on %(osName)s",

View File

@@ -603,6 +603,7 @@
"video": "Video",
"video_room": "Videotuba",
"view_message": "Vaata sõnumit",
"voice": "Hääl",
"warning": "Hoiatus"
},
"composer": {
@@ -4096,9 +4097,11 @@
"user_busy_description": "Kasutaja, kellele sa helistasid, on hõivatud.",
"user_is_presenting": "%(sharerName)s esitab",
"video_call": "Videokõne",
"video_call_incoming": "Saabuv videokõne",
"video_call_started": "Videokõne algas",
"video_call_using": "Videokõne, kus on kasutusel:",
"voice_call": "Häälkõne",
"voice_call_incoming": "Saaduv häälkõne",
"you_are_presenting": "Sina esitad"
},
"web_default_device_name": "%(appName)s: %(browserName)s operatsioonisüsteemis %(osName)s",

View File

@@ -603,6 +603,7 @@
"video": "Vidéo",
"video_room": "Salon vidéo",
"view_message": "Afficher le message",
"voice": "Voix",
"warning": "Attention"
},
"composer": {
@@ -831,6 +832,7 @@
"failed_to_save": "Échec lors de la sauvegarde des paramètres.",
"failed_to_send": "Échec de lenvoi de lévénement !",
"id": "ID : ",
"invalid_device_key_id": "L'ID de la clé de l'appareil est invalide",
"invalid_json": "Ne semble pas être du JSON valide.",
"level": "Rang",
"low_bandwidth_mode": "Mode faible bande passante",
@@ -841,6 +843,7 @@
"notification_state": "Létat des notifications est <strong>%(notificationState)s</strong>",
"notifications_debug": "Débogage des notifications",
"number_of_users": "Nombre dutilisateurs",
"only_joined_members": "Seuls les utilisateurs membres",
"original_event_source": "Évènement source original",
"room_encrypted": "Le salon est <strong>chiffré ✅</strong>",
"room_id": "Identifiant du salon : %(roomId)s",
@@ -887,10 +890,23 @@
"toggle_event": "Afficher/masquer lévènement",
"toolbox": "Boîte à outils",
"use_at_own_risk": "Cette interface ne vérifie pas les types des valeurs. Utilisez la à vos propres risques.",
"user_avatar": "Avatar: %(avatar)s",
"user_displayname": "Nom d'affichage : %(displayname)s",
"user_id": "Identifiant utilisateur : %(userId)s",
"user_no_avatar": "Avatar: <i> Aucun</i>",
"user_no_displayname": "Nom d'affichage : <i>Aucun</i>",
"user_read_up_to": "Lutilisateur a lu jusquà : ",
"user_read_up_to_ignore_synthetic": "Lutilisateur a lu jusquà (ignoreSynthetic) : ",
"user_read_up_to_private": "Lutilisateur a lu jusquà (m.read.private) : ",
"user_read_up_to_private_ignore_synthetic": "Lutilisateur a lu jusquà (m.read.private;ignoreSynthetic) : ",
"user_room_membership": "Adhésion : %(membership)s",
"user_verification_status": {
"identity_changed": "Statut de la vérification : <E2EIcon /> Non vérifié, et identité modifiée",
"unverified": "Statut de la vérification vérification : <E2EIcon /> Non vérifié",
"verified": "Statut de la vérification : <E2EIcon /> Vérifié",
"was_verified": "Statut de la vérifcation vérification : <E2EIcon /> Etait vérifié, mais l'identité a changé."
},
"users": "Utilisateurs",
"value": "Valeur",
"value_colon": "Valeur :",
"value_in_this_room": "Valeur pour ce salon",
@@ -2603,7 +2619,7 @@
"do_not_close_warning": "Ne fermez pas cette fenêtre tant que la réinitialisation n'est pas terminée",
"export_keys": "Exporter les clés",
"import_keys": "Importer les clés",
"other_people_device_description": "Attention : les utilisateurs qui ne se sont pas explicitement vérifiés auprès de vous (par exemple, via des émojis) ne recevront pas vos messages chiffrés. De même, les appareils non vérifiés des utilisateurs vérifiés ne recevront pas vos messages chiffrés.",
"other_people_device_description": "Attention : les utilisateurs qui ne se sont pas explicitement vérifiés auprès de vous (par exemple, via des émojis) ne recevront pas vos messages chiffrés. De même, les appareils non vérifiés des utilisateurs vérifiés ne recevront pas vos messages chiffrés. Cette fonctionalitée requiert le redémarrage de l'application pour prendre effet.",
"other_people_device_label": "Dans les salons chiffrés, envoyez des messages uniquement aux utilisateurs vérifiés",
"other_people_device_title": "Appareils d'autres personnes",
"reset_identity": "Réinitialiser l'identité cryptographique",
@@ -4080,9 +4096,11 @@
"user_busy_description": "Lutilisateur que vous avez appelé est indisponible.",
"user_is_presenting": "%(sharerName)s est à lécran",
"video_call": "Appel vidéo",
"video_call_incoming": "Appel vidéo entrant",
"video_call_started": "Appel vidéo commencé",
"video_call_using": "Appel vidéo utilisant :",
"voice_call": "Appel audio",
"voice_call_incoming": "Appel vocal entrant",
"you_are_presenting": "Vous êtes à lécran"
},
"web_default_device_name": "%(appName)s : %(browserName)s pour %(osName)s",

View File

@@ -93,6 +93,7 @@
"maximise": "Teljes méret",
"mention": "Megemlítés",
"minimise": "Lecsukás",
"new_conversation": "Új beszélgetés",
"new_room": "Új szoba",
"new_video_room": "Új videószoba",
"next": "Következő",
@@ -601,6 +602,7 @@
"video": "Videó",
"video_room": "Videószoba",
"view_message": "Üzenet megjelenítése",
"voice": "Hang",
"warning": "Figyelmeztetés"
},
"composer": {
@@ -803,6 +805,17 @@
},
"developer_mode": "Fejlesztői mód",
"developer_tools": "Fejlesztői eszközök",
"device_dehydrated_no": "Dehidratált: Nem",
"device_dehydrated_yes": "Dehidratált: Igen",
"device_id": "Eszközazonosító:%(deviceId)s",
"device_keys": "Eszközkulcsok",
"device_verification_status": {
"signed_by_owner": "Ellenőrzés állapota: <E2EIcon /> Tulajdonos által aláírt",
"unknown": "Ellenőrzés állapota: Ismeretlen",
"unverified": "Ellenőrzés állapota: <E2EIcon /> A tulajdonos nem írta alá",
"verified": "Ellenőrzés állapota: <E2EIcon /> Keresztaláírással ellenőrizve"
},
"devices": "Kriptográfiai eszközök (%(count)s)",
"edit_setting": "Beállítások szerkesztése",
"edit_values": "Értékek szerkesztése",
"empty_string": "<üres karakterek>",
@@ -818,6 +831,7 @@
"failed_to_save": "A beállítások elmentése sikertelen.",
"failed_to_send": "Az eseményt nem sikerült elküldeni!",
"id": "Azonosító: ",
"invalid_device_key_id": "Érvénytelen eszközkulcs-azonosító",
"invalid_json": "Nem tűnik érvényes JSON szövegnek.",
"level": "Szint",
"low_bandwidth_mode": "Alacsony sávszélességű mód",
@@ -828,6 +842,7 @@
"notification_state": "Értesítés állapota: <strong>%(notificationState)s</strong>",
"notifications_debug": "Értesítések hibakeresése",
"number_of_users": "Felhasználószám",
"only_joined_members": "Csak csatlakozott felhasználók",
"original_event_source": "Eredeti esemény forráskódja",
"room_encrypted": "A szoba <strong>titkosítva van ✅</strong>",
"room_id": "Szoba azon.: %(roomId)s",
@@ -873,10 +888,23 @@
"toggle_event": "esemény be/ki",
"toolbox": "Eszköztár",
"use_at_own_risk": "Ez a felület nem ellenőrzi az érték típusát. Csak saját felelősségére használja.",
"user_avatar": "Profilkép: %(avatar)s",
"user_displayname": "Megjelenített név: %(displayname)s",
"user_id": "Felhasználói azonosító: %(userId)s",
"user_no_avatar": "Profilkép: <i>Nincs</i>",
"user_no_displayname": "Megjelenített név: <i>Nincs</i>",
"user_read_up_to": "A felhasználó eddig olvasta el: ",
"user_read_up_to_ignore_synthetic": "A felhasználó eddig olvasott felfelé (ignoreSynthetic): ",
"user_read_up_to_private": "A felhasználó eddig olvasott felfelé (m.read.private): ",
"user_read_up_to_private_ignore_synthetic": "A felhasználó eddig olvasott felfelé (m.read.private;ignoreSynthetic): ",
"user_room_membership": "Tagság: %(membership)s",
"user_verification_status": {
"identity_changed": "Ellenőrzés állapota: <E2EIcon /> Nincs ellenőrizve, és a személyazonosság megváltozott",
"unverified": "Ellenőrzés állapota: <E2EIcon /> Nem ellenőrzött",
"verified": "Ellenőrzés állapota: <E2EIcon /> Ellenőrzött",
"was_verified": "Ellenőrzési állapot:<E2EIcon /> Ellenőrzés megtörtént, de a személyazonosság megváltozott"
},
"users": "Felhasználók",
"value": "Érték",
"value_colon": "Érték:",
"value_in_this_room": "Érték ebben a szobában",
@@ -989,6 +1017,7 @@
"skip_verification": "Ellenőrzés kihagyása most",
"verify_this_device": "Az eszköz ellenőrzése"
},
"cancelled_verification": "Vagy időtúllépés történt a kérésnél, vagy elutasították a kérést, vagy ellenőrzési történt hiba.",
"cancelling": "Megszakítás…",
"cant_confirm": "Nem tudja megerősíteni?",
"complete_action": "Megértettem",
@@ -996,6 +1025,7 @@
"complete_title": "Ellenőrizve!",
"confirm_identity_description": "A biztonságos üzenetkezelés beállításához ellenőrizze ezt az eszközt.",
"confirm_identity_title": "Erősítse meg, hogy Ön az",
"confirm_the_emojis": "Erősítse meg, hogy a lenti emodzsik megegyeznek a másik eszközön megjelenítettekkel.",
"error_starting_description": "A beszélgetést a másik felhasználóval nem lehetett elindítani.",
"error_starting_title": "Hiba az ellenőrzés indításakor",
"explainer": "Az ezzel felhasználóval váltott biztonságos üzenetek végpontok közti titkosítással védettek, és azt harmadik fél nem tudja elolvasni.",
@@ -1022,15 +1052,21 @@
"wrong_fingerprint": "Nem sikerült ellenőrizni a(z) „%(deviceId)s” eszközt a mellékelt „%(fingerprint)s” ujjlenyomat nem egyezik az eszköz ujjlenyomatával: „%(fprint)s”"
},
"no_support_qr_emoji": "Az ellenőrizni kívánt eszköz nem támogatja sem a QR-kód leolvasását, sem az emodzsis ellenőrzést, amelyeket az %(brand)s támogat. Próbálja meg egy másik klienssel.",
"now_you_can": "Mostantól biztonságosan olvashat vagy küldhet üzeneteket, és bárki, akivel cseveg, szintén megbízhat ebben az eszközben.",
"once_accepted_can_continue": "Az elfogadást követően folytathatja az ellenőrzést.",
"other_party_cancelled": "A másik fél megszakította az ellenőrzést.",
"prompt_encrypted": "Ellenőrizze a szoba összes tagját, hogy meggyőződjön a biztonságáról.",
"prompt_unencrypted": "A titkosított szobákban ellenőrizze az összes tagot, hogy meggyőződjön a biztonságosságáról.",
"qr_or_sas": "%(qrCode)s vagy %(emojiCompare)s",
"qr_prompt": "Ennek az egyedi kódnak a beolvasása",
"qr_reciprocate_check_again_device": "Az ellenőrzés befejezéséhez nézze meg újra a másik eszközén.",
"qr_reciprocate_no": "Nem, nem látok zöld pajzsot",
"qr_reciprocate_same_shield_user": "Majdnem kész! %(displayName)s is ugyanazt a pajzsot mutatja?",
"qr_reciprocate_yes": "Igen, látok egy zöld pajzsot",
"request_toast_accept_user": "Felhasználó ellenőrzése",
"request_toast_decline_counter": "Mellőzés (%(counter)s)",
"request_toast_detail": "%(deviceId)s innen: %(ip)s",
"request_toast_start_verification": "Ellenőrzés indítása",
"sas_caption_self": "Ellenőrizze ezt az eszközt azzal, hogy megerősíti, hogy a következő szám jelenik meg a képernyőjén.",
"sas_caption_user": "Ellenőrizze ezt a felhasználót azzal, hogy megerősíti, hogy a következő szám jelenik meg a képernyőjén.",
"sas_description": "Hasonlítsd össze az egyedi emodzsikat ha valamelyik eszközön nincs kamera",
@@ -1050,12 +1086,18 @@
"unverified_sessions_toast_title": "Ellenőrizetlen bejelentkezései vannak",
"use_another_device": "Másik eszköz használata",
"use_recovery_key": "Helyreállítási kulcs használata",
"verification_dialog_title_choose": "Válassza ki az ellenőrzés módját",
"verification_dialog_title_compare_emojis": "Emodzsik összehasonlítása",
"verification_dialog_title_confirm_green_shield": "Ellenőrizze, hogy a másik eszközén zöld pajzs látható-e.",
"verification_dialog_title_device": "Másik eszköz ellenőrzése",
"verification_dialog_title_failed": "Az ellenőrzés sikertelen",
"verification_dialog_title_start_on_other_device": "Ellenőrzés megkezdése a másik eszközön",
"verification_dialog_title_user": "Ellenőrzési kérés",
"verification_dialog_title_verified": "Eszköz ellenőrizve",
"verification_skip_warning": "Az ellenőrzés nélkül nem fér hozzá az összes üzenetéhez és mások számára megbízhatatlannak fog látszani.",
"verification_success_with_backup": "Ez az eszköz hitelesítve van. A titkosított üzenetekhez hozzáférése van és más felhasználók megbízhatónak látják.",
"verification_success_without_backup": "Az új eszköze ellenőrizve van. Mások megbízhatónak fogják látni.",
"verify_by_completing_one_of": "Az ellenőrzéshez végezze el az alábbiak egyikét:",
"verify_emoji": "Ellenőrzés emodzsival",
"verify_emoji_prompt": "Ellenőrzés egyedi emodzsik összehasonlításával.",
"verify_emoji_prompt_qr": "Ha nem tudod beolvasni az alábbi kódot, ellenőrizd az egyedi emodzsik összehasonlításával.",
@@ -2629,6 +2671,7 @@
"allow_spellcheck": "Helyesírás-ellenőrzés engedélyezése",
"application_language": "Alkalmazás nyelve",
"application_language_reload_hint": "Az alkalmazás újratöltődik egy másik nyelv kiválasztása után",
"avatar_open_menu": "Profilkép menü megnyitása",
"avatar_remove_progress": "Kép eltávolítása...",
"avatar_save_progress": "Kép feltöltése...",
"avatar_upload_error_text": "A fájlformátum nem támogatott, vagy a kép nagyobb, mint %(size)s.",
@@ -2825,6 +2868,10 @@
"rule_suppress_notices": "Botok üzenetei",
"rule_tombstone": "Amikor a szobák fejlesztésre kerülnek",
"show_message_desktop_notification": "Üzenet megjelenítése az asztali értesítésekben",
"sounds_release_announcement": {
"description": "Az értesítési hang és a híváscsengő hangja frissült tisztább, gyorsabb és kevésbé zavaró lett.",
"title": "Frissítettük az értesítési hangokat"
},
"voip": "Hang- és videóhívások"
},
"preferences": {
@@ -2851,6 +2898,7 @@
"room_list_heading": "Szobalista",
"show_avatars_pills": "Profilképek megjelenítése a felhasználók, szobák és események megemlítésénél",
"show_polls_button": "Szavazások gomb megjelenítése",
"startup_window_behaviour_label": "Az indítás és az ablak viselkedése",
"surround_text": "Kijelölt szöveg körülvétele speciális karakterek beírásakor",
"time_heading": "Idő megjelenítése",
"user_timezone": "Időzóna beállítása"
@@ -3020,6 +3068,12 @@
"spaces_explainer": "A terek a szobák és az emberek csoportosításának módjai. A terek mellett, amelyekben tartózkodik, használhat néhány előre beépítettet is.",
"title": "Oldalsáv"
},
"start_automatically": {
"disabled": "Nem",
"enabled": "Igen",
"label": "Az %(brand)s indítása, amikor bejelentkezik a számítógépére.",
"minimised": "Minimalizálva"
},
"tac_only_notifications": "Csak az üzenetszálak központban jelenítsen meg értesítéseket",
"use_12_hour_format": "Az időbélyegek megjelenítése 12 órás formátumban (például du. 2:30)",
"use_command_enter_send_message": "Command + Enter használata az üzenet küldéséhez",
@@ -4014,9 +4068,11 @@
"user_busy_description": "A hívott felhasználó foglalt.",
"user_is_presenting": "%(sharerName)s tartja a bemutatót",
"video_call": "Videóhívás",
"video_call_incoming": "Bejövő videóhívás",
"video_call_started": "A videóhívás elindult",
"video_call_using": "Videóhívás:",
"voice_call": "Hanghívás",
"voice_call_incoming": "Bejövő hanghívás",
"you_are_presenting": "Ön tartja a bemutatót"
},
"web_default_device_name": "%(appName)s: (%(browserName)s itt: %(osName)s)",

View File

@@ -603,6 +603,7 @@
"video": "Vídeo",
"video_room": "Sala de vídeo",
"view_message": "Ver mensagem",
"voice": "Voz",
"warning": "Atenção"
},
"composer": {
@@ -805,6 +806,17 @@
},
"developer_mode": "Modo desenvolvedor",
"developer_tools": "Ferramentas do desenvolvedor",
"device_dehydrated_no": "Desidratado: Não",
"device_dehydrated_yes": "Desidratado: Sim",
"device_id": "ID do dispositivo: %(deviceId)s",
"device_keys": "Chaves do dispositivo",
"device_verification_status": {
"signed_by_owner": "Status da verificação: <E2EIcon /> Assinado pelo proprietário",
"unknown": "Status da verificação: Desconhecido",
"unverified": "Status da verificação: <E2EIcon /> Não assinado pelo proprietário",
"verified": "Status da verificação: <E2EIcon /> Verificado por assinatura cruzada"
},
"devices": "Dispositivos criptográficos (%(count)s)",
"edit_setting": "Editar configuração",
"edit_values": "Editar valores",
"empty_string": "<empty string>",
@@ -820,6 +832,7 @@
"failed_to_save": "Falha ao salvar as configurações.",
"failed_to_send": "Falha ao enviar o evento!",
"id": "ID: ",
"invalid_device_key_id": "ID da chave do dispositivo inválida",
"invalid_json": "Não parece um JSON válido.",
"level": "Nível",
"low_bandwidth_mode": "Modo de baixa largura de banda",
@@ -830,6 +843,7 @@
"notification_state": "O estado da notificação é<strong>%(notificationState)s</strong>",
"notifications_debug": "Depuração de notificações",
"number_of_users": "Número de usuários",
"only_joined_members": "Somente usuários cadastrados",
"original_event_source": "Fonte do evento original",
"room_encrypted": "A sala está <strong> criptografada ✅ </strong>",
"room_id": "ID da sala: %(roomId)s",
@@ -876,10 +890,23 @@
"toggle_event": "alternar evento",
"toolbox": "Ferramentas",
"use_at_own_risk": "Esta interface de usuário NÃO verifica os tipos de valores. Use por sua conta e risco.",
"user_avatar": "Avatar: %(avatar)s",
"user_displayname": "Nome de exibição: %(displayname)s",
"user_id": "ID do usuário: %(userId)s",
"user_no_avatar": "Avatar: <i>Nenhum</i>",
"user_no_displayname": "Nome de exibição: <i>Nenhum</i>",
"user_read_up_to": "O usuário leu até: ",
"user_read_up_to_ignore_synthetic": "O usuário leu até (ignoreSynthetic): ",
"user_read_up_to_private": "O usuário leu até (m.read.private): ",
"user_read_up_to_private_ignore_synthetic": "O usuário leu até (m.read.private; ignoreSynthetic): ",
"user_room_membership": "Afiliação: %(membership)s",
"user_verification_status": {
"identity_changed": "Status da verificação: <E2EIcon /> Não verificado e identidade alterada",
"unverified": "Status da verificação: <E2EIcon /> Não verificado",
"verified": "Status da verificação: <E2EIcon /> Verificado",
"was_verified": "Status da verificação: <E2EIcon /> Foi verificado, mas a identidade mudou."
},
"users": "Usuários",
"value": "Valor",
"value_colon": "Valor:",
"value_in_this_room": "Valor nessa sala",
@@ -992,6 +1019,7 @@
"skip_verification": "Ignorar a verificação por enquanto",
"verify_this_device": "Verifique este dispositivo"
},
"cancelled_verification": "A solicitação expirou, foi negada ou houve uma incompatibilidade de verificação.",
"cancelling": "Cancelando…",
"cant_confirm": "Não consegue confirmar?",
"complete_action": "Ok, entendi",
@@ -999,6 +1027,7 @@
"complete_title": "Confirmado!",
"confirm_identity_description": "Verifique este dispositivo para configurar as mensagens seguras",
"confirm_identity_title": "Confirme sua identidade",
"confirm_the_emojis": "Confirme se os emojis abaixo correspondem aos exibidos no seu outro dispositivo.",
"error_starting_description": "Não foi possível iniciar um bate-papo com o outro usuário.",
"error_starting_title": "Erro ao iniciar a verificação",
"explainer": "As mensagens com este usuário estão protegidas com a criptografia de ponta a ponta e não podem ser lidas por terceiros.",
@@ -1025,15 +1054,21 @@
"wrong_fingerprint": "Não é possível verificar o dispositivo '%(deviceId)s' - a impressão digital fornecida '%(fingerprint)s' não corresponde à impressão digital do dispositivo, '%(fprint)s'"
},
"no_support_qr_emoji": "O dispositivo que você está tentando verificar não suporta a leitura de um código QR ou verificação de emoji, que é o que %(brand)s suporta. Tente com um cliente diferente.",
"now_you_can": "Agora você pode ler ou enviar mensagens com segurança, e qualquer pessoa com quem você converse também poderá confiar neste dispositivo.",
"once_accepted_can_continue": "Depois de aceito, você poderá continuar com a verificação.",
"other_party_cancelled": "Seu contato cancelou a confirmação.",
"prompt_encrypted": "Verifique todos os usuários em uma sala para se certificar de que ela está segura.",
"prompt_unencrypted": "Em salas criptografadas, verifique todos os usuários para garantir a segurança.",
"qr_or_sas": "%(qrCode)s ou %(emojiCompare)s",
"qr_prompt": "Escaneie este código único",
"qr_reciprocate_check_again_device": "Verifique novamente em seu outro dispositivo para concluir a verificação.",
"qr_reciprocate_no": "Não, não vejo nenhum escudo verde.",
"qr_reciprocate_same_shield_user": "Quase lá! Este escudo também aparece para %(displayName)s?",
"qr_reciprocate_yes": "Sim, eu vejo um escudo verde.",
"request_toast_accept_user": "Verificar usuário",
"request_toast_decline_counter": "Ignorar (%(counter)s)",
"request_toast_detail": "%(deviceId)s de %(ip)s",
"request_toast_start_verification": "Iniciar verificação",
"sas_caption_self": "Verifique este dispositivo confirmando que o seguinte número aparece em sua tela.",
"sas_caption_user": "Confirme este usuário, comparando os números a seguir que serão exibidos na sua e na tela dele.",
"sas_description": "Compare um conjunto único de emojis se você não tem uma câmera em nenhum dos dois aparelhos",
@@ -1053,12 +1088,18 @@
"unverified_sessions_toast_title": "Você tem sessões não verificadas",
"use_another_device": "Usar outro dispositivo",
"use_recovery_key": "Usar chave de recuperação",
"verification_dialog_title_choose": "Escolha como verificar",
"verification_dialog_title_compare_emojis": "Comparar emojis",
"verification_dialog_title_confirm_green_shield": "Confirme se você vê um escudo verde no seu outro dispositivo.",
"verification_dialog_title_device": "Verifique outro dispositivo",
"verification_dialog_title_failed": "A verificação falhou",
"verification_dialog_title_start_on_other_device": "Inicie a verificação no outro dispositivo",
"verification_dialog_title_user": "Solicitação de confirmação",
"verification_dialog_title_verified": "Dispositivo verificado",
"verification_skip_warning": "Sem verificar, você não terá acesso a todas as suas mensagens e poderá aparecer como não confiável para outras pessoas.",
"verification_success_with_backup": "O seu novo dispositivo agora está verificado. Ele tem acesso às suas mensagens criptografadas, e outros usuários o verão como confiável.",
"verification_success_without_backup": "Seu novo dispositivo agora foi verificado. Outros usuários o verão como confiável.",
"verify_by_completing_one_of": "Verifique preenchendo um dos seguintes formulários:",
"verify_emoji": "Confirmar por emojis",
"verify_emoji_prompt": "Confirmar comparando emojis únicos.",
"verify_emoji_prompt_qr": "Se você não consegue escanear o código acima, confirme comparando emojis únicos.",
@@ -2578,7 +2619,7 @@
"do_not_close_warning": "Não feche essa janela até que a redefinição seja concluída",
"export_keys": "Exportar chaves",
"import_keys": "Importar chaves",
"other_people_device_description": "Por padrão, em salas criptografadas, não envie mensagens criptografadas para ninguém até que você as tenha verificado",
"other_people_device_description": "Aviso: usuários que não verificaram explicitamente sua identidade com você (por exemplo, usando emojis) não receberão suas mensagens criptografadas. Além disso, dispositivos não verificados de usuários verificados também não receberão suas mensagens criptografadas. As alterações exigem a reinicialização do aplicativo para entrarem em vigor.",
"other_people_device_label": "Nunca envie mensagens criptografadas para dispositivos não verificados",
"other_people_device_title": "Dispositivos de outras pessoas",
"reset_identity": "Redefinir identidade criptográfica",
@@ -2647,6 +2688,7 @@
"allow_spellcheck": "Permitir verificação ortográfica",
"application_language": "Idioma do aplicativo",
"application_language_reload_hint": "O aplicativo será recarregado após selecionar outro idioma",
"avatar_open_menu": "Abrir menu de avatar",
"avatar_remove_progress": "Removendo a imagem...",
"avatar_save_progress": "Enviando imagem ...",
"avatar_upload_error_text": "O formato do arquivo não é suportado ou a imagem é maior que %(size)s.",
@@ -2843,6 +2885,10 @@
"rule_suppress_notices": "Mensagens enviadas por bots",
"rule_tombstone": "Quando a versão da sala é atualizada",
"show_message_desktop_notification": "Mostrar a mensagem na notificação da área de trabalho",
"sounds_release_announcement": {
"description": "Seu som de notificação e toque de chamada foram atualizados — mais claros, rápidos e menos perturbadores.",
"title": "Atualizamos seus sons."
},
"voip": "Chamadas de áudio e vídeo"
},
"preferences": {
@@ -2869,6 +2915,7 @@
"room_list_heading": "Lista de salas",
"show_avatars_pills": "Mostrar avatares em menções de usuários, salas e eventos",
"show_polls_button": "Mostrar botão de enquetes",
"startup_window_behaviour_label": "Inicialização e comportamento da janela",
"surround_text": "Circule o texto selecionado ao digitar caracteres especiais",
"time_heading": "Exibindo tempo",
"user_timezone": "Definir fuso horário"
@@ -3042,6 +3089,12 @@
"spaces_explainer": "Os espaços são formas de agrupar salas e pessoas. Além dos espaços em que você está, você também pode usar alguns pré-construídos.",
"title": "Barra lateral"
},
"start_automatically": {
"disabled": "Não",
"enabled": "Sim",
"label": "Abra %(brand)s quando você entrar no seu computador.",
"minimised": "Minimizado"
},
"tac_only_notifications": "Mostrar apenas notificações no centro de atividades do tópico",
"use_12_hour_format": "Mostrar os horários em formato de 12h (p.ex: 2:30pm)",
"use_command_enter_send_message": "Usar Command + Enter para enviar uma mensagem",
@@ -4043,9 +4096,11 @@
"user_busy_description": "O usuário que você chamou está ocupado.",
"user_is_presenting": "%(sharerName)s está apresentando",
"video_call": "Chamada de vídeo",
"video_call_incoming": "Chamada de vídeo recebida",
"video_call_started": "Videochamada iniciada",
"video_call_using": "Chamada de vídeo usando:",
"voice_call": "Chamada de voz",
"voice_call_incoming": "Chamada de voz recebida",
"you_are_presenting": "Você está apresentando"
},
"web_default_device_name": "%(appName)s: %(browserName)s em %(osName)s",

View File

@@ -95,6 +95,7 @@
"maximise": "Развернуть",
"mention": "Упомянуть",
"minimise": "Свернуть",
"new_conversation": "Новый диалог",
"new_room": "Новая комната",
"new_video_room": "Новая видеокомната",
"next": "Далее",
@@ -579,7 +580,7 @@
"setup_secure_messages": "Настроить безопасные сообщения",
"show_more": "Показать больше",
"someone": "Кто-то",
"space": одпространство",
"space": "Пространство",
"spaces": "Пространства",
"sticker": "Стикер",
"stickerpack": "Набор стикеров",
@@ -606,6 +607,7 @@
"video": "Видео",
"video_room": "Видеокомната",
"view_message": "Посмотреть сообщение",
"voice": "Голос",
"warning": "Внимание"
},
"composer": {
@@ -650,8 +652,8 @@
"placeholder_encrypted": "Отправить зашифрованное сообщение…",
"placeholder_reply": "Отправить незашифрованный ответ…",
"placeholder_reply_encrypted": "Отправить ответ…",
"placeholder_thread": "Ответить на обсуждение…",
"placeholder_thread_encrypted": "Ответить на зашифрованное обсуждение…",
"placeholder_thread": "Ответить в небезопасном обсуждение…",
"placeholder_thread_encrypted": "Ответить в обсуждение…",
"poll_button": "Опрос",
"poll_button_no_perms_description": "У вас нет разрешения начинать опросы в этой комнате.",
"poll_button_no_perms_title": "Требуется разрешение",
@@ -808,6 +810,15 @@
},
"developer_mode": "Режим разработчика",
"developer_tools": "Инструменты разработчика",
"device_id": "ID устройства: %(deviceId)s",
"device_keys": "Ключи устройств",
"device_verification_status": {
"signed_by_owner": "Статус проверки: <E2EIcon /> Подписано владельцем",
"unknown": "Статус проверки: Неизвестно",
"unverified": "Статус проверки:<E2EIcon /> Не подписано владельцем",
"verified": "Статус проверки:<E2EIcon /> Подтверждено перекрестной подписью"
},
"devices": "Криптографические устройства (%(count)s )",
"edit_setting": "Изменить настройки",
"edit_values": "Редактировать значения",
"empty_string": "<empty string>",
@@ -823,6 +834,7 @@
"failed_to_save": "Не удалось сохранить настройки.",
"failed_to_send": "Не удалось отправить событие!",
"id": "ID: ",
"invalid_device_key_id": "Неверный ключа ID устройства",
"invalid_json": "Не похоже на действующий JSON.",
"level": "Уровень",
"low_bandwidth_mode": "Режим низкой пропускной способности",
@@ -833,6 +845,7 @@
"notification_state": "Состояние уведомления <strong>%(notificationState)s</strong>",
"notifications_debug": "Отладка уведомлений",
"number_of_users": "Количество пользователей",
"only_joined_members": "Только зарегистрированные пользователи",
"original_event_source": "Оригинальный исходный код",
"room_encrypted": "Комната <strong> зашифрована ✅</strong>",
"room_id": "ID комнаты: %(roomId)s",
@@ -881,10 +894,23 @@
"toggle_event": "переключить событие",
"toolbox": "Панель инструментов",
"use_at_own_risk": "Этот пользовательский интерфейс НЕ проверяет типы значений. Используйте на свой страх и риск.",
"user_avatar": "Аватар: %(avatar)s",
"user_displayname": "Отображаемое имя: %(displayname)s",
"user_id": "ID пользователя: %(userId)s",
"user_no_avatar": "Аватар: <i>Нет</i>",
"user_no_displayname": "Отображаемое имя: <i>Отсутствует</i>",
"user_read_up_to": "Пользователь прочитал до: ",
"user_read_up_to_ignore_synthetic": "Пользователь прочитал до (IgnoreSynthetic): ",
"user_read_up_to_private": "Пользователь прочитал до (m.read.private): ",
"user_read_up_to_private_ignore_synthetic": "Пользователь прочитал до (m.read.private; ignoreSynthetic): ",
"user_room_membership": "Членство: %(membership)s",
"user_verification_status": {
"identity_changed": "Статус проверки:<E2EIcon /> Не проверено, и личность изменена",
"unverified": "Статус проверки: <E2EIcon /> Непроверено",
"verified": "Статус проверки: <E2EIcon /> Проверено",
"was_verified": "Статус проверки: <E2EIcon /> Был проверен, но личность изменилась"
},
"users": "Пользователи",
"value": "Значение",
"value_colon": "Значение:",
"value_in_this_room": "Значение в этой комнате",
@@ -1089,6 +1115,7 @@
},
"verification_requested_toast_title": "Запрошено подтверждение",
"verified_identity_changed": "Подтвержденная личность %(displayName)s (<b>%(userId)s</b>) изменилась. <a>Узнайте больше</a>",
"verified_identity_changed_no_displayname": "<b>%(userId)s</b> личность была сброшена. <a>Узнать подробности</a>",
"verify_toast_description": "Другие пользователи могут не доверять этому сеансу",
"verify_toast_title": "Заверьте этот сеанс",
"withdraw_verification_action": "Подтверждение верификации"
@@ -1372,6 +1399,7 @@
"name_mxid_share_room": "Пригласите кого-нибудь, используя его имя, имя пользователя (например, <userId/>) или <a>поделитесь этой комнатой</a>.",
"name_mxid_share_space": "Пригласите кого-нибудь, используя их отображаемое имя или имя учётной записи (например, <userId/>) или <a>поделитесь этим пространством</a>.",
"progress": {
"dont_close": "Не закрывайте приложение, до завершения.",
"preparing": "Подготовка приглашений..."
},
"recents_section": "Недавние Диалоги",
@@ -1542,6 +1570,9 @@
"render_reaction_images_description": "Иногда их называют \"пользовательскими эмодзи\".",
"report_to_moderators": "Пожаловаться модераторам",
"report_to_moderators_description": "В поддерживающих модерирование комнатах, кнопка \"Пожаловаться\" позволит вам сообщить о нарушении модераторам комнаты.",
"share_history_on_invite": "Поделиться зашифрованной историей с новыми участниками",
"share_history_on_invite_description": "Приглашая пользователя в зашифрованную комнату, для которой установлена видимость истории как «общая», поделитесь зашифрованной историей с этим пользователем и примите зашифрованную историю, когда вас приглашают в такую комнату.",
"share_history_on_invite_warning": "Эта функция ЭКСПЕРИМЕНТАЛЬНАЯ и в ней реализованы не все меры безопасности. Не включайте её в рабочих учётных записях.",
"sliding_sync": "Режим Sliding Sync",
"sliding_sync_description": "В активной разработке, нельзя отключить.",
"sliding_sync_disabled_notice": "Выйдите из системы и снова войдите, чтобы отключить",
@@ -2002,7 +2033,9 @@
"inaccessible_subtitle_1": "Повторите попытку позже или попросите администратора комнаты или пространства проверить, есть ли у вас доступ.",
"inaccessible_subtitle_2": "При попытке получить доступ к комнате или пространству была возвращена ошибка %(errcode)s. Если вы думаете, что вы видите это сообщение по ошибке, пожалуйста, <issueLink>отправьте отчет об ошибке</issueLink>.",
"intro": {
"display_topic": "Тема: <topic/>",
"dm_caption": "В этом разговоре только вы двое, если только кто-нибудь из вас не пригласит кого-нибудь присоединиться.",
"edit_topic": "Тема: <topic/> (<a>редактировать</a>)",
"enable_encryption_prompt": "Включите шифрование в настройках.",
"encrypted_3pid_dm_pending_join": "Как только все присоединятся, вы сможете общаться в чате",
"no_avatar_label": "Добавьте фото, чтобы люди могли легко заметить вашу комнату.",
@@ -2066,7 +2099,7 @@
"pinned_message_banner": {
"button_close_list": "Закрыть список",
"button_view_all": "Посмотреть все",
"description": "В этой комнате есть закрепленные сообщения. Нажмите, чтобы просмотреть их.",
"description": "Закрепленные сообщения",
"title": "<bold>%(index)s из %(length)s</bold> Закрепленные сообщения"
},
"read_topic": "Нажмите, чтобы увидеть тему",
@@ -2170,18 +2203,37 @@
"mark_unread": "Отметить как непрочитанное"
},
"notification_options": "Настройки уведомлений",
"open_space_menu": "Открыть меню пространств",
"primary_filters": "Фильтры комнат",
"redacting_messages_status": {
"one": "Удаляются сообщения в %(count)s комнате",
"other": "Удаляются сообщения в %(count)s комнатах"
},
"release_announcement": {
"next": "Далее"
"done": "Готово",
"filter": {
"description": "Фильтруйте ваши чаты одним кликом. Разверните, чтобы увидеть больше фильтров.",
"title": "Новые быстрые фильтры"
},
"intro": {
"description": "Список чатов обновили, чтобы он был понятнее и проще в использовании.",
"title": "Чаты теперь выглядят по-новому!"
},
"next": "Далее",
"settings": {
"description": "Чтобы включить или выключить предварительный просмотр сообщений, зайди в Все настройки > Параметры > Список комнат",
"title": "Некоторые настройки были перемещены"
},
"sort": {
"description": "Измените порядок ваших чатов от последних до A-Я.",
"title": "Сортируйте ваши чаты"
}
},
"room": {
"more_options": "Дополнительные параметры",
"open_room": "Открыть комнату %(roomName)s"
},
"room_options": "Настройки комнаты",
"show_less": "Показать меньше",
"show_n_more": {
"other": "Показать ещё %(count)s",
@@ -2197,6 +2249,10 @@
"atoz": "А-Я"
},
"sort_unread_first": "Комнаты с непрочитанными сообщениями в начале",
"space_menu": {
"home": "Пространство — Главная",
"space_settings": "Настройки Пространства"
},
"space_menu_label": "Меню %(spaceName)s",
"sublist_options": "Настройки списка",
"suggested_rooms_heading": "Предлагаемые комнаты"
@@ -2362,6 +2418,10 @@
"users_default": "Роль по умолчанию"
},
"security": {
"cannot_change_to_private_due_to_missing_history_visiblity_permissions": {
"description": "У вас нет прав на изменение настроек видимости истории чата. Это опасно, так как может позволить неприглашенным пользователям читать сообщения.",
"title": "Не получается сделать комнату приватной"
},
"enable_encryption_confirm_description": "После включения шифрования в комнате оно не может быть отключено. Сообщения, отправленные в шифрованной комнате, смогут прочитать только участники комнаты, но не сервер. Включенное шифрование может помешать корректной работе многим ботам и мостам. <a>Подробнее о шифровании.</a>",
"enable_encryption_confirm_title": "Разрешить шифрование?",
"enable_encryption_public_room_confirm_description_1": "<b>Не рекомендуется добавлять шифрование в публичные комнаты.</b> Кто угодно может найти и присоединиться к ним, тем самым позволяя читать сообщения. Вы не получите преимуществ шифрования и при этом не сможете его отключить. Шифрование сообщений в публичной комнате лишь замедлит их получение и отправку.",
@@ -2379,7 +2439,7 @@
"history_visibility_joined": "Только участники (с момента их входа)",
"history_visibility_legend": "Кто может читать историю?",
"history_visibility_shared": "Только участники (с момента выбора этого параметра)",
"history_visibility_warning": "Изменения в том, кто может читать историю, будут применяться только к будущим сообщениям в этой комнате. Существующие истории останутся без изменений.",
"history_visibility_warning": "Видимость существующей истории сообщений в этой комнате не изменится.",
"history_visibility_world_readable": "Все",
"join_rule_description": "Укажите, кто может присоединиться к %(roomName)s.",
"join_rule_invite": "Приватное (только по приглашению)",
@@ -2426,6 +2486,7 @@
"many": "Обновление пространств... (%(progress)s из %(count)s)"
},
"join_rule_upgrade_upgrading_room": "Обновление комнаты",
"join_rule_world_readable_description": "Изменение того, кто может присоединиться к комнате, также изменит видимость будущих сообщений.",
"public_without_alias_warning": "Для связи с этой комнатой, пожалуйста, добавьте адрес.",
"publish_room": "Сделать эту комнату видимой в каталоге общественных комнат.",
"publish_space": "Сделайте это пространство видимым в каталоге общественных комнат.",
@@ -2483,6 +2544,10 @@
"recent_changes_heading": "Последние изменения, которые еще не были получены",
"title": "Сервер не отвечает"
},
"service_worker_error": {
"description": "%(brand)s Для загрузки аутентифицированных медиафайлов из репозиториев Matrix требуется сервис-воркер. Ваш браузер не поддерживает эту функцию, поэтому медиафайлы могут не загружаться.",
"title": "Не удалось загрузить Service Worker"
},
"seshat": {
"error_initialising": "Инициализация поиска сообщений не удалась, проверьте <a>ваши настройки</a> для получения дополнительной информации",
"reset_button": "Сброс хранилища событий",
@@ -2560,13 +2625,15 @@
"breadcrumb_second_description": "Вы потеряете историю сообщений, которая хранится только на сервере",
"breadcrumb_third_description": "Вам нужно будет заново подтвердить все существующие устройства и контакты.",
"breadcrumb_title": "Вы уверены, что хотите сбросить свою идентификацию?",
"breadcrumb_title_cant_confirm": "Вам необходимо сбросить свою личность",
"breadcrumb_title_forgot": "Забыли ключ восстановления? Вам нужно будет восстановить свою идентификацию.",
"breadcrumb_title_sync_failed": "Не удалось синхронизировать хранилище ключей. Вам необходимо сбросить идентификационные данные.",
"breadcrumb_warning": "Делайте это только в том случае, если вы считаете, что ваша учетная запись взломана.",
"details_title": "Сведения о шифровании",
"do_not_close_warning": "Не закрывайте это окно до тех пор, пока сброс не будет завершен",
"export_keys": "Экспортировать ключи",
"import_keys": "Импортировать ключи",
"other_people_device_description": "Внимание: пользователи, которые явно не подтвердили вашу личность (например, с помощью emoji), не получат ваши зашифрованные сообщения. Кроме того, неверифицированные устройства верифицированных пользователей не будут получать ваши зашифрованные сообщения.",
"other_people_device_description": "Внимание: пользователи, которые явно не подтвердили вашу личность (например, с помощью эмодзи), не получат ваши зашифрованные сообщения. Кроме того, неверифицированные устройства верифицированных пользователей не будут получать ваши зашифрованные сообщения.",
"other_people_device_label": "В зашифрованных комнатах отправляйте сообщения только проверенным пользователям",
"other_people_device_title": "Устройства других людей",
"reset_identity": "Сбросить криптографическую идентификацию",
@@ -2575,6 +2642,8 @@
"session_key": "Ключ сеанса:",
"title": "Дополнительно"
},
"confirm_key_storage_off": "Вы уверены, что хотите оставить хранилище ключей отключенным?",
"confirm_key_storage_off_description": "Если вы выйдете из системы на всех своих устройствах, вы потеряете историю сообщений и вам придется заново подтвердить все существующие контакты.<a>Узнать больше</a>",
"delete_key_storage": {
"breadcrumb_page": "Удалить хранилище ключей",
"confirm": "Удалить хранилище ключей",
@@ -2633,6 +2702,7 @@
"allow_spellcheck": "Разрешить проверку орфографии",
"application_language": "Язык приложения",
"application_language_reload_hint": "Приложение перезагрузится после выбора другого языка",
"avatar_open_menu": "Открыть меню аватара",
"avatar_remove_progress": "Удаление изображения…",
"avatar_save_progress": "Загрузка изображения...",
"avatar_upload_error_text": "Формат файла не поддерживается или размер изображения превышает %(size)s.",
@@ -2660,6 +2730,7 @@
"discovery_needs_terms_title": "Позвольте людям найти вас",
"display_name": "Отображаемое имя",
"display_name_error": "Невозможно установить отображаемое имя",
"email_adding_unsupported_by_hs": "Этот домашний сервер не позволяет добавлять адреса электронной почты в вашу учетную запись.",
"email_address_in_use": "Этот адрес электронной почты уже используется",
"email_address_label": "Адрес электронной почты",
"email_not_verified": "Ваш адрес электронной почты еще не проверен",
@@ -2684,7 +2755,9 @@
"error_share_msisdn_discovery": "Не удается предоставить общий доступ к номеру телефона",
"identity_server_no_token": "Не найден токен доступа для идентификации",
"identity_server_not_set": "Сервер идентификации не установлен",
"invalid_phone_number": "Предоставленный номер телефона, по-видимому, недействителен.",
"language_section": "Язык",
"msisdn_adding_unsupported_by_hs": "Этот домашний сервер не позволяет добавлять телефонные номера в вашу учетную запись.",
"msisdn_in_use": "Этот номер телефона уже используется",
"msisdn_label": "Номер телефона",
"msisdn_verification_field_label": "Код подтверждения",
@@ -2707,6 +2780,9 @@
"inline_url_previews_room": "Включить предпросмотр ссылок для участников этой комнаты по умолчанию",
"inline_url_previews_room_account": "Включить предпросмотр ссылок в этой комнате (влияет только на вас)",
"insert_trailing_colon_mentions": "Вставлять двоеточие после упоминания пользователя в начале сообщения",
"invite_controls": {
"default_label": "Разрешить пользователям приглашать вас в комнаты"
},
"jump_to_bottom_on_send": "Перейти к нижней части временной шкалы, когда вы отправляете сообщение",
"key_backup": {
"setup_secure_backup": {
@@ -2763,6 +2839,8 @@
"media_preview": {
"hide_avatars": "Скрыть аватары комнаты и приглашающего",
"hide_media": "Всегда скрывать",
"media_preview_description": "Скрытый медиафайл всегда можно отобразить, нажав на него.",
"media_preview_label": "Показать медиафайлы в хронологии",
"show_in_private": "В личных комнатах",
"show_media": "Всегда показывать"
},
@@ -2821,9 +2899,14 @@
"rule_suppress_notices": "Сообщения от ботов",
"rule_tombstone": "При обновлении комнат",
"show_message_desktop_notification": "Показывать текст сообщения в уведомлениях на рабочем столе",
"sounds_release_announcement": {
"description": "Ваши уведомления и сигналы вызова были обновлены — теперь они понятнее, быстрее и менее отвлекающие.",
"title": "Мы обновили ваши звуки"
},
"voip": "Аудио и видео звонки"
},
"preferences": {
"Electron.enableContentProtection": "Предотвратить захват содержимого окна другими приложениями",
"Electron.enableHardwareAcceleration": "Включите аппаратное ускорение (перезапустите %(appName)s, чтобы настройки вступили в силу)",
"always_show_menu_bar": "Всегда показывать строку меню",
"autocomplete_delay": "Задержка автодополнения (мс)",
@@ -2832,6 +2915,7 @@
"composer_heading": "Редактор",
"default_timezone": "Браузер по умолчанию (%(timezone)s)",
"dialog_title": "<strong>Настройки:</strong> Параметры",
"enable_content_protection": "Включить защиту контента",
"enable_hardware_acceleration": "Включить аппаратное ускорение",
"enable_tray_icon": "Показывать значок в трее и сворачивать в него окно при закрытии",
"keyboard_heading": "Горячие клавиши",
@@ -2845,6 +2929,7 @@
"room_list_heading": "Список комнат",
"show_avatars_pills": "Показывать аватары в упоминаниях пользователей, комнатах и событиях",
"show_polls_button": "Показывать кнопку опроса",
"startup_window_behaviour_label": "Запуск и поведение окна",
"surround_text": "Обводить выделенный текст при вводе специальных символов",
"time_heading": "Отображение времени",
"user_timezone": "Установить часовой пояс"
@@ -2885,7 +2970,7 @@
"message_search_unsupported_web": "%(brand)s не может безопасно кэшировать зашифрованные сообщения локально во время работы в веб-браузере. Используйте <desktopLink>%(brand)s Desktop</desktopLink>, чтобы зашифрованные сообщения появились в результатах поиска.",
"record_session_details": "Записывать название клиента, версию и URL-адрес для более лёгкого распознавания сеансов в менеджере сеансов",
"send_analytics": "Отправить данные аналитики",
"strict_encryption": "Никогда не отправлять неподтверждённым сеансам зашифрованные сообщения через этот сеанс"
"strict_encryption": "Отправлять сообщения только проверенным пользователям"
},
"send_read_receipts": "Уведомлять о прочтении",
"send_read_receipts_unsupported": "Ваш сервер не поддерживает отключение отправки уведомлений о прочтении.",
@@ -3025,6 +3110,12 @@
"spaces_explainer": "Пространства — это способ сгруппировать комнаты и людей. Помимо пространств, в которых вы находитесь, вы также можете использовать готовые помещения.",
"title": "Боковая панель"
},
"start_automatically": {
"disabled": "Нет",
"enabled": "Да",
"label": "Запустить %(brand)s при входе в систему",
"minimised": "Минимизированный"
},
"tac_only_notifications": "Показывать уведомления только в центре активностей обсуждений",
"use_12_hour_format": "Отображать время в 12 часовом формате (напр. 2:30pm)",
"use_command_enter_send_message": "Cmd + Enter, чтобы отправить сообщение",
@@ -3108,6 +3199,8 @@
"jumptodate": "Перейти к заданной дате в ленте сообщений",
"jumptodate_invalid_input": "Мы не смогли распознать заданную дату (%(inputDate)s). Попробуйте использовать формат ГГГГ-ММ-ДД.",
"lenny": "Добавляет ( ͡° ͜ʖ ͡°) в начало сообщения",
"manual_device_verification_confirm_description": "Это позволит другому устройству отправлять и получать сообщения от вашего имени. ЕСЛИ КТО-ТО СКАЗАЛ ВАМ ВСТАВИТЬ ЗДЕСЬ ЧТО-ТО, ВЕРОЯТНО, ВАС ПЫТАЮТСЯ ОБМАНУТЬ! Вы уверены, что хотите подтвердить это другое устройство?",
"manual_device_verification_confirm_title": "Внимание: ручная проверка устройства",
"me": "Отображение действий",
"msg": "Отправить сообщение данному пользователю",
"myavatar": "Меняет изображение профиля во всех комнатах",
@@ -3148,7 +3241,7 @@
"upgraderoom": "Обновляет комнату до новой версии",
"upgraderoom_permission_error": "У вас нет необходимых разрешений для использования этой команды.",
"usage": "Использование",
"verify": "Проверяет пользователя, сеанс и публичные ключи",
"verify": "Проверь вручную одно из своих устройств",
"view": "Просмотр комнаты с указанным адресом",
"whois": "Показать информацию о пользователе"
},
@@ -3258,6 +3351,7 @@
"heading_without_query": "Поиск",
"join_button_text": "Присоединиться к %(roomAddress)s",
"keyboard_scroll_hint": "Используйте <arrows/> для прокрутки",
"messages_label": "Сообщения",
"other_rooms_in_space": "Прочие комнаты в %(spaceName)s",
"public_rooms_label": "Публичные комнаты",
"public_spaces_label": "Публичное пространство",
@@ -3365,6 +3459,7 @@
"historical_event_unverified_device": "Вам необходимо подтвердить это устройство для доступа к истории сообщений.",
"historical_event_user_not_joined": "У вас нет доступа к этому сообщению",
"sender_identity_previously_verified": "Подтвержденная личность изменилась",
"sender_unsigned_device": "Отправлено с незащищенного устройства",
"unable_to_decrypt": "Не удалось расшифровать сообщение"
},
"disambiguated_profile": "%(displayName)s (%(matrixId)s)",
@@ -3390,6 +3485,7 @@
"unable_to_find": "Попытка загрузить выбранный интервал истории чата этой комнаты не удалась, так как запрошенный элемент не найден."
},
"m.audio": {
"audio_player": "Аудиоплеер",
"error_downloading_audio": "Ошибка загрузки аудио",
"error_processing_audio": "Ошибка обработки звукового сообщения",
"error_processing_voice_message": "Ошибка при обработке голосового сообщения",
@@ -3569,7 +3665,8 @@
},
"m.sticker": "%(senderDisplayName)s отправил(а) стикер.",
"m.video": {
"error_decrypting": "Ошибка расшифровки видео"
"error_decrypting": "Ошибка расшифровки видео",
"show_video": "Показать видео"
},
"m.widget": {
"added": "Виджет %(widgetName)s был добавлен %(senderName)s",
@@ -3832,6 +3929,7 @@
"description": "Чтобы создать ссылку для совместного доступа, сделайте эту комнату <b>общедоступной</b> и разрешите пользователям <b>запрашивать присоединение</b>. Это позволит гостям присоединиться без приглашения.",
"dont_change_description": "Кроме того, вы можете провести звонок в отдельной комнате.",
"no_change": "Я не хочу менять уровень доступа.",
"revert_access_description": "(Это можно вернуть к прежнему значению в настройках комнаты: <b>Безопасность и конфиденциальность</b> / <b>Доступ</b>)",
"title": "Изменить уровень доступа в комнату"
},
"upload_failed_generic": "Файл '%(fileName)s' не был загружен.",
@@ -3952,6 +4050,7 @@
"connection_lost": "Соединение с сервером потеряно",
"connection_lost_description": "Вы не можете совершать вызовы без подключения к серверу.",
"consulting": "Общение с %(transferTarget)s. <a>Перевод на %(transferee)s</a>",
"decline_call": "Отклонить",
"default_device": "Устройство по умолчанию",
"dial": "Набор",
"dialpad": "Панель набора номера",
@@ -4003,6 +4102,7 @@
"show_sidebar_button": "Показать боковую панель",
"silence": "Тихий вызов",
"silenced": "Оповещения приглушены",
"skip_lobby_toggle_option": "Присоединиться прямо сейчас",
"start_screenshare": "Начать делиться экраном",
"stop_screenshare": "Перестать делиться экраном",
"too_many_calls": "Слишком много звонков",
@@ -4023,9 +4123,11 @@
"user_busy_description": "Вызываемый пользователь занят.",
"user_is_presenting": "%(sharerName)s показывает",
"video_call": "Видеовызов",
"video_call_incoming": "Входящий видеозвонок",
"video_call_started": "Начался видеозвонок",
"video_call_using": "Видеозвонок с использованием:",
"voice_call": "Голосовой вызов",
"voice_call_incoming": "Входящий голосовой вызов",
"you_are_presenting": "Вы представляете"
},
"web_default_device_name": "%(appName)s: %(browserName)s на %(osName)s",

View File

@@ -610,6 +610,7 @@
"video": "Video",
"video_room": "Video miestnosť",
"view_message": "Zobraziť správu",
"voice": "Hlas",
"warning": "Upozornenie"
},
"composer": {
@@ -4175,9 +4176,11 @@
"user_busy_description": "Volaný používateľ má obsadené.",
"user_is_presenting": "%(sharerName)s prezentuje",
"video_call": "Video hovor",
"video_call_incoming": "Prichádzajúci videohovor",
"video_call_started": "Videohovor bol spustený",
"video_call_using": "Videohovor pomocou:",
"voice_call": "Hlasový hovor",
"voice_call_incoming": "Prichádzajúci hlasový hovor",
"you_are_presenting": "Prezentujete"
},
"web_default_device_name": "%(appName)s: %(browserName)s na %(osName)s",

View File

@@ -84,6 +84,7 @@ export enum CallEvent {
Participants = "participants",
Close = "close",
Destroy = "destroy",
CallTypeChanged = "call_type_changed",
}
interface CallEventHandlerMap {
@@ -94,6 +95,7 @@ interface CallEventHandlerMap {
) => void;
[CallEvent.Close]: () => void;
[CallEvent.Destroy]: () => void;
[CallEvent.CallTypeChanged]: (callType: CallType) => void;
}
/**
@@ -103,6 +105,18 @@ export abstract class Call extends TypedEventEmitter<CallEvent, CallEventHandler
protected readonly widgetUid: string;
protected readonly room: Room;
private _callType: CallType = CallType.Video;
public get callType(): CallType {
return this._callType;
}
protected set callType(callType: CallType) {
if (this._callType !== callType) {
this.emit(CallEvent.CallTypeChanged, callType);
}
this._callType = callType;
}
/**
* The time after which device member state should be considered expired.
*/
@@ -544,7 +558,24 @@ export enum ElementCallIntent {
StartCall = "start_call",
JoinExisting = "join_existing",
StartCallDM = "start_call_dm",
StartCallDMVoice = "start_call_dm_voice",
JoinExistingDM = "join_existing_dm",
JoinExistingDMVoice = "join_existing_dm_voice",
}
/**
* Parameters to be passed during widget creation.
* These parameters are hints only, and may not be accepted by the implementation.
*/
export interface WidgetGenerationParameters {
/**
* Skip showing the lobby screen of a call.
*/
skipLobby?: boolean;
/**
* Does the user intent to start a voice call?
*/
voiceOnly?: boolean;
}
/**
@@ -586,7 +617,12 @@ export class ElementCall extends Call {
* @param client The current client.
* @param roomId The room ID for the call.
*/
private static appendRoomParams(params: URLSearchParams, client: MatrixClient, roomId: string): void {
private static appendRoomParams(
params: URLSearchParams,
client: MatrixClient,
roomId: string,
{ voiceOnly }: WidgetGenerationParameters,
): void {
const room = client.getRoom(roomId);
if (!room) {
// If the room isn't known, or the room is a video room then skip setting an intent.
@@ -610,13 +646,17 @@ export class ElementCall extends Call {
// is released and upgraded.
if (isDM) {
if (hasCallStarted) {
params.append("intent", ElementCallIntent.JoinExistingDM);
params.append(
"intent",
voiceOnly ? ElementCallIntent.JoinExistingDMVoice : ElementCallIntent.JoinExistingDM,
);
params.append("preload", "false");
} else {
params.append("intent", ElementCallIntent.StartCallDM);
params.append("intent", voiceOnly ? ElementCallIntent.StartCallDMVoice : ElementCallIntent.StartCallDM);
params.append("preload", "false");
}
} else {
// Group chats do not have a voice option.
if (hasCallStarted) {
params.append("intent", ElementCallIntent.JoinExisting);
params.append("preload", "false");
@@ -717,7 +757,7 @@ export class ElementCall extends Call {
.forEach((font) => params.append("font", font));
}
this.appendAnalyticsParams(params, client);
this.appendRoomParams(params, client, roomId);
this.appendRoomParams(params, client, roomId, opts);
const replacedUrl = params.toString().replace(/%24/g, "$");
url.hash = `#?${replacedUrl}`;
@@ -751,11 +791,43 @@ export class ElementCall extends Call {
);
}
/**
* Get the correct intent for a widget, so that Element Call presents the correct
* default config.
* @param client The matrix client.
* @param roomId
* @param voiceOnly Should the call be voice-only, or video (default).
*/
public static getWidgetIntent(client: MatrixClient, roomId: string, voiceOnly?: boolean): ElementCallIntent {
const room = client.getRoom(roomId);
if (room !== null && !isVideoRoom(room)) {
const isDM = !!DMRoomMap.shared().getUserIdForRoomId(room.roomId);
const oldestCallMember = client.matrixRTC.getRoomSession(room).getOldestMembership();
const hasCallStarted = !!oldestCallMember && oldestCallMember.sender !== client.getSafeUserId();
if (isDM) {
if (hasCallStarted) {
return voiceOnly ? ElementCallIntent.JoinExistingDMVoice : ElementCallIntent.JoinExistingDM;
} else {
return voiceOnly ? ElementCallIntent.StartCallDMVoice : ElementCallIntent.StartCallDM;
}
} else {
if (hasCallStarted) {
return ElementCallIntent.JoinExisting;
} else {
return ElementCallIntent.StartCall;
}
}
}
// If unknown, default to joining an existing call.
return ElementCallIntent.JoinExisting;
}
private static getWidgetData(
client: MatrixClient,
roomId: string,
currentData: IWidgetData,
overwriteData: IWidgetData,
voiceOnly?: boolean,
): IWidgetData {
let perParticipantE2EE = false;
if (
@@ -763,9 +835,13 @@ export class ElementCall extends Call {
!SettingsStore.getValue("feature_disable_call_per_sender_encryption")
)
perParticipantE2EE = true;
const intent = ElementCall.getWidgetIntent(client, roomId, voiceOnly);
return {
...currentData,
...overwriteData,
intent,
perParticipantE2EE,
};
}
@@ -791,7 +867,7 @@ export class ElementCall extends Call {
this.updateParticipants();
}
public static get(room: Room): ElementCall | null {
public static get(room: Room, voiceOnly?: boolean): ElementCall | null {
const apps = WidgetStore.instance.getApps(room.roomId);
const hasEcWidget = apps.some((app) => WidgetType.CALL.matches(app.type));
const session = room.client.matrixRTC.getRoomSession(room);
@@ -874,7 +950,10 @@ export class ElementCall extends Call {
if (this.session.memberships.length === 0 && !this.presented && !this.room.isCallRoom()) this.destroy();
};
private readonly onMembershipChanged = (): void => this.updateParticipants();
private readonly onMembershipChanged = (): void => {
this.updateParticipants();
this.callType = this.session.getConsensusCallIntent() === "audio" ? CallType.Voice : CallType.Video;
};
private updateParticipants(): void {
const participants = new Map<RoomMember, Set<string>>();

View File

@@ -25,11 +25,16 @@ interface EmittedEvents {
export class ElementWebExtrasApi extends TypedEventEmitter<keyof EmittedEvents, EmittedEvents> implements ExtrasApi {
public spacePanelItems = new Map<string, SpacePanelItemProps>();
public visibleRoomBySpaceKey = new Map<string, () => string[]>();
public setSpacePanelItem(spacekey: string, item: SpacePanelItemProps): void {
this.spacePanelItems.set(spacekey, item);
this.emit(ExtrasApiEvent.SpacePanelItemsChanged);
}
public getVisibleRoomBySpaceKey(spaceKey: string, cb: () => string[]): void {
this.visibleRoomBySpaceKey.set(spaceKey, cb);
}
}
export function useModuleSpacePanelItems(api: ElementWebExtrasApi): ModuleSpacePanelItem[] {

View File

@@ -69,7 +69,7 @@ class LifecycleStore extends AsyncStore<IState> {
dis.dispatch(deferredAction);
break;
}
case "on_client_not_viable":
case Action.ClientNotViable:
case Action.OnLoggedOut:
this.reset();
break;

View File

@@ -77,7 +77,7 @@ export abstract class ReadyWatchingStore extends EventEmitter implements IDestro
this.matrixClient = payload.matrixClient;
await this.onReady();
}
} else if (payload.action === "on_client_not_viable" || payload.action === Action.OnLoggedOut) {
} else if (payload.action === Action.ClientNotViable || payload.action === Action.OnLoggedOut) {
if (this.matrixClient) {
await this.onNotReady();
this.matrixClient = undefined;

View File

@@ -52,6 +52,7 @@ import { ModuleRunner } from "../modules/ModuleRunner";
import { setMarkedUnreadState } from "../utils/notifications";
import { ConnectionState, ElementCall } from "../models/Call";
import { isVideoRoom } from "../utils/video-rooms";
import { ModuleApi } from "../modules/Api";
const NUM_JOIN_RETRY = 5;
@@ -285,17 +286,22 @@ export class RoomViewStore extends EventEmitter {
break;
}
case "on_client_not_viable":
case Action.ClientNotViable:
case Action.OnLoggedOut:
this.reset();
break;
case "reply_to_event":
// Thread timeline view handles its own reply-to-state
if (TimelineRenderingType.Thread !== payload.context) {
const roomId: string | undefined = payload.event?.getRoomId();
// If currently viewed room does not match the room in which we wish to reply then change rooms this
// can happen when performing a search across all rooms. Persist the data from this event for both
// room and search timeline rendering types, search will get auto-closed by RoomView at this time.
if (payload.event && payload.event.getRoomId() !== this.state.roomId) {
if (payload.event && roomId !== this.state.roomId) {
// if the room is displayed in a module, we don't want to change the room view
if (roomId && this.isRoomDisplayedInModule(roomId)) return;
this.dis?.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
room_id: payload.event.getRoomId(),
@@ -365,7 +371,9 @@ export class RoomViewStore extends EventEmitter {
call.presented = true;
// Immediately start the call. This will connect to all required widget events
// and allow the widget to show the lobby.
if (call.connectionState === ConnectionState.Disconnected) call.start({ skipLobby: payload.skipLobby });
if (call.connectionState === ConnectionState.Disconnected) {
call.start({ skipLobby: payload.skipLobby, voiceOnly: payload.voiceOnly });
}
}
// If we switch to a different room from the call, we are no longer presenting it
const prevRoomCall = this.state.roomId ? CallStore.instance.getCall(this.state.roomId) : null;
@@ -800,4 +808,16 @@ export class RoomViewStore extends EventEmitter {
ModuleRunner.instance.invoke(RoomViewLifecycle.ViewRoom, viewRoomOpts, this.getRoomId());
this.setState({ viewRoomOpts });
}
/**
* Checks if a room is already displayed in the current active space module.
* @param roomId
*/
public isRoomDisplayedInModule(roomId: string): boolean {
const currentSpace = this.stores.spaceStore.activeSpace;
const cb = ModuleApi.instance.extras.visibleRoomBySpaceKey.get(currentSpace);
if (!cb) return false;
return cb().includes(roomId);
}
}

View File

@@ -26,7 +26,6 @@ import {
type IWidgetApiErrorResponseDataDetails,
type ISearchUserDirectoryResult,
type IGetMediaConfigResult,
type UpdateDelayedEventAction,
} from "matrix-widget-api";
import {
ClientEvent,
@@ -425,12 +424,34 @@ export class StopGapWidgetDriver extends WidgetDriver {
/**
* @experimental Part of MSC4140 & MSC4157
*/
public async updateDelayedEvent(delayId: string, action: UpdateDelayedEventAction): Promise<void> {
public async cancelScheduledDelayedEvent(delayId: string): Promise<void> {
const client = MatrixClientPeg.get();
if (!client) throw new Error("Not in a room or not attached to a client");
await client._unstable_updateDelayedEvent(delayId, action);
await client._unstable_cancelScheduledDelayedEvent(delayId);
}
/**
* @experimental Part of MSC4140 & MSC4157
*/
public async restartScheduledDelayedEvent(delayId: string): Promise<void> {
const client = MatrixClientPeg.get();
if (!client) throw new Error("Not in a room or not attached to a client");
await client._unstable_restartScheduledDelayedEvent(delayId);
}
/**
* @experimental Part of MSC4140 & MSC4157
*/
public async sendScheduledDelayedEvent(delayId: string): Promise<void> {
const client = MatrixClientPeg.get();
if (!client) throw new Error("Not in a room or not attached to a client");
await client._unstable_sendScheduledDelayedEvent(delayId);
}
/**

View File

@@ -16,10 +16,10 @@ import "@fontsource/inter/600-italic.css";
import "@fontsource/inter/700.css";
import "@fontsource/inter/700-italic.css";
import "@fontsource/inconsolata/latin-ext-400.css";
import "@fontsource/inconsolata/latin-400.css";
import "@fontsource/inconsolata/latin-ext-700.css";
import "@fontsource/inconsolata/latin-700.css";
import "@fontsource/fira-code/latin-ext-400.css";
import "@fontsource/fira-code/latin-400.css";
import "@fontsource/fira-code/latin-ext-700.css";
import "@fontsource/fira-code/latin-700.css";
import { logger } from "matrix-js-sdk/src/logger";

View File

@@ -14,6 +14,7 @@ import CheckIcon from "@vector-im/compound-design-tokens/assets/web/icons/check"
import CrossIcon from "@vector-im/compound-design-tokens/assets/web/icons/close";
import { logger } from "matrix-js-sdk/src/logger";
import { type IRTCNotificationContent } from "matrix-js-sdk/src/matrixrtc";
import { VoiceCallIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { AvatarWithDetails } from "@element-hq/web-shared-components";
import { _t } from "../languageHandler";
@@ -23,12 +24,8 @@ import defaultDispatcher from "../dispatcher/dispatcher";
import { type ViewRoomPayload } from "../dispatcher/payloads/ViewRoomPayload";
import { Action } from "../dispatcher/actions";
import ToastStore from "../stores/ToastStore";
import {
LiveContentSummary,
LiveContentSummaryWithCall,
LiveContentType,
} from "../components/views/rooms/LiveContentSummary";
import { useCall, useJoinCallButtonDisabledTooltip } from "../hooks/useCall";
import { LiveContentSummary, LiveContentType } from "../components/views/rooms/LiveContentSummary";
import { useCall, useJoinCallButtonDisabledTooltip, useParticipantCount } from "../hooks/useCall";
import AccessibleButton, { type ButtonEvent } from "../components/views/elements/AccessibleButton";
import { useDispatcher } from "../hooks/useDispatcher";
import { type ActionPayload } from "../dispatcher/payloads";
@@ -36,6 +33,7 @@ import { type Call, CallEvent } from "../models/Call";
import LegacyCallHandler, { AudioID } from "../LegacyCallHandler";
import { useEventEmitter } from "../hooks/useEventEmitter";
import { CallStore, CallStoreEvent } from "../stores/CallStore";
import DMRoomMap from "../utils/DMRoomMap";
/**
* Get the key for the incoming call toast. A combination of the event ID and room ID.
@@ -71,9 +69,15 @@ interface JoinCallButtonWithCallProps {
onClick: (e: ButtonEvent) => void;
call: Call | null;
disabledTooltip: string | undefined;
isRinging: boolean;
}
function JoinCallButtonWithCall({ onClick, call, disabledTooltip }: JoinCallButtonWithCallProps): JSX.Element {
function JoinCallButtonWithCall({
onClick,
call,
disabledTooltip,
isRinging,
}: JoinCallButtonWithCallProps): JSX.Element {
let disTooltip = disabledTooltip;
const disabledBecauseFullTooltip = useJoinCallButtonDisabledTooltip(call);
disTooltip = disabledTooltip ?? disabledBecauseFullTooltip ?? undefined;
@@ -88,7 +92,7 @@ function JoinCallButtonWithCall({ onClick, call, disabledTooltip }: JoinCallButt
Icon={CheckIcon}
size="sm"
>
{_t("action|join")}
{isRinging ? _t("action|accept") : _t("action|join")}
</Button>
</Tooltip>
);
@@ -152,7 +156,7 @@ export function IncomingCallToast({ notificationEvent }: Props): JSX.Element {
// This section can race, so we use a ref to keep track of whether we have started trying to play.
// This is because `LegacyCallHandler.play` tries to load the sound and then play it asynchonously
// and `LegacyCallHandler.isPlaying` will not be `true` until the sound starts playing.
const isRingToast = notificationContent.notification_type == "ring";
const isRingToast = notificationContent.notification_type === "ring";
if (isRingToast && !soundHasStarted.current && !LegacyCallHandler.instance.isPlaying(AudioID.Ring)) {
// Start ringing if not already.
soundHasStarted.current = true;
@@ -243,10 +247,11 @@ export function IncomingCallToast({ notificationEvent }: Props): JSX.Element {
room_id: room?.roomId,
view_call: true,
skipLobby: ("shiftKey" in e && e.shiftKey) || skipLobbyToggle,
voiceOnly: notificationContent["m.call.intent"] === "audio",
metricsTrigger: undefined,
});
},
[room, skipLobbyToggle],
[room, skipLobbyToggle, notificationContent],
);
// Dismiss on closing toast.
@@ -262,34 +267,53 @@ export function IncomingCallToast({ notificationEvent }: Props): JSX.Element {
useEventEmitter(CallStore.instance, CallStoreEvent.Call, onCall);
useEventEmitter(call ?? undefined, CallEvent.Participants, onParticipantChange);
useEventEmitter(room, RoomEvent.Timeline, onTimelineChange);
const isVoice = notificationContent["m.call.intent"] === "audio";
const otherUserId = DMRoomMap.shared().getUserIdForRoomId(roomId);
const participantCount = useParticipantCount(call);
const detailsInformation =
notificationContent.notification_type === "ring" ? (
<span>{otherUserId}</span>
) : (
<LiveContentSummary
type={isVoice ? LiveContentType.Voice : LiveContentType.Video}
text={isVoice ? _t("common|voice") : _t("common|video")}
active={false}
participantCount={participantCount}
/>
);
const callLiveContentSummary = call ? (
<LiveContentSummaryWithCall call={call} />
) : (
<LiveContentSummary
type={LiveContentType.Video}
text={_t("common|video")}
active={false}
participantCount={0}
/>
);
return (
<TooltipProvider>
<>
<div className="mx_IncomingCallToast_content">
<div className="mx_IncomingCallToast_message">
<VideoCallIcon width="20px" height="20px" style={{ position: "relative", top: "4px" }} />{" "}
{_t("voip|video_call_started")}
</div>
{isVoice ? (
<div className="mx_IncomingCallToast_message">
<VoiceCallIcon width="20px" height="20px" style={{ position: "relative", top: "4px" }} />{" "}
{_t("voip|voice_call_incoming")}
</div>
) : (
<div className="mx_IncomingCallToast_message">
<VideoCallIcon width="20px" height="20px" style={{ position: "relative", top: "4px" }} />{" "}
{notificationContent.notification_type === "ring"
? _t("voip|video_call_incoming")
: _t("voip|video_call_started")}
</div>
)}
<AvatarWithDetails
avatar={<RoomAvatar room={room ?? undefined} size="32px" />}
details={callLiveContentSummary}
details={detailsInformation}
title={room ? room.name : _t("voip|call_toast_unknown_room")}
className="mx_IncomingCallToast_AvatarWithDetails"
/>
<div className="mx_IncomingCallToast_toggleWithLabel">
<span>{_t("voip|skip_lobby_toggle_option")}</span>
<ToggleInput onChange={(e) => setSkipLobbyToggle(e.target.checked)} checked={skipLobbyToggle} />
</div>
{!isVoice && (
<div className="mx_IncomingCallToast_toggleWithLabel">
<span>{_t("voip|skip_lobby_toggle_option")}</span>
<ToggleInput
onChange={(e) => setSkipLobbyToggle(e.target.checked)}
checked={skipLobbyToggle}
/>
</div>
)}
<div className="mx_IncomingCallToast_buttons">
<DeclineCallButtonWithNotificationEvent
notificationEvent={notificationEvent}
@@ -299,6 +323,7 @@ export function IncomingCallToast({ notificationEvent }: Props): JSX.Element {
<JoinCallButtonWithCall
onClick={onJoinClick}
call={call}
isRinging={notificationContent.notification_type === "ring"}
disabledTooltip={otherCallIsOngoing ? "Ongoing call" : undefined}
/>
</div>

View File

@@ -1,4 +1,5 @@
/*
Copyright 2025 Element Creations Ltd.
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
@@ -26,6 +27,8 @@ import { Action } from "../dispatcher/actions";
import { UserTab } from "../components/views/dialogs/UserTab";
import defaultDispatcher from "../dispatcher/dispatcher";
import ConfirmKeyStorageOffDialog from "../components/views/dialogs/ConfirmKeyStorageOffDialog";
import { MatrixClientPeg } from "../MatrixClientPeg";
import { resetKeyBackupAndWait } from "../utils/crypto/resetKeyBackup";
import { PosthogAnalytics } from "../PosthogAnalytics";
const TOAST_KEY = "setupencryption";
@@ -37,7 +40,6 @@ const getTitle = (kind: Kind): string => {
case Kind.VERIFY_THIS_SESSION:
return _t("encryption|verify_toast_title");
case Kind.KEY_STORAGE_OUT_OF_SYNC:
case Kind.KEY_STORAGE_OUT_OF_SYNC_STORE:
return _t("encryption|key_storage_out_of_sync");
case Kind.TURN_ON_KEY_STORAGE:
return _t("encryption|turn_on_key_storage");
@@ -50,7 +52,6 @@ const getIcon = (kind: Kind): string | undefined => {
return undefined;
case Kind.VERIFY_THIS_SESSION:
case Kind.KEY_STORAGE_OUT_OF_SYNC:
case Kind.KEY_STORAGE_OUT_OF_SYNC_STORE:
return "verification_warning";
case Kind.TURN_ON_KEY_STORAGE:
return "key_storage";
@@ -64,7 +65,6 @@ const getSetupCaption = (kind: Kind): string => {
case Kind.VERIFY_THIS_SESSION:
return _t("action|verify");
case Kind.KEY_STORAGE_OUT_OF_SYNC:
case Kind.KEY_STORAGE_OUT_OF_SYNC_STORE:
return _t("encryption|enter_recovery_key");
case Kind.TURN_ON_KEY_STORAGE:
return _t("action|continue");
@@ -78,7 +78,6 @@ const getSetupCaption = (kind: Kind): string => {
const getPrimaryButtonIcon = (kind: Kind): ComponentType<React.SVGAttributes<SVGElement>> | undefined => {
switch (kind) {
case Kind.KEY_STORAGE_OUT_OF_SYNC:
case Kind.KEY_STORAGE_OUT_OF_SYNC_STORE:
return KeyIcon;
default:
return;
@@ -92,7 +91,6 @@ const getSecondaryButtonLabel = (kind: Kind): string => {
case Kind.VERIFY_THIS_SESSION:
return _t("encryption|verification|unverified_sessions_toast_reject");
case Kind.KEY_STORAGE_OUT_OF_SYNC:
case Kind.KEY_STORAGE_OUT_OF_SYNC_STORE:
return _t("encryption|forgot_recovery_key");
case Kind.TURN_ON_KEY_STORAGE:
return _t("action|dismiss");
@@ -106,7 +104,6 @@ const getDescription = (kind: Kind): string => {
case Kind.VERIFY_THIS_SESSION:
return _t("encryption|verify_toast_description");
case Kind.KEY_STORAGE_OUT_OF_SYNC:
case Kind.KEY_STORAGE_OUT_OF_SYNC_STORE:
return _t("encryption|key_storage_out_of_sync_description");
case Kind.TURN_ON_KEY_STORAGE:
return _t("encryption|turn_on_key_storage_description");
@@ -126,13 +123,9 @@ export enum Kind {
*/
VERIFY_THIS_SESSION = "verify_this_session",
/**
* Prompt the user to enter their recovery key, to retrieve secrets
* Prompt the user to enter their recovery key
*/
KEY_STORAGE_OUT_OF_SYNC = "key_storage_out_of_sync",
/**
* Prompt the user to enter their recovery key, to store secrets
*/
KEY_STORAGE_OUT_OF_SYNC_STORE = "key_storage_out_of_sync_store",
/**
* Prompt the user to turn on key storage
*/
@@ -174,8 +167,7 @@ export const showToast = (kind: Kind): void => {
case Kind.VERIFY_THIS_SESSION:
Modal.createDialog(SetupEncryptionDialog, {}, undefined, /* priority = */ false, /* static = */ true);
break;
case Kind.KEY_STORAGE_OUT_OF_SYNC:
case Kind.KEY_STORAGE_OUT_OF_SYNC_STORE: {
case Kind.KEY_STORAGE_OUT_OF_SYNC: {
const modal = Modal.createDialog(
Spinner,
undefined,
@@ -183,10 +175,34 @@ export const showToast = (kind: Kind): void => {
/* priority */ false,
/* static */ true,
);
const matrixClient = MatrixClientPeg.safeGet();
const crypto = matrixClient.getCrypto()!;
try {
await accessSecretStorage();
const deviceListener = DeviceListener.sharedInstance();
// we need to call keyStorageOutOfSyncNeedsBackupReset here because
// deviceListener.whilePaused() sets its client to undefined, so
// keyStorageOutOfSyncNeedsBackupReset won't be able to check
// the backup state.
const needsBackupReset = await deviceListener.keyStorageOutOfSyncNeedsBackupReset(false);
// pause the device listener because we could be making lots
// of changes, and don't want toasts to pop up and disappear
// while we're doing it
await deviceListener.whilePaused(async () => {
await accessSecretStorage(async () => {
// Reset backup if needed.
if (needsBackupReset) {
await resetKeyBackupAndWait(crypto);
} else if (await matrixClient.isKeyBackupKeyStored()) {
await crypto.loadSessionBackupPrivateKeyFromSecretStorage();
}
});
});
} catch (error) {
onAccessSecretStorageFailed(kind, error as Error);
await onAccessSecretStorageFailed(error as Error);
} finally {
modal.close();
}
@@ -209,13 +225,18 @@ export const showToast = (kind: Kind): void => {
deviceListener.dismissEncryptionSetup();
break;
}
case Kind.KEY_STORAGE_OUT_OF_SYNC:
case Kind.KEY_STORAGE_OUT_OF_SYNC_STORE: {
// Open the user settings dialog to the encryption tab and start the flow to reset encryption
case Kind.KEY_STORAGE_OUT_OF_SYNC: {
// Open the user settings dialog to the encryption tab and start the flow to reset encryption or change the recovery key
const deviceListener = DeviceListener.sharedInstance();
const needsCrossSigningReset = await deviceListener.keyStorageOutOfSyncNeedsCrossSigningReset(true);
const payload: OpenToTabPayload = {
action: Action.ViewUserSettings,
initialTabId: UserTab.Encryption,
props: { initialEncryptionState: "reset_identity_forgot" },
props: {
initialEncryptionState: needsCrossSigningReset
? "reset_identity_forgot"
: "change_recovery_key",
},
};
defaultDispatcher.dispatch(payload);
break;
@@ -250,25 +271,23 @@ export const showToast = (kind: Kind): void => {
* recovery key, but this failed. If the user just gave up, that is fine,
* but if not, that means downloading encryption info from 4S did not fix
* the problem we identified. Presumably, something is wrong with what they
* have in 4S. If we were trying to fetch secrets from 4S, we tell them to
* reset their identity, to reset everything. If we were trying to store
* secrets in 4S, or set up recovery, we tell them to change their recovery
* key, to create a new 4S that we can store the secrets in.
* have in 4S.
*/
const onAccessSecretStorageFailed = (
kind: Kind.KEY_STORAGE_OUT_OF_SYNC | Kind.KEY_STORAGE_OUT_OF_SYNC_STORE,
error: Error,
): void => {
const onAccessSecretStorageFailed = async (error: Error): Promise<void> => {
if (error instanceof AccessCancelledError) {
// The user cancelled the dialog - just allow it to close
} else {
// A real error happened - jump to the reset identity tab
// A real error happened - jump to the reset identity or change
// recovery tab
const needsCrossSigningReset =
await DeviceListener.sharedInstance().keyStorageOutOfSyncNeedsCrossSigningReset(true);
const payload: OpenToTabPayload = {
action: Action.ViewUserSettings,
initialTabId: UserTab.Encryption,
props: {
initialEncryptionState:
kind === Kind.KEY_STORAGE_OUT_OF_SYNC ? "reset_identity_sync_failed" : "change_recovery_key",
initialEncryptionState: needsCrossSigningReset
? "reset_identity_sync_failed"
: "change_recovery_key",
},
};
defaultDispatcher.dispatch(payload);

View File

@@ -0,0 +1,20 @@
/*
Copyright 2025 Element Creations 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 { type CryptoApi } from "matrix-js-sdk/src/crypto-api";
/**
* Creates a new key backup version, and wait until it is enabled.
*
* This is typically used within a {@link DeviceListener.pause()} call, to
* ensure that the device listener doesn't check the backup status until after the
* key backup is active.
*/
export async function resetKeyBackupAndWait(crypto: CryptoApi): Promise<void> {
await crypto.resetKeyBackup();
await crypto.checkKeyBackupAndEnable();
}

View File

@@ -13,14 +13,14 @@ import customCSS from "!!raw-loader!./exportCustomCSS.css";
const cssSelectorTextClassesRegex = /\.[\w-]+/g;
function mutateCssText(css: string): string {
// replace used fonts so that we don't have to bundle Inter & Inconsalata
// replace used fonts so that we don't have to bundle Inter & Fira Code
const sansFont = `-apple-system, BlinkMacSystemFont, avenir next,
avenir, segoe ui, helvetica neue, helvetica, Ubuntu, roboto, noto, arial, sans-serif`;
return css
.replace(/font-family: ?(Inter|'Inter'|"Inter")/g, `font-family: ${sansFont}`)
.replace(/--cpd-font-family-sans: ?(Inter|'Inter'|"Inter")/g, `--cpd-font-family-sans: ${sansFont}`)
.replace(
/font-family: ?Inconsolata/g,
/font-family: ?Fira Code/g,
"font-family: Menlo, Consolas, Monaco, Liberation Mono, Lucida Console, monospace",
);
}

View File

@@ -27,7 +27,8 @@ export const placeCall = async (
room: Room,
callType: CallType,
platformCallType: PlatformCallType,
skipLobby?: boolean,
skipLobby: boolean | undefined,
voiceOnly: boolean,
): Promise<void> => {
const { analyticsName } = getPlatformCallTypeProps(platformCallType);
PosthogTrackers.trackInteraction(analyticsName);
@@ -39,6 +40,7 @@ export const placeCall = async (
action: Action.ViewRoom,
room_id: room.roomId,
view_call: true,
voiceOnly,
skipLobby,
metricsTrigger: undefined,
});

View File

@@ -218,7 +218,7 @@ export default class ElectronPlatform extends BasePlatform {
this.electron.send("app_onAction", payload);
}
if (payload.action === "client_started") {
if (payload.action === Action.ClientStarted) {
this.clientStartedPromiseWithResolvers.resolve();
}
}

View File

@@ -53,7 +53,7 @@ export default class WebPlatform extends BasePlatform {
super.onAction(payload);
switch (payload.action) {
case "client_started":
case Action.ClientStarted:
// Defer drawing the toast until the client is started as the lifecycle methods reset the ToastStore right before
this.registerServiceWorkerPromise.catch(this.handleServiceWorkerRegistrationError);
break;

View File

@@ -181,6 +181,7 @@ export function setUpClientRoomAndStores(): {
const roomSession = new MockEventEmitter({
memberships: [],
getOldestMembership: jest.fn().mockReturnValue(undefined),
getConsensusCallIntent: jest.fn().mockReturnValue(undefined),
room,
}) as Mocked<MatrixRTCSession>;

View File

@@ -195,6 +195,7 @@ export function createTestClient(): MatrixClient {
content: {},
});
}),
getAccountDataFromServer: jest.fn(),
mxcUrlToHttp: jest.fn().mockImplementation((mxc: string) => `http://this.is.a.url/${mxc.substring(6)}`),
setAccountData: jest.fn(),
deleteAccountData: jest.fn(),
@@ -276,7 +277,9 @@ export function createTestClient(): MatrixClient {
_unstable_sendDelayedEvent: jest.fn(),
_unstable_sendDelayedStateEvent: jest.fn(),
_unstable_updateDelayedEvent: jest.fn(),
_unstable_cancelScheduledDelayedEvent: jest.fn(),
_unstable_restartScheduledDelayedEvent: jest.fn(),
_unstable_sendScheduledDelayedEvent: jest.fn(),
searchUserDirectory: jest.fn().mockResolvedValue({ limited: false, results: [] }),
setDeviceVerified: jest.fn(),

View File

@@ -126,6 +126,7 @@ describe("DeviceListener", () => {
getRooms: jest.fn().mockReturnValue([]),
isVersionSupported: jest.fn().mockResolvedValue(true),
isInitialSyncComplete: jest.fn().mockReturnValue(true),
isKeyBackupKeyStored: jest.fn(),
waitForClientWellKnown: jest.fn(),
getClientWellKnown: jest.fn(),
getDeviceId: jest.fn().mockReturnValue(deviceId),
@@ -275,13 +276,6 @@ describe("DeviceListener", () => {
});
describe("recheck", () => {
it("does nothing when cross signing feature is not supported", async () => {
mockClient!.isVersionSupported.mockResolvedValue(false);
await createAndStart();
expect(mockClient!.isVersionSupported).toHaveBeenCalledWith("v1.1");
expect(mockCrypto!.isCrossSigningReady).not.toHaveBeenCalled();
});
it("does nothing when crypto is not enabled", async () => {
mockClient!.getCrypto.mockReturnValue(undefined);
await createAndStart();
@@ -453,7 +447,7 @@ describe("DeviceListener", () => {
await createAndStart();
expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith(
SetupEncryptionToast.Kind.KEY_STORAGE_OUT_OF_SYNC_STORE,
SetupEncryptionToast.Kind.KEY_STORAGE_OUT_OF_SYNC,
);
});
});
@@ -1224,4 +1218,134 @@ describe("DeviceListener", () => {
});
});
});
describe("key storage out of sync", () => {
describe("needs backup reset", () => {
it("should not need resetting if backup disabled", async () => {
const deviceListener = await createAndStart();
mockClient.getAccountDataFromServer.mockResolvedValue({
disabled: true,
});
expect(await deviceListener.keyStorageOutOfSyncNeedsBackupReset(false)).toBe(false);
expect(await deviceListener.keyStorageOutOfSyncNeedsBackupReset(true)).toBe(false);
});
it("should not need resetting if backup key is present locally or in 4S, and user has 4S key", async () => {
const deviceListener = await createAndStart();
mockClient.getAccountDataFromServer.mockResolvedValue({
disabled: false,
});
mockCrypto.getSessionBackupPrivateKey.mockResolvedValue(null);
mockClient.isKeyBackupKeyStored.mockResolvedValue({});
expect(await deviceListener.keyStorageOutOfSyncNeedsBackupReset(false)).toBe(false);
mockCrypto.getSessionBackupPrivateKey.mockResolvedValue(new Uint8Array());
mockClient.isKeyBackupKeyStored.mockResolvedValue(null);
expect(await deviceListener.keyStorageOutOfSyncNeedsBackupReset(false)).toBe(false);
});
it("should not need resetting if backup key is present locally and user forgot 4S key", async () => {
const deviceListener = await createAndStart();
mockClient.getAccountDataFromServer.mockResolvedValue({
disabled: false,
});
mockCrypto.getSessionBackupPrivateKey.mockResolvedValue(new Uint8Array());
mockClient.isKeyBackupKeyStored.mockResolvedValue(null);
expect(await deviceListener.keyStorageOutOfSyncNeedsBackupReset(true)).toBe(false);
});
it("should need resetting if backup key is missing locally and user forgot 4S key", async () => {
const deviceListener = await createAndStart();
mockClient.getAccountDataFromServer.mockResolvedValue({
disabled: false,
});
mockCrypto.getSessionBackupPrivateKey.mockResolvedValue(null);
mockClient.isKeyBackupKeyStored.mockResolvedValue({});
expect(await deviceListener.keyStorageOutOfSyncNeedsBackupReset(true)).toBe(true);
});
it("should need resetting if backup key is missing locally and in 4s", async () => {
const deviceListener = await createAndStart();
mockClient.getAccountDataFromServer.mockResolvedValue({
disabled: false,
});
mockCrypto.getSessionBackupPrivateKey.mockResolvedValue(null);
mockClient.isKeyBackupKeyStored.mockResolvedValue(null);
expect(await deviceListener.keyStorageOutOfSyncNeedsBackupReset(false)).toBe(true);
});
});
describe("needs cross-signing reset", () => {
it("should not need resetting if cross-signing keys are present locally or in 4S, and user has 4S key", async () => {
const deviceListener = await createAndStart();
mockCrypto.getCrossSigningStatus.mockResolvedValue({
publicKeysOnDevice: true,
privateKeysInSecretStorage: false,
privateKeysCachedLocally: {
masterKey: true,
selfSigningKey: true,
userSigningKey: true,
},
});
expect(await deviceListener.keyStorageOutOfSyncNeedsCrossSigningReset(false)).toBe(false);
mockCrypto.getCrossSigningStatus.mockResolvedValue({
publicKeysOnDevice: true,
privateKeysInSecretStorage: true,
privateKeysCachedLocally: {
masterKey: false,
selfSigningKey: false,
userSigningKey: false,
},
});
expect(await deviceListener.keyStorageOutOfSyncNeedsCrossSigningReset(false)).toBe(false);
});
it("should not need resetting if cross-signing keys are present locally and user forgot 4S key", async () => {
const deviceListener = await createAndStart();
mockCrypto.getCrossSigningStatus.mockResolvedValue({
publicKeysOnDevice: true,
privateKeysInSecretStorage: false,
privateKeysCachedLocally: {
masterKey: true,
selfSigningKey: true,
userSigningKey: true,
},
});
expect(await deviceListener.keyStorageOutOfSyncNeedsCrossSigningReset(true)).toBe(false);
});
it("should need resetting if cross-signing keys are missing locally and user forgot 4S key", async () => {
const deviceListener = await createAndStart();
mockCrypto.getCrossSigningStatus.mockResolvedValue({
publicKeysOnDevice: true,
privateKeysInSecretStorage: true,
privateKeysCachedLocally: {
masterKey: false,
selfSigningKey: false,
userSigningKey: false,
},
});
expect(await deviceListener.keyStorageOutOfSyncNeedsCrossSigningReset(true)).toBe(true);
});
it("should need resetting if cross-signing keys are missing locally and in 4S key", async () => {
const deviceListener = await createAndStart();
mockCrypto.getCrossSigningStatus.mockResolvedValue({
publicKeysOnDevice: true,
privateKeysInSecretStorage: false,
privateKeysCachedLocally: {
masterKey: false,
selfSigningKey: false,
userSigningKey: false,
},
});
expect(await deviceListener.keyStorageOutOfSyncNeedsCrossSigningReset(false)).toBe(true);
});
});
});
});

View File

@@ -21,7 +21,7 @@ import { render } from "jest-matrix-react";
import { type ReactElement } from "react";
import { type Mocked, mocked } from "jest-mock";
import { textForEvent } from "../../src/TextForEvent";
import { hasText, textForEvent } from "../../src/TextForEvent";
import SettingsStore from "../../src/settings/SettingsStore";
import { createTestClient, stubClient } from "../test-utils";
import { MatrixClientPeg } from "../../src/MatrixClientPeg";
@@ -700,4 +700,20 @@ describe("TextForEvent", () => {
).toEqual(result);
});
});
describe("hasText", () => {
it("should return true for a known handler given an invalid event", async () => {
const cli = stubClient();
const ev = new MatrixEvent({
type: "m.room.name",
content: {
name: { foo: "bar" },
},
state_key: "",
room_id: "!roomId",
sender: cli.getUserId()!,
});
expect(hasText(ev, cli, false)).toBe(true);
});
});
});

View File

@@ -547,15 +547,15 @@ describe("<MatrixChat />", () => {
getComponent({ realQueryParams });
defaultDispatcher.dispatch({
action: "will_start_client",
action: Action.WillStartClient,
});
// client successfully started
await waitFor(() =>
expect(defaultDispatcher.dispatch).toHaveBeenCalledWith({ action: "client_started" }),
expect(defaultDispatcher.dispatch).toHaveBeenCalledWith({ action: Action.ClientStarted }),
);
// check we get to logged in view
await waitForSyncAndLoad(loginClient, true);
// set up keys screen is rendered
expect(screen.getByText("Setting up keys")).toBeInTheDocument();
});
it("should persist device language when available", async () => {
@@ -1166,14 +1166,16 @@ describe("<MatrixChat />", () => {
// Given force_verification is on (outer describe)
// And we just logged in via OIDC (inner describe)
mocked(loginClient.getCrypto()!.userHasCrossSigningKeys).mockResolvedValue(true);
// When we load the page
getComponent({ realQueryParams });
defaultDispatcher.dispatch({
action: "will_start_client",
action: Action.WillStartClient,
});
await waitFor(() =>
expect(defaultDispatcher.dispatch).toHaveBeenCalledWith({ action: "client_started" }),
expect(defaultDispatcher.dispatch).toHaveBeenCalledWith({ action: Action.ClientStarted }),
);
// Then we are not allowed in - we are being asked to verify
@@ -1340,22 +1342,8 @@ describe("<MatrixChat />", () => {
expect(screen.getByRole("heading", { name: "Welcome Ernie" })).toBeInTheDocument();
});
it("should go straight to logged in view when user does not have cross signing keys and server does not support cross signing", async () => {
loginClient.doesServerSupportUnstableFeature.mockResolvedValue(false);
await getComponentAndLogin(false);
expect(loginClient.doesServerSupportUnstableFeature).toHaveBeenCalledWith(
"org.matrix.e2e_cross_signing",
);
// logged in
await screen.findByLabelText("User menu");
});
describe("when server supports cross signing and user does not have cross signing setup", () => {
beforeEach(() => {
loginClient.doesServerSupportUnstableFeature.mockResolvedValue(true);
jest.spyOn(loginClient.getCrypto()!, "userHasCrossSigningKeys").mockResolvedValue(false);
});
@@ -1400,8 +1388,6 @@ describe("<MatrixChat />", () => {
});
it("should go to setup e2e screen", async () => {
loginClient.doesServerSupportUnstableFeature.mockResolvedValue(true);
await getComponentAndLogin();
expect(loginClient.getCrypto()!.userHasCrossSigningKeys).toHaveBeenCalled();
@@ -1424,9 +1410,7 @@ describe("<MatrixChat />", () => {
expect(screen.getByText("Confirm your identity")).toBeInTheDocument();
});
it("should setup e2e when server supports cross signing", async () => {
loginClient.doesServerSupportUnstableFeature.mockResolvedValue(true);
it("should setup e2e", async () => {
await getComponentAndLogin();
expect(loginClient.getCrypto()!.userHasCrossSigningKeys).toHaveBeenCalled();
@@ -1584,11 +1568,11 @@ describe("<MatrixChat />", () => {
it("should continue to post login setup when no session is found in local storage", async () => {
getComponent({ realQueryParams });
defaultDispatcher.dispatch({
action: "will_start_client",
action: Action.WillStartClient,
});
// logged in but waiting for sync screen
await screen.findByText("Logout");
// set up keys screen is rendered
expect(await screen.findByText("Setting up keys")).toBeInTheDocument();
});
});
});
@@ -1844,7 +1828,7 @@ describe("<MatrixChat />", () => {
getComponent({});
defaultDispatcher.dispatch({
action: "will_start_client",
action: Action.WillStartClient,
});
await flushPromises();
mockClient.emit(CryptoEvent.KeyBackupFailed, "error code");
@@ -1867,7 +1851,7 @@ describe("<MatrixChat />", () => {
getComponent({});
defaultDispatcher.dispatch({
action: "will_start_client",
action: Action.WillStartClient,
});
await flushPromises();
mockClient.emit(CryptoEvent.KeyBackupFailed, "error code");

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