diff --git a/.eslintignore b/.eslintignore index e1b0ceb50c..1cdde75cbc 100644 --- a/.eslintignore +++ b/.eslintignore @@ -7,3 +7,4 @@ test/end-to-end-tests/lib/ src/component-index.js # Auto-generated file src/modules.ts +src/modules.js diff --git a/.eslintrc.js b/.eslintrc.js index f310384972..892d7cdbb1 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -200,8 +200,13 @@ module.exports = { "@typescript-eslint/ban-ts-comment": "off", // We're okay with assertion errors when we ask for them "@typescript-eslint/no-non-null-assertion": "off", - // We do this sometimes to brand interfaces - "@typescript-eslint/no-empty-object-type": "off", + "@typescript-eslint/no-empty-object-type": [ + "error", + { + // We do this sometimes to brand interfaces + allowInterfaces: "with-single-extends", + }, + ], }, }, // temporary override for offending icon require files @@ -247,6 +252,7 @@ module.exports = { // We don't need super strict typing in test utilities "@typescript-eslint/explicit-function-return-type": "off", "@typescript-eslint/explicit-member-accessibility": "off", + "@typescript-eslint/no-empty-object-type": "off", // Jest/Playwright specific diff --git a/.github/workflows/dockerhub.yaml b/.github/workflows/dockerhub.yaml index 90a892ed8b..28ed6a6304 100644 --- a/.github/workflows/dockerhub.yaml +++ b/.github/workflows/dockerhub.yaml @@ -51,7 +51,7 @@ jobs: - name: Build and push id: build-and-push - uses: docker/build-push-action@67a2d409c0a876cbe6b11854e3e25193efe4e62d # v6 + uses: docker/build-push-action@ca877d9245402d1537745e0e356eab47c3520991 # v6 with: context: . push: true diff --git a/.gitignore b/.gitignore index 685a2cc317..3e9dc5e135 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,7 @@ electron/pub /coverage # Auto-generated file /src/modules.ts +/src/modules.js /build_config.yaml /book /index.html diff --git a/.prettierignore b/.prettierignore index 418329cf28..46b1ac5b54 100644 --- a/.prettierignore +++ b/.prettierignore @@ -17,6 +17,7 @@ electron/pub /coverage # Auto-generated file /src/modules.ts +/src/modules.js /src/i18n/strings /build_config.yaml # Raises an error because it contains a template var breaking the script tag diff --git a/.stylelintrc.js b/.stylelintrc.js index fa36402ff1..ffc6c345b9 100644 --- a/.stylelintrc.js +++ b/.stylelintrc.js @@ -33,19 +33,15 @@ module.exports = { "import-notation": null, "value-keyword-case": null, "declaration-block-no-redundant-longhand-properties": null, - "declaration-block-no-duplicate-properties": [ - true, - // useful for fallbacks - { ignore: ["consecutive-duplicates-with-different-values"] }, - ], "shorthand-property-no-redundant-values": null, "property-no-vendor-prefix": null, - "value-no-vendor-prefix": null, "selector-no-vendor-prefix": null, "media-feature-name-no-vendor-prefix": null, "number-max-precision": null, "no-invalid-double-slash-comments": true, "media-feature-range-notation": null, + "declaration-property-value-no-unknown": null, + "declaration-property-value-keyword-no-deprecated": null, "csstools/value-no-unknown-custom-properties": [ true, { diff --git a/CHANGELOG.md b/CHANGELOG.md index 5411b67428..b9346c78c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,21 @@ +Changes in [1.11.91](https://github.com/element-hq/element-web/releases/tag/v1.11.91) (2025-01-28) +================================================================================================== +## ✨ Features + +* Implement changes to memberlist from feedback ([#29029](https://github.com/element-hq/element-web/pull/29029)). Contributed by @MidhunSureshR. +* Add toast for recovery keys being out of sync ([#28946](https://github.com/element-hq/element-web/pull/28946)). Contributed by @dbkr. +* Refactor LegacyCallHandler event emitter to use TypedEventEmitter ([#29008](https://github.com/element-hq/element-web/pull/29008)). Contributed by @t3chguy. +* Add `Recovery` section in the new user settings `Encryption` tab ([#28673](https://github.com/element-hq/element-web/pull/28673)). Contributed by @florianduros. +* Retry loading chunks to make the app more resilient ([#29001](https://github.com/element-hq/element-web/pull/29001)). Contributed by @t3chguy. +* Clear account idb table on logout ([#28996](https://github.com/element-hq/element-web/pull/28996)). Contributed by @t3chguy. +* Implement new memberlist design with MVVM architecture ([#28874](https://github.com/element-hq/element-web/pull/28874)). Contributed by @MidhunSureshR. + +## 🐛 Bug Fixes + +* [Backport staging] Switch to secure random strings ([#29035](https://github.com/element-hq/element-web/pull/29035)). Contributed by @RiotRobot. +* React to MatrixEvent sender/target being updated for rendering state events ([#28947](https://github.com/element-hq/element-web/pull/28947)). Contributed by @t3chguy. + + Changes in [1.11.90](https://github.com/element-hq/element-web/releases/tag/v1.11.90) (2025-01-14) ================================================================================================== ## ✨ Features diff --git a/debian/control b/debian/control index 158c3ada17..506ae1eb33 100755 --- a/debian/control +++ b/debian/control @@ -8,6 +8,6 @@ Package: element-web Architecture: all Recommends: httpd, element-io-archive-keyring Description: - A feature-rich client for Matrix.org + Element: the future of secure communication This package contains the web-based client that can be served through a web server. diff --git a/module_system/installer.ts b/module_system/installer.ts index 4e677b7d67..48dad8d908 100644 --- a/module_system/installer.ts +++ b/module_system/installer.ts @@ -23,10 +23,9 @@ const MODULES_TS_HEADER = ` * You are not a salmon. */ -import { RuntimeModule } from "@matrix-org/react-sdk-module-api/lib/RuntimeModule"; `; const MODULES_TS_DEFINITIONS = ` -export const INSTALLED_MODULES: RuntimeModule[] = []; +export const INSTALLED_MODULES = []; `; export function installer(config: BuildConfig): void { @@ -78,8 +77,8 @@ export function installer(config: BuildConfig): void { return; // hit the finally{} block before exiting } - // If we reach here, everything seems fine. Write modules.ts and log some output - // Note: we compile modules.ts in two parts for developer friendliness if they + // If we reach here, everything seems fine. Write modules.js and log some output + // Note: we compile modules.js in two parts for developer friendliness if they // happen to look at it. console.log("The following modules have been installed: ", installedModules); let modulesTsHeader = MODULES_TS_HEADER; @@ -193,5 +192,5 @@ function isModuleVersionCompatible(ourApiVersion: string, moduleApiVersion: stri } function writeModulesTs(content: string): void { - fs.writeFileSync("./src/modules.ts", content, "utf-8"); + fs.writeFileSync("./src/modules.js", content, "utf-8"); } diff --git a/package.json b/package.json index df4e0801d3..ae11b46e99 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "element-web", - "version": "1.11.90", - "description": "A feature-rich client for Matrix.org", + "version": "1.11.91", + "description": "Element: the future of secure communication", "author": "New Vector Ltd.", "repository": { "type": "git", @@ -74,7 +74,7 @@ "@types/react-dom": "18.3.5", "oidc-client-ts": "3.1.0", "jwt-decode": "4.0.0", - "caniuse-lite": "1.0.30001692", + "caniuse-lite": "1.0.30001696", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0", "wrap-ansi": "npm:wrap-ansi@^7.0.0" }, @@ -84,7 +84,7 @@ "@fontsource/inter": "^5", "@formatjs/intl-segmenter": "^11.5.7", "@matrix-org/analytics-events": "^0.29.0", - "@matrix-org/emojibase-bindings": "^1.3.3", + "@matrix-org/emojibase-bindings": "^1.3.4", "@matrix-org/react-sdk-module-api": "^2.4.0", "@matrix-org/spec": "^1.7.0", "@sentry/browser": "^8.0.0", @@ -179,7 +179,7 @@ "@playwright/test": "^1.40.1", "@principalstudio/html-webpack-inject-preload": "^1.2.7", "@sentry/webpack-plugin": "^3.0.0", - "@stylistic/eslint-plugin": "^2.9.0", + "@stylistic/eslint-plugin": "^3.0.0", "@svgr/webpack": "^8.0.0", "@testcontainers/postgresql": "^10.16.0", "@testing-library/dom": "^10.4.0", @@ -256,7 +256,7 @@ "jsqr": "^1.4.0", "knip": "^5.36.2", "lint-staged": "^15.0.2", - "mailhog": "^4.16.0", + "mailpit-api": "^1.0.5", "matrix-web-i18n": "^3.2.1", "mini-css-extract-plugin": "2.9.2", "minimist": "^1.2.6", @@ -280,8 +280,8 @@ "semver": "^7.5.2", "source-map-loader": "^5.0.0", "strip-ansi": "^7.1.0", - "stylelint": "^16.1.0", - "stylelint-config-standard": "^36.0.0", + "stylelint": "^16.13.0", + "stylelint-config-standard": "^37.0.0", "stylelint-scss": "^6.0.0", "stylelint-value-no-unknown-custom-properties": "^6.0.1", "terser-webpack-plugin": "^5.3.9", diff --git a/playwright/e2e/crypto/backups-mas.spec.ts b/playwright/e2e/crypto/backups-mas.spec.ts index a6f4fb9390..84707eb49d 100644 --- a/playwright/e2e/crypto/backups-mas.spec.ts +++ b/playwright/e2e/crypto/backups-mas.spec.ts @@ -19,19 +19,19 @@ test.use(masHomeserver); test.describe("Encryption state after registration", () => { test.skip(isDendrite, "does not yet support MAS"); - test("Key backup is enabled by default", async ({ page, mailhogClient, app }, testInfo) => { + test("Key backup is enabled by default", async ({ page, mailpitClient, app }, testInfo) => { await page.goto("/#/login"); await page.getByRole("button", { name: "Continue" }).click(); - await registerAccountMas(page, mailhogClient, `alice_${testInfo.testId}`, "alice@email.com", "Pa$sW0rD!"); + await registerAccountMas(page, mailpitClient, `alice_${testInfo.testId}`, "alice@email.com", "Pa$sW0rD!"); await app.settings.openUserSettings("Security & Privacy"); await expect(page.getByText("This session is backing up your keys.")).toBeVisible(); }); - test("user is prompted to set up recovery", async ({ page, mailhogClient, app }, testInfo) => { + test("user is prompted to set up recovery", async ({ page, mailpitClient, app }, testInfo) => { await page.goto("/#/login"); await page.getByRole("button", { name: "Continue" }).click(); - await registerAccountMas(page, mailhogClient, `alice_${testInfo.testId}`, "alice@email.com", "Pa$sW0rD!"); + await registerAccountMas(page, mailpitClient, `alice_${testInfo.testId}`, "alice@email.com", "Pa$sW0rD!"); await page.getByRole("button", { name: "Add room" }).click(); await page.getByRole("menuitem", { name: "New room" }).click(); @@ -47,7 +47,7 @@ test.describe("Key backup reset from elsewhere", () => { test("Key backup is disabled when reset from elsewhere", async ({ page, - mailhogClient, + mailpitClient, request, homeserver, }, testInfo) => { @@ -60,7 +60,7 @@ test.describe("Key backup reset from elsewhere", () => { await page.goto("/#/login"); await page.getByRole("button", { name: "Continue" }).click(); - await registerAccountMas(page, mailhogClient, testUsername, "alice@email.com", testPassword); + await registerAccountMas(page, mailpitClient, testUsername, "alice@email.com", testPassword); await page.getByRole("button", { name: "Add room" }).click(); await page.getByRole("menuitem", { name: "New room" }).click(); diff --git a/playwright/e2e/crypto/backups.spec.ts b/playwright/e2e/crypto/backups.spec.ts index 95bf708122..8209dedcee 100644 --- a/playwright/e2e/crypto/backups.spec.ts +++ b/playwright/e2e/crypto/backups.spec.ts @@ -10,6 +10,7 @@ import { type Page } from "@playwright/test"; import { test, expect } from "../../element-web-test"; import { isDendrite } from "../../plugins/homeserver/dendrite"; +import { completeCreateSecretStorageDialog } from "./utils.ts"; async function expectBackupVersionToBe(page: Page, version: string) { await expect(page.locator(".mx_SecureBackupPanel_statusList tr:nth-child(5) td")).toHaveText( @@ -35,19 +36,7 @@ test.describe("Backups", () => { await expect(securityTab.getByRole("heading", { name: "Secure Backup" })).toBeVisible(); await securityTab.getByRole("button", { name: "Set up", exact: true }).click(); - const currentDialogLocator = page.locator(".mx_Dialog"); - - // It's the first time and secure storage is not set up, so it will create one - await expect(currentDialogLocator.getByRole("heading", { name: "Set up Secure Backup" })).toBeVisible(); - await currentDialogLocator.getByRole("button", { name: "Continue", exact: true }).click(); - await expect(currentDialogLocator.getByRole("heading", { name: "Save your Security Key" })).toBeVisible(); - await currentDialogLocator.getByRole("button", { name: "Copy", exact: true }).click(); - // copy the recovery key to use it later - const securityKey = await app.getClipboard(); - await currentDialogLocator.getByRole("button", { name: "Continue", exact: true }).click(); - - await expect(currentDialogLocator.getByRole("heading", { name: "Secure Backup successful" })).toBeVisible(); - await currentDialogLocator.getByRole("button", { name: "Done", exact: true }).click(); + const securityKey = await completeCreateSecretStorageDialog(page); // Open the settings again await app.settings.openUserSettings("Security & Privacy"); @@ -62,6 +51,7 @@ test.describe("Backups", () => { await expectBackupVersionToBe(page, "1"); await securityTab.getByRole("button", { name: "Delete Backup", exact: true }).click(); + const currentDialogLocator = page.locator(".mx_Dialog"); await expect(currentDialogLocator.getByRole("heading", { name: "Delete Backup" })).toBeVisible(); // Delete it await currentDialogLocator.getByTestId("dialog-primary-button").click(); // Click "Delete Backup" diff --git a/playwright/e2e/crypto/crypto.spec.ts b/playwright/e2e/crypto/crypto.spec.ts index f99a7a6458..babee2aeea 100644 --- a/playwright/e2e/crypto/crypto.spec.ts +++ b/playwright/e2e/crypto/crypto.spec.ts @@ -8,7 +8,14 @@ Please see LICENSE files in the repository root for full details. import type { Page } from "@playwright/test"; import { expect, test } from "../../element-web-test"; -import { autoJoin, copyAndContinue, createSharedRoomWithUser, enableKeyBackup, verify } from "./utils"; +import { + autoJoin, + completeCreateSecretStorageDialog, + copyAndContinue, + createSharedRoomWithUser, + enableKeyBackup, + verify, +} from "./utils"; import { Bot } from "../../pages/bot"; import { ElementAppPage } from "../../pages/ElementAppPage"; import { isDendrite } from "../../plugins/homeserver/dendrite"; @@ -111,18 +118,7 @@ test.describe("Cryptography", function () { await app.settings.openUserSettings("Security & Privacy"); await page.getByRole("button", { name: "Set up Secure Backup" }).click(); - const dialog = page.locator(".mx_Dialog"); - // Recovery key is selected by default - await dialog.getByRole("button", { name: "Continue" }).click(); - await copyAndContinue(page); - - // If the device is unverified, there should be a "Setting up keys" step; however, it - // can be quite quick, and playwright can miss it, so we can't test for it. - - // Either way, we end up at a success dialog: - await expect(dialog.getByText("Secure Backup successful")).toBeVisible(); - await dialog.getByRole("button", { name: "Done" }).click(); - await expect(dialog.getByText("Secure Backup successful")).not.toBeVisible(); + await completeCreateSecretStorageDialog(page); // Verify that the SSSS keys are in the account data stored in the server await verifyKey(app, "master"); diff --git a/playwright/e2e/crypto/dehydration.spec.ts b/playwright/e2e/crypto/dehydration.spec.ts index 9beb053932..b3ac82a19d 100644 --- a/playwright/e2e/crypto/dehydration.spec.ts +++ b/playwright/e2e/crypto/dehydration.spec.ts @@ -11,6 +11,8 @@ import { Locator, type Page } from "@playwright/test"; import { test, expect } from "../../element-web-test"; import { viewRoomSummaryByName } from "../right-panel/utils"; import { isDendrite } from "../../plugins/homeserver/dendrite"; +import { completeCreateSecretStorageDialog, createBot, logIntoElement } from "./utils.ts"; +import { Client } from "../../pages/client.ts"; const ROOM_NAME = "Test room"; const NAME = "Alice"; @@ -44,7 +46,7 @@ test.use({ test.describe("Dehydration", () => { test.skip(isDendrite, "does not yet support dehydration v2"); - test("Create dehydrated device", async ({ page, user, app }, workerInfo) => { + test("'Set up secure backup' creates dehydrated device", async ({ page, user, app }, workerInfo) => { // Create a backup (which will create SSSS, and dehydrated device) const securityTab = await app.settings.openUserSettings("Security & Privacy"); @@ -53,17 +55,7 @@ test.describe("Dehydration", () => { await expect(securityTab.getByText("Offline device enabled")).not.toBeVisible(); await securityTab.getByRole("button", { name: "Set up", exact: true }).click(); - const currentDialogLocator = page.locator(".mx_Dialog"); - - // It's the first time and secure storage is not set up, so it will create one - await expect(currentDialogLocator.getByRole("heading", { name: "Set up Secure Backup" })).toBeVisible(); - await currentDialogLocator.getByRole("button", { name: "Continue", exact: true }).click(); - await expect(currentDialogLocator.getByRole("heading", { name: "Save your Security Key" })).toBeVisible(); - await currentDialogLocator.getByRole("button", { name: "Copy", exact: true }).click(); - await currentDialogLocator.getByRole("button", { name: "Continue", exact: true }).click(); - - await expect(currentDialogLocator.getByRole("heading", { name: "Secure Backup successful" })).toBeVisible(); - await currentDialogLocator.getByRole("button", { name: "Done", exact: true }).click(); + await completeCreateSecretStorageDialog(page); // Open the settings again await app.settings.openUserSettings("Security & Privacy"); @@ -96,4 +88,49 @@ test.describe("Dehydration", () => { await expect(page.locator(".mx_UserInfo_devices").getByText("Offline device enabled")).toBeVisible(); await expect(page.locator(".mx_UserInfo_devices").getByText("Dehydrated device")).not.toBeVisible(); }); + + test("Reset recovery key during login re-creates dehydrated device", async ({ + page, + homeserver, + app, + credentials, + }) => { + // Set up cross-signing and recovery + const { botClient } = await createBot(page, homeserver, credentials); + // ... and dehydration + await botClient.evaluate(async (client) => await client.getCrypto().startDehydration()); + + const initialDehydratedDeviceIds = await getDehydratedDeviceIds(botClient); + expect(initialDehydratedDeviceIds.length).toBe(1); + + await botClient.evaluate(async (client) => client.stopClient()); + + // Log in our client + await logIntoElement(page, credentials); + + // Oh no, we forgot our recovery key + await page.locator(".mx_AuthPage").getByRole("button", { name: "Reset all" }).click(); + await page.locator(".mx_AuthPage").getByRole("button", { name: "Proceed with reset" }).click(); + + await completeCreateSecretStorageDialog(page, { accountPassword: credentials.password }); + + // There should be a brand new dehydrated device + const dehydratedDeviceIds = await getDehydratedDeviceIds(app.client); + expect(dehydratedDeviceIds.length).toBe(1); + expect(dehydratedDeviceIds[0]).not.toEqual(initialDehydratedDeviceIds[0]); + }); }); + +async function getDehydratedDeviceIds(client: Client): Promise { + return await client.evaluate(async (client) => { + const userId = client.getUserId(); + const devices = await client.getCrypto().getUserDeviceInfo([userId]); + return Array.from( + devices + .get(userId) + .values() + .filter((d) => d.dehydrated) + .map((d) => d.deviceId), + ); + }); +} diff --git a/playwright/e2e/crypto/toasts.spec.ts b/playwright/e2e/crypto/toasts.spec.ts new file mode 100644 index 0000000000..7763cc29c2 --- /dev/null +++ b/playwright/e2e/crypto/toasts.spec.ts @@ -0,0 +1,54 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import { GeneratedSecretStorageKey } from "matrix-js-sdk/src/crypto-api"; + +import { test, expect } from "../../element-web-test"; +import { createBot, deleteCachedSecrets, logIntoElement } from "./utils"; + +test.describe("Key storage out of sync toast", () => { + let recoveryKey: GeneratedSecretStorageKey; + + test.beforeEach(async ({ page, homeserver, credentials }) => { + const res = await createBot(page, homeserver, credentials); + recoveryKey = res.recoveryKey; + + await logIntoElement(page, credentials, recoveryKey.encodedPrivateKey); + + await deleteCachedSecrets(page); + + // We won't be prompted for crypto setup unless we have an e2e room, so make one + await page.getByRole("button", { name: "Add room" }).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(); + }); + + test("should prompt for recovery key if 'enter recovery key' pressed", { tag: "@screenshot" }, async ({ page }) => { + // Need to wait for 2 to appear since playwright only evaluates 'first()' initially, so the waiting won't work + await expect(page.getByRole("alert")).toHaveCount(2); + await expect(page.getByRole("alert").first()).toMatchScreenshot("key-storage-out-of-sync-toast.png"); + + await page.getByRole("button", { name: "Enter recovery key" }).click(); + await page.locator(".mx_Dialog").getByRole("button", { name: "use your Security Key" }).click(); + + await page.getByRole("textbox", { name: "Security key" }).fill(recoveryKey.encodedPrivateKey); + await page.getByRole("button", { name: "Continue" }).click(); + + await expect(page.getByRole("button", { name: "Enter recovery key" })).not.toBeVisible(); + }); + + test("should open settings to reset flow if 'forgot recovery key' pressed", async ({ page, app, credentials }) => { + await expect(page.getByRole("button", { name: "Enter recovery key" })).toBeVisible(); + + await page.getByRole("button", { name: "Forgot recovery key?" }).click(); + + await expect( + page.getByRole("heading", { name: "Forgot your recovery key? You’ll need to reset your identity." }), + ).toBeVisible(); + }); +}); diff --git a/playwright/e2e/crypto/utils.ts b/playwright/e2e/crypto/utils.ts index 2c8fb7d3c1..d4e276094f 100644 --- a/playwright/e2e/crypto/utils.ts +++ b/playwright/e2e/crypto/utils.ts @@ -214,6 +214,11 @@ export async function logIntoElement(page: Page, credentials: Credentials, secur // if a securityKey was given, verify the new device if (securityKey !== undefined) { await page.locator(".mx_AuthPage").getByRole("button", { name: "Verify with Security Key" }).click(); + + const useSecurityKey = page.locator(".mx_Dialog").getByRole("button", { name: "use your Security Key" }); + if (await useSecurityKey.isVisible()) { + await useSecurityKey.click(); + } // Fill in the security key await page.locator(".mx_Dialog").locator('input[type="password"]').fill(securityKey); await page.locator(".mx_Dialog_primary:not([disabled])", { hasText: "Continue" }).click(); @@ -288,19 +293,52 @@ export async function doTwoWaySasVerification(page: Page, verifier: JSHandle { await app.settings.openUserSettings("Security & Privacy"); await app.page.getByRole("button", { name: "Set up Secure Backup" }).click(); - const dialog = app.page.locator(".mx_Dialog"); - // Recovery key is selected by default - await dialog.getByRole("button", { name: "Continue" }).click({ timeout: 60000 }); - // copy the text ourselves - const securityKey = await dialog.locator(".mx_CreateSecretStorageDialog_recoveryKey code").textContent(); - await copyAndContinue(app.page); + return await completeCreateSecretStorageDialog(app.page); +} - await expect(dialog.getByText("Secure Backup successful")).toBeVisible(); - await dialog.getByRole("button", { name: "Done" }).click(); - await expect(dialog.getByText("Secure Backup successful")).not.toBeVisible(); +/** + * Go through the "Set up Secure Backup" dialog (aka the `CreateSecretStorageDialog`). + * + * Assumes the dialog is already open for some reason (see also {@link enableKeyBackup}). + * + * @param page - The playwright `Page` fixture. + * @param opts - Options object + * @param opts.accountPassword - The user's account password. If we are also resetting cross-signing, then we will need + * to upload the public cross-signing keys, which will cause the app to prompt for the password. + * + * @returns the new recovery key. + */ +export async function completeCreateSecretStorageDialog( + page: Page, + opts?: { accountPassword?: string }, +): Promise { + const currentDialogLocator = page.locator(".mx_Dialog"); - return securityKey; + await expect(currentDialogLocator.getByRole("heading", { name: "Set up Secure Backup" })).toBeVisible(); + // "Generate a Security Key" is selected by default + await currentDialogLocator.getByRole("button", { name: "Continue", exact: true }).click(); + await expect(currentDialogLocator.getByRole("heading", { name: "Save your Security Key" })).toBeVisible(); + await currentDialogLocator.getByRole("button", { name: "Copy", exact: true }).click(); + // copy the recovery key to use it later + const recoveryKey = await page.evaluate(() => navigator.clipboard.readText()); + await currentDialogLocator.getByRole("button", { name: "Continue", exact: true }).click(); + + // If the device is unverified, there should be a "Setting up keys" step. + // If this is not the first time we are setting up cross-signing, the app will prompt for our password; otherwise + // the step is quite quick, and playwright can miss it, so we can't test for it. + if (opts && Object.hasOwn(opts, "accountPassword")) { + await expect(currentDialogLocator.getByRole("heading", { name: "Setting up keys" })).toBeVisible(); + await page.getByPlaceholder("Password").fill(opts!.accountPassword); + await currentDialogLocator.getByRole("button", { name: "Continue" }).click(); + } + + // Either way, we end up at a success dialog: + await expect(currentDialogLocator.getByRole("heading", { name: "Secure Backup successful" })).toBeVisible(); + await currentDialogLocator.getByRole("button", { name: "Done", exact: true }).click(); + await expect(currentDialogLocator.getByText("Secure Backup successful")).not.toBeVisible(); + + return recoveryKey; } /** diff --git a/playwright/e2e/oidc/index.ts b/playwright/e2e/oidc/index.ts index bfd49b496a..1dcae1c21d 100644 --- a/playwright/e2e/oidc/index.ts +++ b/playwright/e2e/oidc/index.ts @@ -6,14 +6,14 @@ 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 { API, Messages } from "mailhog"; +import { MailpitClient } from "mailpit-api"; import { Page } from "@playwright/test"; import { expect } from "../../element-web-test"; export async function registerAccountMas( page: Page, - mailhog: API, + mailpit: MailpitClient, username: string, email: string, password: string, @@ -27,13 +27,13 @@ export async function registerAccountMas( await page.getByRole("textbox", { name: "Confirm Password" }).fill(password); await page.getByRole("button", { name: "Continue" }).click(); - let messages: Messages; + let code: string; await expect(async () => { - messages = await mailhog.messages(); - expect(messages.items).toHaveLength(1); + const messages = await mailpit.listMessages(); + expect(messages.messages[0].To[0].Address).toEqual(email); + const text = await mailpit.renderMessageText(messages.messages[0].ID); + [, code] = text.match(/Your verification code to confirm this email address is: (\d{6})/); }).toPass(); - expect(messages.items[0].to).toEqual(`${username} <${email}>`); - const [, code] = messages.items[0].text.match(/Your verification code to confirm this email address is: (\d{6})/); await page.getByRole("textbox", { name: "6-digit code" }).fill(code); await page.getByRole("button", { name: "Continue" }).click(); diff --git a/playwright/e2e/oidc/oidc-native.spec.ts b/playwright/e2e/oidc/oidc-native.spec.ts index 4d7fc7538d..8c9128c39b 100644 --- a/playwright/e2e/oidc/oidc-native.spec.ts +++ b/playwright/e2e/oidc/oidc-native.spec.ts @@ -19,7 +19,7 @@ test.describe("OIDC Native", { tag: ["@no-firefox", "@no-webkit"] }, () => { context, page, homeserver, - mailhogClient, + mailpitClient, mas, }, testInfo) => { await page.clock.install(); @@ -33,7 +33,7 @@ test.describe("OIDC Native", { tag: ["@no-firefox", "@no-webkit"] }, () => { await page.getByRole("button", { name: "Continue" }).click(); const userId = `alice_${testInfo.testId}`; - await registerAccountMas(page, mailhogClient, userId, "alice@email.com", "Pa$sW0rD!"); + await registerAccountMas(page, mailpitClient, userId, "alice@email.com", "Pa$sW0rD!"); // Eventually, we should end up at the home screen. await expect(page).toHaveURL(/\/#\/home$/, { timeout: 10000 }); diff --git a/playwright/e2e/read-receipts/index.ts b/playwright/e2e/read-receipts/index.ts index eab261042e..384cef1d5e 100644 --- a/playwright/e2e/read-receipts/index.ts +++ b/playwright/e2e/read-receipts/index.ts @@ -526,9 +526,10 @@ class Helpers { await expect(threadPanel).toBeVisible(); await threadPanel.evaluate(($panel) => { const $button = $panel.querySelector('[data-testid="base-card-back-button"]'); + const title = $panel.querySelector(".mx_BaseCard_header_title")?.textContent; // If the Threads back button is present then click it - the // threads button can open either threads list or thread panel - if ($button) { + if ($button && title !== "Threads") { $button.click(); } }); diff --git a/playwright/e2e/register/email.spec.ts b/playwright/e2e/register/email.spec.ts index cd990f9eaf..d351893f8b 100644 --- a/playwright/e2e/register/email.spec.ts +++ b/playwright/e2e/register/email.spec.ts @@ -34,7 +34,7 @@ test.describe("Email Registration", async () => { test( "registers an account and lands on the home page", { tag: "@screenshot" }, - async ({ page, mailhogClient, request, checkA11y }) => { + async ({ page, mailpitClient, request, checkA11y }) => { await expect(page.getByRole("textbox", { name: "Username" })).toBeVisible(); // Hide the server text as it contains the randomly allocated Homeserver port const screenshotOptions = { mask: [page.locator(".mx_ServerPicker_server")] }; @@ -51,10 +51,11 @@ test.describe("Email Registration", async () => { await expect(page.getByText("An error was encountered when sending the email")).not.toBeVisible(); - const messages = await mailhogClient.messages(); - expect(messages.items).toHaveLength(1); - expect(messages.items[0].to).toEqual("alice@email.com"); - const [emailLink] = messages.items[0].text.match(/http.+/); + const messages = await mailpitClient.listMessages(); + expect(messages.messages).toHaveLength(1); + expect(messages.messages[0].To[0].Address).toEqual("alice@email.com"); + const text = await mailpitClient.renderMessageText(messages.messages[0].ID); + const [emailLink] = text.match(/http.+/); await request.get(emailLink); // "Click" the link in the email await expect(page.getByText("Welcome alice")).toBeVisible(); diff --git a/playwright/e2e/settings/encryption-user-tab/encryption-tab.spec.ts b/playwright/e2e/settings/encryption-user-tab/encryption-tab.spec.ts new file mode 100644 index 0000000000..79ee3fc7a5 --- /dev/null +++ b/playwright/e2e/settings/encryption-user-tab/encryption-tab.spec.ts @@ -0,0 +1,96 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import { GeneratedSecretStorageKey } from "matrix-js-sdk/src/crypto-api"; + +import { test, expect } from "."; +import { + checkDeviceIsConnectedKeyBackup, + checkDeviceIsCrossSigned, + createBot, + deleteCachedSecrets, + verifySession, +} from "../../crypto/utils"; + +test.describe("Encryption tab", () => { + test.use({ + displayName: "Alice", + }); + + let recoveryKey: GeneratedSecretStorageKey; + let expectedBackupVersion: string; + + test.beforeEach(async ({ page, homeserver, credentials }) => { + // The bot bootstraps cross-signing, creates a key backup and sets up a recovery key + const res = await createBot(page, homeserver, credentials); + recoveryKey = res.recoveryKey; + expectedBackupVersion = res.expectedBackupVersion; + }); + + test( + "should show a 'Verify this device' button if the device is unverified", + { tag: "@screenshot" }, + async ({ page, app, util }) => { + const dialog = await util.openEncryptionTab(); + const content = util.getEncryptionTabContent(); + + // The user's device is in an unverified state, therefore the only option available to them here is to verify it + const verifyButton = dialog.getByRole("button", { name: "Verify this device" }); + await expect(verifyButton).toBeVisible(); + await expect(content).toMatchScreenshot("verify-device-encryption-tab.png"); + await verifyButton.click(); + + await util.verifyDevice(recoveryKey); + + await expect(content).toMatchScreenshot("default-tab.png", { + mask: [content.getByTestId("deviceId"), content.getByTestId("sessionKey")], + }); + + // Check that our device is now cross-signed + await checkDeviceIsCrossSigned(app); + + // Check that the current device is connected to key backup + // The backup decryption key should be in cache also, as we got it directly from the 4S + await checkDeviceIsConnectedKeyBackup(app, expectedBackupVersion, true); + }, + ); + + // Test what happens if the cross-signing secrets are in secret storage but are not cached in the local DB. + // + // This can happen if we verified another device and secret-gossiping failed, or the other device itself lacked the secrets. + // We simulate this case by deleting the cached secrets in the indexedDB. + test( + "should prompt to enter the recovery key when the secrets are not cached locally", + { tag: "@screenshot" }, + async ({ page, app, util }) => { + await verifySession(app, "new passphrase"); + // We need to delete the cached secrets + await deleteCachedSecrets(page); + + await util.openEncryptionTab(); + // We ask the user to enter the recovery key + const dialog = util.getEncryptionTabContent(); + const enterKeyButton = dialog.getByRole("button", { name: "Enter recovery key" }); + await expect(enterKeyButton).toBeVisible(); + await expect(dialog).toMatchScreenshot("out-of-sync-recovery.png"); + await enterKeyButton.click(); + + // Fill the recovery key + await util.enterRecoveryKey(recoveryKey); + await expect(dialog).toMatchScreenshot("default-tab.png", { + mask: [dialog.getByTestId("deviceId"), dialog.getByTestId("sessionKey")], + }); + + // Check that our device is now cross-signed + await checkDeviceIsCrossSigned(app); + + // Check that the current device is connected to key backup + // The backup decryption key should be in cache also, as we got it directly from the 4S + await checkDeviceIsConnectedKeyBackup(app, expectedBackupVersion, true); + }, + ); +}); diff --git a/playwright/e2e/settings/encryption-user-tab/recovery.spec.ts b/playwright/e2e/settings/encryption-user-tab/recovery.spec.ts index 316f305c97..8bb16f018b 100644 --- a/playwright/e2e/settings/encryption-user-tab/recovery.spec.ts +++ b/playwright/e2e/settings/encryption-user-tab/recovery.spec.ts @@ -5,53 +5,17 @@ * Please see LICENSE files in the repository root for full details. */ -import { GeneratedSecretStorageKey } from "matrix-js-sdk/src/crypto-api"; - import { test, expect } from "."; -import { - checkDeviceIsConnectedKeyBackup, - checkDeviceIsCrossSigned, - createBot, - deleteCachedSecrets, - verifySession, -} from "../../crypto/utils"; +import { checkDeviceIsConnectedKeyBackup, createBot, verifySession } from "../../crypto/utils"; test.describe("Recovery section in Encryption tab", () => { test.use({ displayName: "Alice", }); - let recoveryKey: GeneratedSecretStorageKey; - let expectedBackupVersion: string; - test.beforeEach(async ({ page, homeserver, credentials }) => { - const res = await createBot(page, homeserver, credentials); - recoveryKey = res.recoveryKey; - expectedBackupVersion = res.expectedBackupVersion; - }); - - test("should verify the device", { tag: "@screenshot" }, async ({ page, app, util }) => { - const dialog = await util.openEncryptionTab(); - const content = util.getEncryptionTabContent(); - - // The user's device is in an unverified state, therefore the only option available to them here is to verify it - const verifyButton = dialog.getByRole("button", { name: "Verify this device" }); - await expect(verifyButton).toBeVisible(); - await expect(content).toMatchScreenshot("verify-device-encryption-tab.png"); - await verifyButton.click(); - - await util.verifyDevice(recoveryKey); - - await expect(content).toMatchScreenshot("default-tab.png", { - mask: [content.getByTestId("deviceId"), content.getByTestId("sessionKey")], - }); - - // Check that our device is now cross-signed - await checkDeviceIsCrossSigned(app); - - // Check that the current device is connected to key backup - // The backup decryption key should be in cache also, as we got it directly from the 4S - await checkDeviceIsConnectedKeyBackup(app, expectedBackupVersion, true); + // The bot bootstraps cross-signing, creates a key backup and sets up a recovery key + await createBot(page, homeserver, credentials); }); test( @@ -121,37 +85,4 @@ test.describe("Recovery section in Encryption tab", () => { // Check that the current device is connected to key backup and the backup version is the expected one await checkDeviceIsConnectedKeyBackup(app, "1", true); }); - - // Test what happens if the cross-signing secrets are in secret storage but are not cached in the local DB. - // - // This can happen if we verified another device and secret-gossiping failed, or the other device itself lacked the secrets. - // We simulate this case by deleting the cached secrets in the indexedDB. - test( - "should enter the recovery key when the secrets are not cached", - { tag: "@screenshot" }, - async ({ page, app, util }) => { - await verifySession(app, "new passphrase"); - // We need to delete the cached secrets - await deleteCachedSecrets(page); - - await util.openEncryptionTab(); - // We ask the user to enter the recovery key - const dialog = util.getEncryptionTabContent(); - const enterKeyButton = dialog.getByRole("button", { name: "Enter recovery key" }); - await expect(enterKeyButton).toBeVisible(); - await expect(util.getEncryptionRecoverySection()).toMatchScreenshot("out-of-sync-recovery.png"); - await enterKeyButton.click(); - - // Fill the recovery key - await util.enterRecoveryKey(recoveryKey); - await expect(util.getEncryptionRecoverySection()).toMatchScreenshot("default-recovery.png"); - - // Check that our device is now cross-signed - await checkDeviceIsCrossSigned(app); - - // Check that the current device is connected to key backup - // The backup decryption key should be in cache also, as we got it directly from the 4S - await checkDeviceIsConnectedKeyBackup(app, expectedBackupVersion, true); - }, - ); }); diff --git a/playwright/pages/bot.ts b/playwright/pages/bot.ts index 435e4a1cbb..28546bf546 100644 --- a/playwright/pages/bot.ts +++ b/playwright/pages/bot.ts @@ -192,7 +192,6 @@ export class Bot extends Client { await clientHandle.evaluate(async (cli) => { await cli.initRustCrypto({ useIndexedDB: false }); - cli.setGlobalErrorOnUnknownDevices(false); await cli.startClient(); }); diff --git a/playwright/pages/client.ts b/playwright/pages/client.ts index 611a6cef19..d1ce3709d3 100644 --- a/playwright/pages/client.ts +++ b/playwright/pages/client.ts @@ -25,6 +25,7 @@ import type { StateEvents, TimelineEvents, AccountDataEvents, + EmptyObject, } from "matrix-js-sdk/src/matrix"; import type { RoomMessageEventContent } from "matrix-js-sdk/src/types"; import { Credentials } from "../plugins/homeserver"; @@ -363,7 +364,7 @@ export class Client { event: JSHandle, receiptType?: ReceiptType, unthreaded?: boolean, - ): Promise<{}> { + ): Promise { const client = await this.prepareClient(); return client.evaluate( (client, { event, receiptType, unthreaded }) => { @@ -386,7 +387,7 @@ export class Client { * @return {Promise} Resolves: {} an empty object. * @return {module:http-api.MatrixError} Rejects: with an error response. */ - public async setDisplayName(name: string): Promise<{}> { + public async setDisplayName(name: string): Promise { const client = await this.prepareClient(); return client.evaluate(async (cli: MatrixClient, name) => cli.setDisplayName(name), name); } @@ -397,7 +398,7 @@ export class Client { * @return {Promise} Resolves: {} an empty object. * @return {module:http-api.MatrixError} Rejects: with an error response. */ - public async setAvatarUrl(url: string): Promise<{}> { + public async setAvatarUrl(url: string): Promise { const client = await this.prepareClient(); return client.evaluate(async (cli: MatrixClient, url) => cli.setAvatarUrl(url), url); } diff --git a/playwright/plugins/homeserver/synapse/consentHomeserver.ts b/playwright/plugins/homeserver/synapse/consentHomeserver.ts index e714e8a9c1..7c57e16d22 100644 --- a/playwright/plugins/homeserver/synapse/consentHomeserver.ts +++ b/playwright/plugins/homeserver/synapse/consentHomeserver.ts @@ -10,7 +10,7 @@ import { Fixtures } from "../../../element-web-test.ts"; export const consentHomeserver: Fixtures = { _homeserver: [ - async ({ _homeserver: container, mailhog }, use) => { + async ({ _homeserver: container, mailpit }, use) => { container .withCopyDirectoriesToContainer([ { source: "playwright/plugins/homeserver/synapse/res", target: "/data/res" }, @@ -18,7 +18,7 @@ export const consentHomeserver: Fixtures = { .withConfig({ email: { enable_notifs: false, - smtp_host: "mailhog", + smtp_host: "mailpit", smtp_port: 1025, smtp_user: "username", smtp_pass: "password", diff --git a/playwright/plugins/homeserver/synapse/emailHomeserver.ts b/playwright/plugins/homeserver/synapse/emailHomeserver.ts index f7dee7b01a..b6602b977b 100644 --- a/playwright/plugins/homeserver/synapse/emailHomeserver.ts +++ b/playwright/plugins/homeserver/synapse/emailHomeserver.ts @@ -10,13 +10,13 @@ import { Fixtures } from "../../../element-web-test.ts"; export const emailHomeserver: Fixtures = { _homeserver: [ - async ({ _homeserver: container, mailhog }, use) => { + async ({ _homeserver: container, mailpit }, use) => { container.withConfig({ enable_registration_without_verification: undefined, disable_msisdn_registration: undefined, registrations_require_3pid: ["email"], email: { - smtp_host: "mailhog", + smtp_host: "mailpit", smtp_port: 1025, notif_from: "Your Friendly %(app)s homeserver ", app_name: "my_branded_matrix_server", diff --git a/playwright/plugins/homeserver/synapse/masHomeserver.ts b/playwright/plugins/homeserver/synapse/masHomeserver.ts index d52c446e9b..0cb70835c7 100644 --- a/playwright/plugins/homeserver/synapse/masHomeserver.ts +++ b/playwright/plugins/homeserver/synapse/masHomeserver.ts @@ -11,7 +11,7 @@ import { Fixtures } from "../../../element-web-test.ts"; export const masHomeserver: Fixtures = { mas: [ - async ({ _homeserver: homeserver, logger, network, postgres, mailhog }, use) => { + async ({ _homeserver: homeserver, logger, network, postgres, mailpit }, use) => { const config = { clients: [ { diff --git a/playwright/services.ts b/playwright/services.ts index a501bf6138..1b7514662a 100644 --- a/playwright/services.ts +++ b/playwright/services.ts @@ -6,7 +6,7 @@ Please see LICENSE files in the repository root for full details. */ import { test as base } from "@playwright/test"; -import mailhog from "mailhog"; +import { MailpitClient } from "mailpit-api"; import { Network, StartedNetwork } from "testcontainers"; import { PostgreSqlContainer, StartedPostgreSqlContainer } from "@testcontainers/postgresql"; @@ -14,13 +14,13 @@ import { SynapseConfig, SynapseContainer } from "./testcontainers/synapse.ts"; import { Logger } from "./logger.ts"; import { StartedMatrixAuthenticationServiceContainer } from "./testcontainers/mas.ts"; import { HomeserverContainer, StartedHomeserverContainer } from "./testcontainers/HomeserverContainer.ts"; -import { MailhogContainer, StartedMailhogContainer } from "./testcontainers/mailhog.ts"; +import { MailhogContainer, StartedMailhogContainer } from "./testcontainers/mailpit.ts"; import { OAuthServer } from "./plugins/oauth_server"; import { DendriteContainer, PineconeContainer } from "./testcontainers/dendrite.ts"; import { HomeserverType } from "./plugins/homeserver"; export interface TestFixtures { - mailhogClient: mailhog.API; + mailpitClient: MailpitClient; } export interface Services { @@ -28,7 +28,7 @@ export interface Services { network: StartedNetwork; postgres: StartedPostgreSqlContainer; - mailhog: StartedMailhogContainer; + mailpit: StartedMailhogContainer; synapseConfig: SynapseConfig; _homeserver: HomeserverContainer; @@ -90,20 +90,20 @@ export const test = base.extend({ { scope: "worker" }, ], - mailhog: [ + mailpit: [ async ({ logger, network }, use) => { const container = await new MailhogContainer() .withNetwork(network) - .withNetworkAliases("mailhog") - .withLogConsumer(logger.getConsumer("mailhog")) + .withNetworkAliases("mailpit") + .withLogConsumer(logger.getConsumer("mailpit")) .start(); await use(container); await container.stop(); }, { scope: "worker" }, ], - mailhogClient: async ({ mailhog: container }, use) => { - await container.client.deleteAll(); + mailpitClient: async ({ mailpit: container }, use) => { + await container.client.deleteMessages(); await use(container.client); }, diff --git a/playwright/snapshots/crypto/toasts.spec.ts/key-storage-out-of-sync-toast-linux.png b/playwright/snapshots/crypto/toasts.spec.ts/key-storage-out-of-sync-toast-linux.png new file mode 100644 index 0000000000..8e335bd232 Binary files /dev/null and b/playwright/snapshots/crypto/toasts.spec.ts/key-storage-out-of-sync-toast-linux.png differ diff --git a/playwright/snapshots/right-panel/memberlist.spec.ts/with-four-members-linux.png b/playwright/snapshots/right-panel/memberlist.spec.ts/with-four-members-linux.png index 460eec3a8c..d8bab27faa 100644 Binary files a/playwright/snapshots/right-panel/memberlist.spec.ts/with-four-members-linux.png and b/playwright/snapshots/right-panel/memberlist.spec.ts/with-four-members-linux.png differ diff --git a/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/default-tab-linux.png b/playwright/snapshots/settings/encryption-user-tab/encryption-tab.spec.ts/default-tab-linux.png similarity index 100% rename from playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/default-tab-linux.png rename to playwright/snapshots/settings/encryption-user-tab/encryption-tab.spec.ts/default-tab-linux.png diff --git a/playwright/snapshots/settings/encryption-user-tab/encryption-tab.spec.ts/out-of-sync-recovery-linux.png b/playwright/snapshots/settings/encryption-user-tab/encryption-tab.spec.ts/out-of-sync-recovery-linux.png new file mode 100644 index 0000000000..e6664a5f79 Binary files /dev/null and b/playwright/snapshots/settings/encryption-user-tab/encryption-tab.spec.ts/out-of-sync-recovery-linux.png differ diff --git a/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/verify-device-encryption-tab-linux.png b/playwright/snapshots/settings/encryption-user-tab/encryption-tab.spec.ts/verify-device-encryption-tab-linux.png similarity index 100% rename from playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/verify-device-encryption-tab-linux.png rename to playwright/snapshots/settings/encryption-user-tab/encryption-tab.spec.ts/verify-device-encryption-tab-linux.png diff --git a/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/out-of-sync-recovery-linux.png b/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/out-of-sync-recovery-linux.png deleted file mode 100644 index 9e1aa6c34e..0000000000 Binary files a/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/out-of-sync-recovery-linux.png and /dev/null differ diff --git a/playwright/snapshots/user-view/user-view.spec.ts/user-info-linux.png b/playwright/snapshots/user-view/user-view.spec.ts/user-info-linux.png index 30206f1a25..4e30587935 100644 Binary files a/playwright/snapshots/user-view/user-view.spec.ts/user-info-linux.png and b/playwright/snapshots/user-view/user-view.spec.ts/user-info-linux.png differ diff --git a/playwright/testcontainers/mailhog.ts b/playwright/testcontainers/mailpit.ts similarity index 66% rename from playwright/testcontainers/mailhog.ts rename to playwright/testcontainers/mailpit.ts index c3305607d8..c4c025c05c 100644 --- a/playwright/testcontainers/mailhog.ts +++ b/playwright/testcontainers/mailpit.ts @@ -6,13 +6,16 @@ Please see LICENSE files in the repository root for full details. */ import { AbstractStartedContainer, GenericContainer, StartedTestContainer, Wait } from "testcontainers"; -import mailhog from "mailhog"; +import { MailpitClient } from "mailpit-api"; export class MailhogContainer extends GenericContainer { constructor() { - super("mailhog/mailhog:latest"); + super("axllent/mailpit:latest"); - this.withExposedPorts(8025).withWaitStrategy(Wait.forListeningPorts()); + this.withExposedPorts(8025).withWaitStrategy(Wait.forListeningPorts()).withEnvironment({ + MP_SMTP_AUTH_ALLOW_INSECURE: "true", + MP_SMTP_AUTH_ACCEPT_ANY: "true", + }); } public override async start(): Promise { @@ -21,10 +24,10 @@ export class MailhogContainer extends GenericContainer { } export class StartedMailhogContainer extends AbstractStartedContainer { - public readonly client: mailhog.API; + public readonly client: MailpitClient; constructor(container: StartedTestContainer) { super(container); - this.client = mailhog({ host: container.getHost(), port: container.getMappedPort(8025) }); + this.client = new MailpitClient(`http://${container.getHost()}:${container.getMappedPort(8025)}`); } } diff --git a/playwright/testcontainers/mas.ts b/playwright/testcontainers/mas.ts index 9b05b521ba..9162bc96e3 100644 --- a/playwright/testcontainers/mas.ts +++ b/playwright/testcontainers/mas.ts @@ -92,7 +92,7 @@ const DEFAULT_CONFIG = { reply_to: '"Authentication Service" ', transport: "smtp", mode: "plain", - hostname: "mailhog", + hostname: "mailpit", port: 1025, username: "username", password: "password", diff --git a/playwright/testcontainers/synapse.ts b/playwright/testcontainers/synapse.ts index f83f2a1e75..f4ee2d8e08 100644 --- a/playwright/testcontainers/synapse.ts +++ b/playwright/testcontainers/synapse.ts @@ -19,7 +19,7 @@ import { HomeserverContainer, StartedHomeserverContainer } from "./HomeserverCon import { StartedMatrixAuthenticationServiceContainer } from "./mas.ts"; import { Api, ClientServerApi, Verb } from "../plugins/utils/api.ts"; -const TAG = "develop@sha256:2f4dbd0b748e9178ea761f5586d0a1ade88f283a0481ba5dd2c42bc19d45b2a4"; +const TAG = "develop@sha256:06b88d1ca4985c50db14aa5cf4ec83d19dc1ad30ad33b79233493ccd169a477f"; const DEFAULT_CONFIG = { server_name: "localhost", diff --git a/res/css/views/right_panel/_UserInfo.pcss b/res/css/views/right_panel/_UserInfo.pcss index 0af5585d1a..7a67986ae8 100644 --- a/res/css/views/right_panel/_UserInfo.pcss +++ b/res/css/views/right_panel/_UserInfo.pcss @@ -34,7 +34,7 @@ Please see LICENSE files in the repository root for full details. } .mx_UserInfo_container { - padding: var(--cpd-space-4x) 0; + padding: var(--cpd-space-2x) 0 var(--cpd-space-4x); margin: 0 var(--cpd-space-4x); .mx_UserInfo_container_verifyButton { @@ -65,7 +65,7 @@ Please see LICENSE files in the repository root for full details. } .mx_UserInfo_avatar { - margin: $spacing-24 $spacing-32 0 $spacing-32; + margin: var(--cpd-space-12x) var(--cpd-space-4x) 0 var(--cpd-space-4x); .mx_UserInfo_avatar_transition { max-width: 120px; @@ -98,8 +98,18 @@ Please see LICENSE files in the repository root for full details. margin: 5px 0; } + .mx_UserInfo_header { + margin-bottom: var(--cpd-space-8x); + padding-bottom: 0; + } + .mx_UserInfo_profile { + display: flex; + flex-direction: column; + gap: var(--cpd-space-1x); + h1 { + margin: 0; font-size: $font-20px; line-height: $font-25px; @@ -119,8 +129,45 @@ Please see LICENSE files in the repository root for full details. } } + .mx_UserInfo_profile_name { + height: 30px; + } + + .mx_UserInfo_profile_mxid { + color: var(--cpd-color-text-secondary); + height: 28px; + } + .mx_UserInfo_profileStatus { - margin: var(--cpd-space-1x) 0; + height: 20px; + } + + .mx_UserInfo_timezone { + height: 20px; + margin: 0; + display: flex; + align-items: center; + } + + /** Overrides for the copy to clipboard button **/ + .mx_CopyableText { + align-items: center; + } + + .mx_CopyableText_copyButton { + width: 28px; + height: 28px; + display: flex; + justify-content: center; + align-items: center; + position: unset; + padding-left: var(--cpd-space-2x); + } + + .mx_CopyableText_copyButton::before { + width: 20px; + height: 20px; + background-color: var(--cpd-color-icon-secondary-alpha); } } diff --git a/res/css/views/rooms/_RoomHeader.pcss b/res/css/views/rooms/_RoomHeader.pcss index 32eb055f07..ac2e419e0d 100644 --- a/res/css/views/rooms/_RoomHeader.pcss +++ b/res/css/views/rooms/_RoomHeader.pcss @@ -71,6 +71,7 @@ Please see LICENSE files in the repository root for full details. padding: var(--cpd-space-1-5x); cursor: pointer; user-select: none; + font: var(--cpd-font-body-sm-medium); /* RoomAvatar doesn't pass classes down to avatar So set style here @@ -83,6 +84,12 @@ Please see LICENSE files in the repository root for full details. color: $primary-content; background: var(--cpd-color-bg-subtle-primary); } + + &.mx_FacePile_toggled { + background: var(--cpd-color-bg-success-subtle); + color: var(--cpd-color-text-action-accent); + font: var(--cpd-font-body-sm-semibold); + } } .mx_RoomHeader .mx_BaseAvatar { @@ -93,3 +100,7 @@ Please see LICENSE files in the repository root for full details. /* Workaround for https://github.com/element-hq/compound/issues/331 */ min-width: 240px; } + +.mx_RoomHeader .mx_RoomHeader_toggled { + color: var(--cpd-color-icon-accent-primary); +} diff --git a/res/css/views/rooms/_UserIdentityWarning.pcss b/res/css/views/rooms/_UserIdentityWarning.pcss index e5d14eb472..cf87e47a24 100644 --- a/res/css/views/rooms/_UserIdentityWarning.pcss +++ b/res/css/views/rooms/_UserIdentityWarning.pcss @@ -20,8 +20,14 @@ Please see LICENSE files in the repository root for full details. margin-left: var(--cpd-space-6x); flex-grow: 1; } + .mx_UserIdentityWarning_main.critical { + color: var(--cpd-color-text-critical-primary); + } } } +.mx_UserIdentityWarning.critical { + background: linear-gradient(180deg, var(--cpd-color-red-100) 0%, var(--cpd-color-theme-bg) 100%); +} .mx_MessageComposer.mx_MessageComposer--compact > .mx_UserIdentityWarning { margin-left: calc(-25px + var(--RoomView_MessageList-padding)); diff --git a/res/fonts/Twemoji_Mozilla/TwemojiMozilla-colr.woff2 b/res/fonts/Twemoji_Mozilla/TwemojiMozilla-colr.woff2 index c9ecd7a0da..5bfc425d66 100644 Binary files a/res/fonts/Twemoji_Mozilla/TwemojiMozilla-colr.woff2 and b/res/fonts/Twemoji_Mozilla/TwemojiMozilla-colr.woff2 differ diff --git a/src/@types/diff-dom.d.ts b/src/@types/diff-dom.d.ts index 986a84dc0b..12587446d0 100644 --- a/src/@types/diff-dom.d.ts +++ b/src/@types/diff-dom.d.ts @@ -18,6 +18,7 @@ declare module "diff-dom" { newValue: HTMLElement | string; } + // eslint-disable-next-line @typescript-eslint/no-empty-object-type interface IOpts {} export class DiffDOM { diff --git a/src/@types/matrix-js-sdk.d.ts b/src/@types/matrix-js-sdk.d.ts index 6ffa09dd4f..92b76c4c4d 100644 --- a/src/@types/matrix-js-sdk.d.ts +++ b/src/@types/matrix-js-sdk.d.ts @@ -11,6 +11,7 @@ import type { BLURHASH_FIELD } from "../utils/image-media"; import type { JitsiCallMemberEventType, JitsiCallMemberContent } from "../call-types"; import type { ILayoutStateEvent, WIDGET_LAYOUT_EVENT_TYPE } from "../stores/widgets/types"; import type { EncryptedFile } from "matrix-js-sdk/src/types"; +import type { EmptyObject } from "matrix-js-sdk/src/matrix"; import type { DeviceClientInformation } from "../utils/device/types.ts"; import type { UserWidget } from "../utils/WidgetUtils-types.ts"; @@ -35,7 +36,7 @@ declare module "matrix-js-sdk/src/types" { [JitsiCallMemberEventType]: JitsiCallMemberContent; // Unstable widgets state events - "im.vector.modular.widgets": IWidget | {}; + "im.vector.modular.widgets": IWidget | EmptyObject; [WIDGET_LAYOUT_EVENT_TYPE]: ILayoutStateEvent; // Element custom state events @@ -104,6 +105,6 @@ declare module "matrix-js-sdk/src/types" { // https://github.com/matrix-org/matrix-doc/pull/3246 waveform?: number[]; }; - "org.matrix.msc3245.voice"?: {}; + "org.matrix.msc3245.voice"?: EmptyObject; } } diff --git a/src/@types/react.d.ts b/src/@types/react.d.ts index 2573bc0ab9..b1f6352adb 100644 --- a/src/@types/react.d.ts +++ b/src/@types/react.d.ts @@ -10,7 +10,7 @@ import React, { PropsWithChildren } from "react"; declare module "react" { // Fix forwardRef types for Generic components - https://stackoverflow.com/a/58473012 - function forwardRef( + function forwardRef( render: (props: PropsWithChildren

, ref: React.ForwardedRef) => React.ReactElement | null, ): (props: P & React.RefAttributes) => React.ReactElement | null; diff --git a/src/AddThreepid.ts b/src/AddThreepid.ts index 757ea18180..10c28bfc4a 100644 --- a/src/AddThreepid.ts +++ b/src/AddThreepid.ts @@ -249,6 +249,7 @@ export default class AddThreepid { * @param {{type: string, session?: string}} auth UI auth object * @return {Promise} Response from /3pid/add call (in current spec, an empty object) */ + // eslint-disable-next-line @typescript-eslint/no-empty-object-type private makeAddThreepidOnlyRequest = (auth?: IAddThreePidOnlyBody["auth"] | null): Promise<{}> => { return this.matrixClient.addThreePidOnly({ sid: this.sessionId!, diff --git a/src/MatrixClientPeg.ts b/src/MatrixClientPeg.ts index ab952c5633..e14bfdcf30 100644 --- a/src/MatrixClientPeg.ts +++ b/src/MatrixClientPeg.ts @@ -41,6 +41,7 @@ import PlatformPeg from "./PlatformPeg"; import { formatList } from "./utils/FormattingUtils"; import SdkConfig from "./SdkConfig"; import { setDeviceIsolationMode } from "./settings/controllers/DeviceIsolationModeController.ts"; +import { initialiseDehydration } from "./utils/device/dehydration"; export interface IMatrixClientCreds { homeserverUrl: string; @@ -340,7 +341,20 @@ class MatrixClientPegClass implements IMatrixClientPeg { setDeviceIsolationMode(this.matrixClient, SettingsStore.getValue("feature_exclude_insecure_devices")); - // TODO: device dehydration and whathaveyou + // Start dehydration. This code is only for the case where the client + // gets restarted, so we only do this if we already have the dehydration + // key cached, and we don't have to try to rehydrate a device. If this + // is a new login, we will start dehydration after Secret Storage is + // unlocked. + try { + await initialiseDehydration({ onlyIfKeyCached: true, rehydrate: false }, this.matrixClient); + } catch (e) { + // We may get an error dehydrating, such as if cross-signing and + // SSSS are not set up yet. Just log the error and continue. + // If SSSS gets set up later, we will re-try dehydration. + console.log("Error starting device dehydration", e); + } + return; } diff --git a/src/SecurityManager.ts b/src/SecurityManager.ts index 6af8dd5f18..370d0e9453 100644 --- a/src/SecurityManager.ts +++ b/src/SecurityManager.ts @@ -9,7 +9,7 @@ Please see LICENSE files in the repository root for full details. import { lazy } from "react"; import { SecretStorage } from "matrix-js-sdk/src/matrix"; import { deriveRecoveryKeyFromPassphrase, decodeRecoveryKey, CryptoCallbacks } from "matrix-js-sdk/src/crypto-api"; -import { logger } from "matrix-js-sdk/src/logger"; +import { logger as rootLogger } from "matrix-js-sdk/src/logger"; import Modal from "./Modal"; import { MatrixClientPeg } from "./MatrixClientPeg"; @@ -29,6 +29,8 @@ let secretStorageKeys: Record = {}; let secretStorageKeyInfo: Record = {}; let secretStorageBeingAccessed = false; +const logger = rootLogger.getChild("SecurityManager:"); + /** * This can be used by other components to check if secret storage access is in * progress, so that we can e.g. avoid intermittently showing toasts during @@ -70,33 +72,34 @@ function makeInputToKey( }; } -async function getSecretStorageKey({ - keys: keyInfos, -}: { - keys: Record; -}): Promise<[string, Uint8Array]> { +async function getSecretStorageKey( + { + keys: keyInfos, + }: { + keys: Record; + }, + secretName: string, +): Promise<[string, Uint8Array]> { const cli = MatrixClientPeg.safeGet(); - let keyId = await cli.secretStorage.getDefaultKeyId(); - let keyInfo!: SecretStorage.SecretStorageKeyDescription; - if (keyId) { - // use the default SSSS key if set - keyInfo = keyInfos[keyId]; - if (!keyInfo) { - // if the default key is not available, pretend the default key - // isn't set - keyId = null; - } - } - if (!keyId) { - // if no default SSSS key is set, fall back to a heuristic of using the + const defaultKeyId = await cli.secretStorage.getDefaultKeyId(); + + let keyId: string; + // If the defaultKey is useful, use that + if (defaultKeyId && keyInfos[defaultKeyId]) { + keyId = defaultKeyId; + } else { + // Fall back to a heuristic of using the // only available key, if only one key is set - const keyInfoEntries = Object.entries(keyInfos); - if (keyInfoEntries.length > 1) { + const usefulKeys = Object.keys(keyInfos); + if (usefulKeys.length > 1) { throw new Error("Multiple storage key requests not implemented"); } - [keyId, keyInfo] = keyInfoEntries[0]; + keyId = usefulKeys[0]; } - logger.debug(`getSecretStorageKey: request for 4S keys [${Object.keys(keyInfos)}]: looking for key ${keyId}`); + const keyInfo = keyInfos[keyId]; + logger.debug( + `getSecretStorageKey: request for 4S keys [${Object.keys(keyInfos)}] for secret \`${secretName}\`: looking for key ${keyId}`, + ); // Check the in-memory cache if (secretStorageBeingAccessed && secretStorageKeys[keyId]) { @@ -106,12 +109,18 @@ async function getSecretStorageKey({ const keyFromCustomisations = ModuleRunner.instance.extensions.cryptoSetup.getSecretStorageKey(); if (keyFromCustomisations) { - logger.log("getSecretStorageKey: Using secret storage key from CryptoSetupExtension"); + logger.debug("getSecretStorageKey: Using secret storage key from CryptoSetupExtension"); cacheSecretStorageKey(keyId, keyInfo, keyFromCustomisations); return [keyId, keyFromCustomisations]; } - logger.debug("getSecretStorageKey: prompting user for key"); + // We only prompt the user for the default key + if (keyId !== defaultKeyId) { + logger.debug(`getSecretStorageKey: request for non-default key ${keyId}: not prompting user`); + throw new Error("Request for non-default 4S key"); + } + + logger.debug(`getSecretStorageKey: prompting user for key ${keyId}`); const inputToKey = makeInputToKey(keyInfo); const { finished } = Modal.createDialog( AccessSecretStorageDialog, @@ -139,7 +148,7 @@ async function getSecretStorageKey({ if (!keyParams) { throw new AccessCancelledError(); } - logger.debug("getSecretStorageKey: got key from user"); + logger.debug(`getSecretStorageKey: got key ${keyId} from user`); const key = await inputToKey(keyParams); // Save to cache to avoid future prompts in the current session @@ -154,6 +163,7 @@ function cacheSecretStorageKey( key: Uint8Array, ): void { if (secretStorageBeingAccessed) { + logger.debug(`Caching 4S key ${keyId}`); secretStorageKeys[keyId] = key; secretStorageKeyInfo[keyId] = keyInfo; } @@ -173,13 +183,13 @@ export const crossSigningCallbacks: CryptoCallbacks = { * @param func - The operation to be wrapped. */ export async function withSecretStorageKeyCache(func: () => Promise): Promise { - logger.debug("SecurityManager: enabling 4S key cache"); + logger.debug("enabling 4S key cache"); secretStorageBeingAccessed = true; try { return await func(); } finally { // Clear secret storage key cache now that work is complete - logger.debug("SecurityManager: disabling 4S key cache"); + logger.debug("disabling 4S key cache"); secretStorageBeingAccessed = false; secretStorageKeys = {}; secretStorageKeyInfo = {}; diff --git a/src/Terms.ts b/src/Terms.ts index 02b67545da..a1870ebe21 100644 --- a/src/Terms.ts +++ b/src/Terms.ts @@ -7,11 +7,12 @@ Please see LICENSE files in the repository root for full details. */ import classNames from "classnames"; -import { SERVICE_TYPES, MatrixClient } from "matrix-js-sdk/src/matrix"; +import { SERVICE_TYPES, MatrixClient, Terms, Policy, InternationalisedPolicy } from "matrix-js-sdk/src/matrix"; import { logger } from "matrix-js-sdk/src/logger"; import Modal from "./Modal"; import TermsDialog from "./components/views/dialogs/TermsDialog"; +import { pickBestLanguage } from "./languageHandler.tsx"; export class TermsNotSignedError extends Error {} @@ -32,23 +33,8 @@ export class Service { ) {} } -export interface LocalisedPolicy { - name: string; - url: string; -} - -export interface Policy { - // @ts-ignore: No great way to express indexed types together with other keys - version: string; - [lang: string]: LocalisedPolicy; -} - -export type Policies = { - [policy: string]: Policy; -}; - export type ServicePolicyPair = { - policies: Policies; + policies: Terms["policies"]; service: Service; }; @@ -58,6 +44,11 @@ export type TermsInteractionCallback = ( extraClassNames?: string, ) => Promise; +export function pickBestPolicyLanguage(policy: Policy): InternationalisedPolicy | undefined { + const termsLang = pickBestLanguage(Object.keys(policy).filter((k) => k !== "version")); + return policy[termsLang]; +} + /** * Start a flow where the user is presented with terms & conditions for some services * @@ -96,7 +87,7 @@ export async function startTermsFlow( * } */ - const terms: { policies: Policies }[] = await Promise.all(termsPromises); + const terms: Terms[] = await Promise.all(termsPromises); const policiesAndServicePairs = terms.map((t, i) => { return { service: services[i], policies: t.policies }; }); @@ -113,11 +104,11 @@ export async function startTermsFlow( // things they've not agreed to yet. const unagreedPoliciesAndServicePairs: ServicePolicyPair[] = []; for (const { service, policies } of policiesAndServicePairs) { - const unagreedPolicies: Policies = {}; + const unagreedPolicies: Terms["policies"] = {}; for (const [policyName, policy] of Object.entries(policies)) { let policyAgreed = false; for (const lang of Object.keys(policy)) { - if (lang === "version") continue; + if (lang === "version" || typeof policy[lang] === "string") continue; if (agreedUrlSet.has(policy[lang].url)) { policyAgreed = true; break; @@ -154,7 +145,7 @@ export async function startTermsFlow( const urlsForService = Array.from(agreedUrlSet).filter((url) => { for (const policy of Object.values(policiesAndService.policies)) { for (const lang of Object.keys(policy)) { - if (lang === "version") continue; + if (lang === "version" || typeof policy[lang] === "string") continue; if (policy[lang].url === url) return true; } } diff --git a/src/TextForEvent.tsx b/src/TextForEvent.tsx index bdb7e8cbe0..5b34fe83b2 100644 --- a/src/TextForEvent.tsx +++ b/src/TextForEvent.tsx @@ -17,6 +17,7 @@ import { MsgType, M_POLL_START, M_POLL_END, + ContentHelpers, } from "matrix-js-sdk/src/matrix"; import { KnownMembership } from "matrix-js-sdk/src/types"; import { logger } from "matrix-js-sdk/src/logger"; @@ -227,11 +228,16 @@ function textForMemberEvent( function textForTopicEvent(ev: MatrixEvent): (() => string) | null { const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); + const topic = ContentHelpers.parseTopicContent(ev.getContent()).text; return () => - _t("timeline|m.room.topic", { - senderDisplayName, - topic: ev.getContent().topic, - }); + topic + ? _t("timeline|m.room.topic|changed", { + senderDisplayName, + topic, + }) + : _t("timeline|m.room.topic|removed", { + senderDisplayName, + }); } function textForRoomAvatarEvent(ev: MatrixEvent): (() => string) | null { diff --git a/src/WorkerManager.ts b/src/WorkerManager.ts index 089463dc91..f34d8dbfd1 100644 --- a/src/WorkerManager.ts +++ b/src/WorkerManager.ts @@ -10,7 +10,7 @@ import { defer, IDeferred } from "matrix-js-sdk/src/utils"; import { WorkerPayload } from "./workers/worker"; -export class WorkerManager { +export class WorkerManager { private readonly worker: Worker; private seq = 0; private pendingDeferredMap = new Map>(); diff --git a/src/async-components/views/dialogs/security/CreateSecretStorageDialog.tsx b/src/async-components/views/dialogs/security/CreateSecretStorageDialog.tsx index 235f73fc8e..7f93a03339 100644 --- a/src/async-components/views/dialogs/security/CreateSecretStorageDialog.tsx +++ b/src/async-components/views/dialogs/security/CreateSecretStorageDialog.tsx @@ -332,7 +332,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent { +interface IOptions { keys: Array>; funcs?: Array<(o: T) => string | string[]>; shouldMatchWordsOnly?: boolean; @@ -37,7 +37,7 @@ interface IOptions { * @param {function[]} options.funcs List of functions that when called with the * object as an arg will return a string to use as an index */ -export default class QueryMatcher { +export default class QueryMatcher { private _options: IOptions; private _items = new Map(); diff --git a/src/components/structures/AutoHideScrollbar.tsx b/src/components/structures/AutoHideScrollbar.tsx index 764a712d44..da27f25cdb 100644 --- a/src/components/structures/AutoHideScrollbar.tsx +++ b/src/components/structures/AutoHideScrollbar.tsx @@ -11,7 +11,7 @@ import classNames from "classnames"; import React, { HTMLAttributes, ReactHTML, ReactNode, WheelEvent } from "react"; type DynamicHtmlElementProps = - JSX.IntrinsicElements[T] extends HTMLAttributes<{}> ? DynamicElementProps : DynamicElementProps<"div">; + JSX.IntrinsicElements[T] extends HTMLAttributes ? DynamicElementProps : DynamicElementProps<"div">; type DynamicElementProps = Partial>; export type IProps = Omit, "onScroll"> & { diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index 9d3114c67c..1d9637fa49 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -50,7 +50,7 @@ import ThemeController from "../../settings/controllers/ThemeController"; import { startAnyRegistrationFlow } from "../../Registration"; import ResizeNotifier from "../../utils/ResizeNotifier"; import AutoDiscoveryUtils from "../../utils/AutoDiscoveryUtils"; -import ThemeWatcher from "../../settings/watchers/ThemeWatcher"; +import ThemeWatcher, { ThemeWatcherEvent } from "../../settings/watchers/ThemeWatcher"; import { FontWatcher } from "../../settings/watchers/FontWatcher"; import { storeRoomAliasInCache } from "../../RoomAliasCache"; import ToastStore from "../../stores/ToastStore"; @@ -131,6 +131,7 @@ import { ConfirmSessionLockTheftView } from "./auth/ConfirmSessionLockTheftView" import { LoginSplashView } from "./auth/LoginSplashView"; import { cleanUpDraftsIfRequired } from "../../DraftCleaner"; import { InitialCryptoSetupStore } from "../../stores/InitialCryptoSetupStore"; +import { setTheme } from "../../theme"; // legacy export export { default as Views } from "../../Views"; @@ -463,6 +464,7 @@ export default class MatrixChat extends React.PureComponent { this.themeWatcher = new ThemeWatcher(); this.fontWatcher = new FontWatcher(); this.themeWatcher.start(); + this.themeWatcher.on(ThemeWatcherEvent.Change, setTheme); this.fontWatcher.start(); initSentry(SdkConfig.get("sentry")); @@ -495,6 +497,7 @@ export default class MatrixChat extends React.PureComponent { public componentWillUnmount(): void { Lifecycle.stopMatrixClient(); dis.unregister(this.dispatcherRef); + this.themeWatcher?.off(ThemeWatcherEvent.Change, setTheme); this.themeWatcher?.stop(); this.fontWatcher?.stop(); UIStore.destroy(); @@ -1695,13 +1698,6 @@ export default class MatrixChat extends React.PureComponent { if (crypto) { const blacklistEnabled = SettingsStore.getValueAt(SettingLevel.DEVICE, "blacklistUnverifiedDevices"); crypto.globalBlacklistUnverifiedDevices = blacklistEnabled; - - // With cross-signing enabled, we send to unknown devices - // without prompting. Any bad-device status the user should - // be aware of will be signalled through the room shield - // changing colour. More advanced behaviour will come once - // we implement more settings. - cli.setGlobalErrorOnUnknownDevices(false); } // Cannot be done in OnLoggedIn as at that point the AccountSettingsHandler doesn't yet have a client diff --git a/src/components/structures/NonUrgentToastContainer.tsx b/src/components/structures/NonUrgentToastContainer.tsx index aa445a5498..eac92f8af6 100644 --- a/src/components/structures/NonUrgentToastContainer.tsx +++ b/src/components/structures/NonUrgentToastContainer.tsx @@ -7,19 +7,18 @@ Please see LICENSE files in the repository root for full details. */ import * as React from "react"; +import { EmptyObject } from "matrix-js-sdk/src/matrix"; import { ComponentClass } from "../../@types/common"; import NonUrgentToastStore from "../../stores/NonUrgentToastStore"; import { UPDATE_EVENT } from "../../stores/AsyncStore"; -interface IProps {} - interface IState { toasts: ComponentClass[]; } -export default class NonUrgentToastContainer extends React.PureComponent { - public constructor(props: IProps) { +export default class NonUrgentToastContainer extends React.PureComponent { + public constructor(props: EmptyObject) { super(props); this.state = { diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index fe51b60564..5695c7a404 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -66,7 +66,7 @@ import RoomPreviewBar from "../views/rooms/RoomPreviewBar"; import RoomPreviewCard from "../views/rooms/RoomPreviewCard"; import RoomUpgradeWarningBar from "../views/rooms/RoomUpgradeWarningBar"; import AuxPanel from "../views/rooms/AuxPanel"; -import RoomHeader from "../views/rooms/RoomHeader"; +import RoomHeader from "../views/rooms/RoomHeader/RoomHeader"; import { IOOBData, IThreepidInvite } from "../../stores/ThreepidInviteStore"; import EffectsOverlay from "../views/elements/EffectsOverlay"; import { containsEmoji } from "../../effects/utils"; diff --git a/src/components/structures/ToastContainer.tsx b/src/components/structures/ToastContainer.tsx index 02db99a0e0..862e4150aa 100644 --- a/src/components/structures/ToastContainer.tsx +++ b/src/components/structures/ToastContainer.tsx @@ -9,6 +9,7 @@ Please see LICENSE files in the repository root for full details. import * as React from "react"; import classNames from "classnames"; import { Text } from "@vector-im/compound-web"; +import { EmptyObject } from "matrix-js-sdk/src/matrix"; import ToastStore, { IToast } from "../../stores/ToastStore"; @@ -17,8 +18,8 @@ interface IState { countSeen: number; } -export default class ToastContainer extends React.Component<{}, IState> { - public constructor(props: {}) { +export default class ToastContainer extends React.Component { + public constructor(props: EmptyObject) { super(props); this.state = { toasts: ToastStore.sharedInstance().getToasts(), diff --git a/src/components/structures/WaitingForThirdPartyRoomView.tsx b/src/components/structures/WaitingForThirdPartyRoomView.tsx index 7787a03bf2..065ed39cdb 100644 --- a/src/components/structures/WaitingForThirdPartyRoomView.tsx +++ b/src/components/structures/WaitingForThirdPartyRoomView.tsx @@ -11,7 +11,7 @@ import { MatrixEvent } from "matrix-js-sdk/src/matrix"; import ResizeNotifier from "../../utils/ResizeNotifier"; import ErrorBoundary from "../views/elements/ErrorBoundary"; -import RoomHeader from "../views/rooms/RoomHeader"; +import RoomHeader from "../views/rooms/RoomHeader/RoomHeader.tsx"; import ScrollPanel from "./ScrollPanel"; import EventTileBubble from "../views/messages/EventTileBubble"; import NewRoomIntro from "../views/rooms/NewRoomIntro"; diff --git a/src/components/structures/auth/header/AuthHeaderProvider.tsx b/src/components/structures/auth/header/AuthHeaderProvider.tsx index 0189b69212..4c4eb62098 100644 --- a/src/components/structures/auth/header/AuthHeaderProvider.tsx +++ b/src/components/structures/auth/header/AuthHeaderProvider.tsx @@ -24,7 +24,7 @@ interface AuthHeaderAction { export type AuthHeaderReducer = Reducer[], AuthHeaderAction>; -export function AuthHeaderProvider({ children }: PropsWithChildren<{}>): JSX.Element { +export function AuthHeaderProvider({ children }: PropsWithChildren): JSX.Element { const [state, dispatch] = useReducer( (state: ComponentProps[], action: AuthHeaderAction) => { switch (action.type) { diff --git a/src/components/utils/Flex.tsx b/src/components/utils/Flex.tsx index 3788e32c45..c5b81e9eae 100644 --- a/src/components/utils/Flex.tsx +++ b/src/components/utils/Flex.tsx @@ -7,14 +7,14 @@ Please see LICENSE files in the repository root for full details. */ import classNames from "classnames"; -import React, { useMemo } from "react"; +import React, { ComponentProps, JSXElementConstructor, useMemo } from "react"; -type FlexProps = { +type FlexProps> = { /** * The type of the HTML element * @default div */ - as?: string; + as?: T; /** * The CSS class name. */ @@ -30,7 +30,7 @@ type FlexProps = { */ direction?: "row" | "column" | "row-reverse" | "column-reverse"; /** - * The alingment of the flex children + * The alignment of the flex children * @default start */ align?: "start" | "center" | "end" | "baseline" | "stretch"; @@ -48,12 +48,12 @@ type FlexProps = { * the on click event callback */ onClick?: (e: React.MouseEvent) => void; -}; +} & ComponentProps; /** * A flexbox container helper */ -export function Flex({ +export function Flex = "div">({ as = "div", display = "flex", direction = "row", @@ -63,7 +63,7 @@ export function Flex({ className, children, ...props -}: React.PropsWithChildren): JSX.Element { +}: React.PropsWithChildren>): JSX.Element { const style = useMemo( () => ({ "--mx-flex-display": display, diff --git a/src/components/viewmodels/rooms/UserIdentityWarningViewModel.tsx b/src/components/viewmodels/rooms/UserIdentityWarningViewModel.tsx new file mode 100644 index 0000000000..5a5d2da2d1 --- /dev/null +++ b/src/components/viewmodels/rooms/UserIdentityWarningViewModel.tsx @@ -0,0 +1,192 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { EventType, MatrixEvent, Room, RoomMember, RoomStateEvent } from "matrix-js-sdk/src/matrix"; +import { CryptoApi, CryptoEvent } from "matrix-js-sdk/src/crypto-api"; +import { throttle } from "lodash"; +import { logger } from "matrix-js-sdk/src/logger"; + +import { useMatrixClientContext } from "../../../contexts/MatrixClientContext.tsx"; +import { useTypedEventEmitter } from "../../../hooks/useEventEmitter.ts"; + +export type ViolationType = "PinViolation" | "VerificationViolation"; + +/** + * Represents a prompt to the user about a violation in the room. + * The type of violation and the member it relates to are included. + * If the type is "VerificationViolation", the warning is critical and should be reported with more urgency. + */ +export type ViolationPrompt = { + member: RoomMember; + type: ViolationType; +}; + +/** + * The state of the UserIdentityWarningViewModel. + * This includes the current prompt to show to the user and a callback to handle button clicks. + * If currentPrompt is undefined, there are no violations to show. + */ +export interface UserIdentityWarningState { + currentPrompt?: ViolationPrompt; + dispatchAction: (action: UserIdentityWarningViewModelAction) => void; +} + +/** + * List of actions that can be dispatched to the UserIdentityWarningViewModel. + */ +export type UserIdentityWarningViewModelAction = + | { type: "PinUserIdentity"; userId: string } + | { type: "WithdrawVerification"; userId: string }; + +/** + * Maps a list of room members to a list of violations. + * Checks for all members in the room to see if they have any violations. + * If no violations are found, an empty list is returned. + * + * @param cryptoApi + * @param members - The list of room members to check for violations. + */ +async function mapToViolations(cryptoApi: CryptoApi, members: RoomMember[]): Promise { + const violationList = new Array(); + for (const member of members) { + const verificationStatus = await cryptoApi.getUserVerificationStatus(member.userId); + if (verificationStatus.wasCrossSigningVerified() && !verificationStatus.isCrossSigningVerified()) { + violationList.push({ member, type: "VerificationViolation" }); + } else if (verificationStatus.needsUserApproval) { + violationList.push({ member, type: "PinViolation" }); + } + } + return violationList; +} + +export function useUserIdentityWarningViewModel(room: Room, key: string): UserIdentityWarningState { + const cli = useMatrixClientContext(); + const crypto = cli.getCrypto(); + + const [members, setMembers] = useState([]); + const [currentPrompt, setCurrentPrompt] = useState(undefined); + + const loadViolations = useMemo( + () => + throttle(async (): Promise => { + const isEncrypted = crypto && (await crypto.isEncryptionEnabledInRoom(room.roomId)); + if (!isEncrypted) { + setMembers([]); + setCurrentPrompt(undefined); + return; + } + + const targetMembers = await room.getEncryptionTargetMembers(); + setMembers(targetMembers); + const violations = await mapToViolations(crypto, targetMembers); + + let candidatePrompt: ViolationPrompt | undefined; + if (violations.length > 0) { + // sort by user ID to ensure consistent ordering + const sortedViolations = violations.sort((a, b) => a.member.userId.localeCompare(b.member.userId)); + candidatePrompt = sortedViolations[0]; + } else { + candidatePrompt = undefined; + } + + // is the current prompt still valid? + setCurrentPrompt((existingPrompt): ViolationPrompt | undefined => { + if (existingPrompt && violations.includes(existingPrompt)) { + return existingPrompt; + } else if (candidatePrompt) { + return candidatePrompt; + } else { + return undefined; + } + }); + }), + [crypto, room], + ); + + // We need to listen for changes to the members list + useTypedEventEmitter( + cli, + RoomStateEvent.Events, + useCallback( + async (event: MatrixEvent): Promise => { + if (!crypto || event.getRoomId() !== room.roomId) { + return; + } + let shouldRefresh = false; + + const eventType = event.getType(); + + if (eventType === EventType.RoomEncryption && event.getStateKey() === "") { + // Room is now encrypted, so we can initialise the component. + shouldRefresh = true; + } else if (eventType == EventType.RoomMember) { + // We're processing an m.room.member event + // Something has changed in membership, someone joined or someone left or + // someone changed their display name. Anyhow let's refresh. + const userId = event.getStateKey(); + shouldRefresh = !!userId; + } + + if (shouldRefresh) { + loadViolations().catch((e) => { + logger.error("Error refreshing UserIdentityWarningViewModel:", e); + }); + } + }, + [crypto, room, loadViolations], + ), + ); + + // We need to listen for changes to the verification status of the members to refresh violations + useTypedEventEmitter( + cli, + CryptoEvent.UserTrustStatusChanged, + useCallback( + (userId: string): void => { + if (members.find((m) => m.userId == userId)) { + // This member is tracked, we need to refresh. + // refresh all for now? + // As a later optimisation we could store the current violations and only update the relevant one. + loadViolations().catch((e) => { + logger.error("Error refreshing UserIdentityWarning:", e); + }); + } + }, + [loadViolations, members], + ), + ); + + useEffect(() => { + loadViolations().catch((e) => { + logger.error("Error initialising UserIdentityWarning:", e); + }); + }, [loadViolations]); + + const dispatchAction = useCallback( + (action: UserIdentityWarningViewModelAction): void => { + if (!crypto) { + return; + } + if (action.type === "PinUserIdentity") { + crypto.pinCurrentUserIdentity(action.userId).catch((e) => { + logger.error("Error pinning user identity:", e); + }); + } else if (action.type === "WithdrawVerification") { + crypto.withdrawVerificationRequirement(action.userId).catch((e) => { + logger.error("Error withdrawing verification requirement:", e); + }); + } + }, + [crypto], + ); + + return { + currentPrompt, + dispatchAction, + }; +} diff --git a/src/components/views/audio_messages/Waveform.tsx b/src/components/views/audio_messages/Waveform.tsx index 83d02b81fd..2110622403 100644 --- a/src/components/views/audio_messages/Waveform.tsx +++ b/src/components/views/audio_messages/Waveform.tsx @@ -18,8 +18,6 @@ interface IProps { progress: number; // percent complete, 0-1, default 100% } -interface IState {} - /** * A simple waveform component. This renders bars (centered vertically) for each * height provided in the component properties. Updating the properties will update @@ -28,7 +26,7 @@ interface IState {} * For CSS purposes, a mx_Waveform_bar_100pct class is added when the bar should be * "filled", as a demonstration of the progress property. */ -export default class Waveform extends React.PureComponent { +export default class Waveform extends React.PureComponent { public static defaultProps = { progress: 1, }; diff --git a/src/components/views/auth/InteractiveAuthEntryComponents.tsx b/src/components/views/auth/InteractiveAuthEntryComponents.tsx index d493e5c3ca..92440dc203 100644 --- a/src/components/views/auth/InteractiveAuthEntryComponents.tsx +++ b/src/components/views/auth/InteractiveAuthEntryComponents.tsx @@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details. */ import classNames from "classnames"; -import { MatrixClient } from "matrix-js-sdk/src/matrix"; +import { InternationalisedPolicy, Terms, MatrixClient } from "matrix-js-sdk/src/matrix"; import { AuthType, AuthDict, IInputs, IStageStatus } from "matrix-js-sdk/src/interactive-auth"; import { logger } from "matrix-js-sdk/src/logger"; import React, { ChangeEvent, createRef, FormEvent, Fragment } from "react"; @@ -16,14 +16,13 @@ import PopOutIcon from "@vector-im/compound-design-tokens/assets/web/icons/pop-o import EmailPromptIcon from "../../../../res/img/element-icons/email-prompt.svg"; import { _t } from "../../../languageHandler"; -import SettingsStore from "../../../settings/SettingsStore"; -import { LocalisedPolicy, Policies } from "../../../Terms"; import { AuthHeaderModifier } from "../../structures/auth/header/AuthHeaderModifier"; import AccessibleButton, { AccessibleButtonKind, ButtonEvent } from "../elements/AccessibleButton"; import Field from "../elements/Field"; import Spinner from "../elements/Spinner"; import CaptchaForm from "./CaptchaForm"; import { Flex } from "../../utils/Flex"; +import { pickBestPolicyLanguage } from "../../../Terms.ts"; /* This file contains a collection of components which are used by the * InteractiveAuth to prompt the user to enter the information needed @@ -235,12 +234,10 @@ export class RecaptchaAuthEntry extends React.Component; } -interface LocalisedPolicyWithId extends LocalisedPolicy { +interface LocalisedPolicyWithId extends InternationalisedPolicy { id: string; } @@ -278,7 +275,6 @@ export class TermsAuthEntry extends React.Component = {}; const pickedPolicies: { id: string; @@ -287,17 +283,7 @@ export class TermsAuthEntry extends React.Component e !== "version"); - langPolicy = firstLang ? policy[firstLang] : undefined; - } + const langPolicy = pickBestPolicyLanguage(policy); if (!langPolicy) throw new Error("Failed to find a policy to show the user"); initToggles[policyId] = false; @@ -908,7 +894,7 @@ export class SSOAuthEntry extends React.Component extends React.Component { +export class FallbackAuthEntry extends React.Component { protected popupWindow: Window | null; protected fallbackButton = createRef(); diff --git a/src/components/views/auth/Welcome.tsx b/src/components/views/auth/Welcome.tsx index 39d5f95a8d..dc7eb4ad40 100644 --- a/src/components/views/auth/Welcome.tsx +++ b/src/components/views/auth/Welcome.tsx @@ -7,6 +7,7 @@ Please see LICENSE files in the repository root for full details. import React from "react"; import classNames from "classnames"; +import { EmptyObject } from "matrix-js-sdk/src/matrix"; import SdkConfig from "../../../SdkConfig"; import AuthPage from "./AuthPage"; @@ -16,9 +17,7 @@ import LanguageSelector from "./LanguageSelector"; import EmbeddedPage from "../../structures/EmbeddedPage"; import { MATRIX_LOGO_HTML } from "../../structures/static-page-vars"; -interface IProps {} - -export default class Welcome extends React.PureComponent { +export default class Welcome extends React.PureComponent { public render(): React.ReactNode { const pagesConfig = SdkConfig.getObject("embedded_pages"); let pageUrl: string | undefined; diff --git a/src/components/views/dialogs/ModalWidgetDialog.tsx b/src/components/views/dialogs/ModalWidgetDialog.tsx index 58c6c92a5e..1bc123c485 100644 --- a/src/components/views/dialogs/ModalWidgetDialog.tsx +++ b/src/components/views/dialogs/ModalWidgetDialog.tsx @@ -33,7 +33,7 @@ import { OwnProfileStore } from "../../../stores/OwnProfileStore"; import { arrayFastClone } from "../../../utils/arrays"; import { ElementWidget } from "../../../stores/widgets/StopGapWidget"; import { ELEMENT_CLIENT_ID } from "../../../identifiers"; -import SettingsStore from "../../../settings/SettingsStore"; +import ThemeWatcher, { ThemeWatcherEvent } from "../../../settings/watchers/ThemeWatcher"; interface IProps { widgetDefinition: IModalWidgetOpenRequestData; @@ -54,6 +54,7 @@ export default class ModalWidgetDialog extends React.PureComponent = React.createRef(); + private readonly themeWatcher = new ThemeWatcher(); public state: IState = { disabledButtonIds: (this.props.widgetDefinition.buttons || []).filter((b) => b.disabled).map((b) => b.id), @@ -77,6 +78,8 @@ export default class ModalWidgetDialog extends React.PureComponent { + this.themeWatcher.start(); + this.themeWatcher.on(ThemeWatcherEvent.Change, this.onThemeChange); + // Theme may have changed while messaging was starting + this.onThemeChange(this.themeWatcher.getEffectiveTheme()); this.state.messaging?.sendWidgetConfig(this.props.widgetDefinition); }; @@ -94,6 +101,10 @@ export default class ModalWidgetDialog extends React.PureComponent { + this.state.messaging?.updateTheme({ name: theme }); + }; + private onWidgetClose = (ev: CustomEvent): void => { this.props.onFinished(true, ev.detail.data); }; @@ -127,7 +138,7 @@ export default class ModalWidgetDialog extends React.PureComponent k !== "version")); + const internationalisedPolicy = pickBestPolicyLanguage(policyValues[i]); + if (!internationalisedPolicy) continue; let serviceName: JSX.Element | undefined; let summary: JSX.Element | undefined; if (i === 0) { @@ -136,19 +136,19 @@ export default class TermsDialog extends React.PureComponent + {serviceName} {summary} - - {termDoc[termsLang].name} + + {internationalisedPolicy.name} , @@ -164,7 +164,7 @@ export default class TermsDialog extends React.PureComponent> => { const tabs: Tab[] = []; @@ -184,7 +186,12 @@ export default function UserSettingsDialog(props: IProps): JSX.Element { ); tabs.push( - new Tab(UserTab.Encryption, _td("settings|encryption|title"), , ), + new Tab( + UserTab.Encryption, + _td("settings|encryption|title"), + , + , + ), ); if (showLabsFlags() || SettingsStore.getFeatureSettingNames().some((k) => SettingsStore.getBetaInfo(k))) { @@ -219,8 +226,9 @@ export default function UserSettingsDialog(props: IProps): JSX.Element { const [activeTabId, _setActiveTabId] = useActiveTabWithDefault(getTabs(), UserTab.Account, props.initialTabId); const setActiveTabId = (tabId: UserTab): void => { _setActiveTabId(tabId); - // Clear this so switching away from the tab and back to it will not show the QR code again + // Clear these so switching away from the tab and back to it will not show the QR code again setShowMsc4108QrCode(false); + setShowResetIdentity(false); }; const [activeToast, toastRack] = useActiveToast(); diff --git a/src/components/views/elements/EditableItemList.tsx b/src/components/views/elements/EditableItemList.tsx index 134c615194..f08b65e24f 100644 --- a/src/components/views/elements/EditableItemList.tsx +++ b/src/components/views/elements/EditableItemList.tsx @@ -101,7 +101,7 @@ interface IProps { onNewItemChanged?(item: string): void; } -export default class EditableItemList

extends React.PureComponent { +export default class EditableItemList

extends React.PureComponent { protected onItemAdded = (e: ButtonEvent): void => { e.stopPropagation(); e.preventDefault(); diff --git a/src/components/views/elements/FacePile.tsx b/src/components/views/elements/FacePile.tsx index 49416a8d38..5eecd9a3ba 100644 --- a/src/components/views/elements/FacePile.tsx +++ b/src/components/views/elements/FacePile.tsx @@ -9,9 +9,12 @@ Please see LICENSE files in the repository root for full details. import React, { FC, HTMLAttributes, ReactNode } from "react"; import { RoomMember } from "matrix-js-sdk/src/matrix"; import { AvatarStack, Tooltip } from "@vector-im/compound-web"; +import classNames from "classnames"; import MemberAvatar from "../avatars/MemberAvatar"; import AccessibleButton, { ButtonEvent } from "./AccessibleButton"; +import { useToggled } from "../rooms/RoomHeader/toggle/useToggled"; +import { RightPanelPhases } from "../../../stores/right-panel/RightPanelStorePhases"; interface IProps extends Omit, "onChange"> { members: RoomMember[]; @@ -57,8 +60,14 @@ const FacePile: FC = ({ ); + const toggled = useToggled(RightPanelPhases.MemberList); + const classes = classNames({ + mx_FacePile: true, + mx_FacePile_toggled: toggled, + }); + const content = ( - + {pileContents} {children} diff --git a/src/components/views/elements/StyledCheckbox.tsx b/src/components/views/elements/StyledCheckbox.tsx index 8e1905924a..2445859341 100644 --- a/src/components/views/elements/StyledCheckbox.tsx +++ b/src/components/views/elements/StyledCheckbox.tsx @@ -21,9 +21,7 @@ interface IProps extends React.InputHTMLAttributes { id?: string; } -interface IState {} - -export default class StyledCheckbox extends React.PureComponent { +export default class StyledCheckbox extends React.PureComponent { private id: string; public static readonly defaultProps = { diff --git a/src/components/views/elements/StyledRadioButton.tsx b/src/components/views/elements/StyledRadioButton.tsx index 01e4de1dad..caf40c7f1a 100644 --- a/src/components/views/elements/StyledRadioButton.tsx +++ b/src/components/views/elements/StyledRadioButton.tsx @@ -18,9 +18,7 @@ interface IProps extends React.InputHTMLAttributes { childrenInLabel?: boolean; } -interface IState {} - -export default class StyledRadioButton extends React.PureComponent { +export default class StyledRadioButton extends React.PureComponent { public static readonly defaultProps = { className: "", childrenInLabel: true, diff --git a/src/components/views/right_panel/UserInfo.tsx b/src/components/views/right_panel/UserInfo.tsx index 4e8968afaa..3be3725576 100644 --- a/src/components/views/right_panel/UserInfo.tsx +++ b/src/components/views/right_panel/UserInfo.tsx @@ -85,6 +85,7 @@ import { asyncSome } from "../../../utils/arrays"; import { Flex } from "../../utils/Flex"; import CopyableText from "../elements/CopyableText"; import { useUserTimezone } from "../../../hooks/useUserTimezone"; + export interface IDevice extends Device { ambiguous?: boolean; } @@ -580,8 +581,10 @@ export const warnSelfDemote = async (isSpace: boolean): Promise => { const Container: React.FC<{ children: ReactNode; -}> = ({ children }) => { - return

{children}
; + className?: string; +}> = ({ children, className }) => { + const classes = classNames("mx_UserInfo_container", className); + return
{children}
; }; interface IPowerLevelsContent { @@ -1707,10 +1710,10 @@ export const UserInfoHeader: React.FC<{ - + - + {displayName} {e2eIcon} @@ -1718,11 +1721,11 @@ export const UserInfoHeader: React.FC<{ {presenceLabel} {timezoneInfo && ( - + {timezoneInfo?.friendly ?? ""} - + )} diff --git a/src/components/views/room_settings/RoomProfileSettings.tsx b/src/components/views/room_settings/RoomProfileSettings.tsx index 8789ea48fc..15ed6f461c 100644 --- a/src/components/views/room_settings/RoomProfileSettings.tsx +++ b/src/components/views/room_settings/RoomProfileSettings.tsx @@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details. import React, { createRef } from "react"; import classNames from "classnames"; -import { EventType } from "matrix-js-sdk/src/matrix"; +import { ContentHelpers, EventType } from "matrix-js-sdk/src/matrix"; import { _t } from "../../../languageHandler"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; @@ -51,7 +51,7 @@ export default class RoomProfileSettings extends React.Component const avatarUrl = avatarEvent?.getContent()["url"] ?? null; const topicEvent = room.currentState.getStateEvents(EventType.RoomTopic, ""); - const topic = topicEvent && topicEvent.getContent() ? topicEvent.getContent()["topic"] : ""; + const topic = (topicEvent && ContentHelpers.parseTopicContent(topicEvent.getContent()).text) || ""; const nameEvent = room.currentState.getStateEvents(EventType.RoomName, ""); const name = nameEvent && nameEvent.getContent() ? nameEvent.getContent()["name"] : ""; @@ -145,6 +145,8 @@ export default class RoomProfileSettings extends React.Component if (this.state.originalTopic !== this.state.topic) { const html = htmlSerializeFromMdIfNeeded(this.state.topic, { forceHTML: false }); + // XXX: Note that we deliberately send an empty string on an empty topic rather + // than a clearer `undefined` value. Synapse still requires a string in a topic. await client.setRoomTopic(this.props.roomId, this.state.topic, html); newState.originalTopic = this.state.topic; } diff --git a/src/components/views/rooms/Autocomplete.tsx b/src/components/views/rooms/Autocomplete.tsx index f77f394d8c..33d3435dfc 100644 --- a/src/components/views/rooms/Autocomplete.tsx +++ b/src/components/views/rooms/Autocomplete.tsx @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { createRef, KeyboardEvent, RefObject } from "react"; +import React, { createRef, RefObject } from "react"; import classNames from "classnames"; import { flatMap } from "lodash"; import { Room } from "matrix-js-sdk/src/matrix"; @@ -206,7 +206,7 @@ export default class Autocomplete extends React.PureComponent { this.setSelection(1 + index); } - public onEscape(e: KeyboardEvent): boolean | undefined { + public onEscape(e: KeyboardEvent | React.KeyboardEvent): boolean | undefined { const completionCount = this.countCompletions(); if (completionCount === 0) { // autocomplete is already empty, so don't preventDefault diff --git a/src/components/views/rooms/MemberList/MemberListView.tsx b/src/components/views/rooms/MemberList/MemberListView.tsx index f45b15eff1..328420d737 100644 --- a/src/components/views/rooms/MemberList/MemberListView.tsx +++ b/src/components/views/rooms/MemberList/MemberListView.tsx @@ -21,6 +21,7 @@ import { ThreePidInviteTileView } from "./tiles/ThreePidInviteTileView"; import { MemberListHeaderView } from "./MemberListHeaderView"; import BaseCard from "../../right_panel/BaseCard"; import { _t } from "../../../../languageHandler"; +import { RovingTabIndexProvider } from "../../../../accessibility/RovingTabIndex"; interface IProps { roomId: string; @@ -86,24 +87,33 @@ const MemberListView: React.FC = (props: IProps) => { header={_t("common|people")} onClose={props.onClose} > - - - - - - {({ height, width }) => ( - - )} - - + + {({ onKeyDownHandler }) => ( + + + + + + {({ height, width }) => ( + + )} + + + )} + ); }; diff --git a/src/components/views/rooms/MemberList/tiles/common/MemberTileView.tsx b/src/components/views/rooms/MemberList/tiles/common/MemberTileView.tsx index a487115a26..3b4e3d6cf2 100644 --- a/src/components/views/rooms/MemberList/tiles/common/MemberTileView.tsx +++ b/src/components/views/rooms/MemberList/tiles/common/MemberTileView.tsx @@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details. import React from "react"; -import AccessibleButton from "../../../../elements/AccessibleButton"; +import { RovingAccessibleButton } from "../../../../../../accessibility/RovingTabIndex"; interface Props { avatarJsx: JSX.Element; @@ -28,7 +28,7 @@ export function MemberTileView(props: Props): JSX.Element { return ( // The wrapping div is required to make the magic mouse listener work, for some reason.
- +
{props.avatarJsx} {props.presenceJsx} @@ -39,7 +39,7 @@ export function MemberTileView(props: Props): JSX.Element { {userLabelJsx} {props.iconJsx}
- +
); } diff --git a/src/components/views/rooms/RoomBreadcrumbs.tsx b/src/components/views/rooms/RoomBreadcrumbs.tsx index 40290358f5..ce87718197 100644 --- a/src/components/views/rooms/RoomBreadcrumbs.tsx +++ b/src/components/views/rooms/RoomBreadcrumbs.tsx @@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details. */ import React, { createRef } from "react"; -import { Room } from "matrix-js-sdk/src/matrix"; +import { EmptyObject, Room } from "matrix-js-sdk/src/matrix"; import { CSSTransition } from "react-transition-group"; import { BreadcrumbsStore } from "../../../stores/BreadcrumbsStore"; @@ -21,8 +21,6 @@ import { Action } from "../../../dispatcher/actions"; import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton"; -interface IProps {} - interface IState { // Both of these control the animation for the breadcrumbs. For details on the // actual animation, see the CSS. @@ -59,11 +57,11 @@ const RoomBreadcrumbTile: React.FC<{ room: Room; onClick: (ev: ButtonEvent) => v ); }; -export default class RoomBreadcrumbs extends React.PureComponent { +export default class RoomBreadcrumbs extends React.PureComponent { private unmounted = false; private toolbar = createRef(); - public constructor(props: IProps) { + public constructor(props: EmptyObject) { super(props); this.state = { diff --git a/src/components/views/rooms/RoomHeader.tsx b/src/components/views/rooms/RoomHeader.tsx deleted file mode 100644 index afe68ee5be..0000000000 --- a/src/components/views/rooms/RoomHeader.tsx +++ /dev/null @@ -1,408 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2023 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial -Please see LICENSE files in the repository root for full details. -*/ - -import React, { useCallback, useMemo, useState } from "react"; -import { Body as BodyText, 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"; -import CloseCallIcon from "@vector-im/compound-design-tokens/assets/web/icons/close"; -import ThreadsIcon from "@vector-im/compound-design-tokens/assets/web/icons/threads-solid"; -import RoomInfoIcon from "@vector-im/compound-design-tokens/assets/web/icons/info-solid"; -import NotificationsIcon from "@vector-im/compound-design-tokens/assets/web/icons/notifications-solid"; -import VerifiedIcon from "@vector-im/compound-design-tokens/assets/web/icons/verified"; -import ErrorIcon from "@vector-im/compound-design-tokens/assets/web/icons/error"; -import PublicIcon from "@vector-im/compound-design-tokens/assets/web/icons/public"; -import { JoinRule, type Room } from "matrix-js-sdk/src/matrix"; -import { ViewRoomOpts } from "@matrix-org/react-sdk-module-api/lib/lifecycles/RoomViewLifecycle"; - -import { useRoomName } from "../../../hooks/useRoomName"; -import { RightPanelPhases } from "../../../stores/right-panel/RightPanelStorePhases"; -import { useMatrixClientContext } from "../../../contexts/MatrixClientContext"; -import { useRoomMemberCount, useRoomMembers } from "../../../hooks/useRoomMembers"; -import { _t } from "../../../languageHandler"; -import { Flex } from "../../utils/Flex"; -import { Box } from "../../utils/Box"; -import { getPlatformCallTypeProps, useRoomCall } from "../../../hooks/room/useRoomCall"; -import { useRoomThreadNotifications } from "../../../hooks/room/useRoomThreadNotifications"; -import { useGlobalNotificationState } from "../../../hooks/useGlobalNotificationState"; -import SdkConfig from "../../../SdkConfig"; -import { useFeatureEnabled } from "../../../hooks/useSettings"; -import { useEncryptionStatus } from "../../../hooks/useEncryptionStatus"; -import { E2EStatus } from "../../../utils/ShieldUtils"; -import FacePile from "../elements/FacePile"; -import { useRoomState } from "../../../hooks/useRoomState"; -import RoomAvatar from "../avatars/RoomAvatar"; -import { formatCount } from "../../../utils/FormattingUtils"; -import RightPanelStore from "../../../stores/right-panel/RightPanelStore"; -import PosthogTrackers from "../../../PosthogTrackers"; -import { VideoRoomChatButton } from "./RoomHeader/VideoRoomChatButton"; -import { RoomKnocksBar } from "./RoomKnocksBar"; -import { isVideoRoom as calcIsVideoRoom } from "../../../utils/video-rooms"; -import { notificationLevelToIndicator } from "../../../utils/notifications"; -import { CallGuestLinkButton } from "./RoomHeader/CallGuestLinkButton"; -import { ButtonEvent } from "../elements/AccessibleButton"; -import WithPresenceIndicator, { useDmMember } from "../avatars/WithPresenceIndicator"; -import { IOOBData } from "../../../stores/ThreepidInviteStore"; -import { MainSplitContentType } from "../../structures/RoomView"; -import defaultDispatcher from "../../../dispatcher/dispatcher.ts"; -import { RoomSettingsTab } from "../dialogs/RoomSettingsDialog.tsx"; -import { useScopedRoomContext } from "../../../contexts/ScopedRoomContext.tsx"; - -export default function RoomHeader({ - room, - additionalButtons, - oobData, -}: { - room: Room; - additionalButtons?: ViewRoomOpts["buttons"]; - oobData?: IOOBData; -}): JSX.Element { - const client = useMatrixClientContext(); - - const roomName = useRoomName(room); - const joinRule = useRoomState(room, (state) => state.getJoinRule()); - - const members = useRoomMembers(room, 2500); - const memberCount = useRoomMemberCount(room, { throttleWait: 2500 }); - - const { - voiceCallDisabledReason, - voiceCallClick, - videoCallDisabledReason, - videoCallClick, - toggleCallMaximized: toggleCall, - isViewingCall, - isConnectedToCall, - hasActiveCallSession, - callOptions, - 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(); - - const dmMember = useDmMember(room); - const isDirectMessage = !!dmMember; - const e2eStatus = useEncryptionStatus(client, room); - - const notificationsEnabled = useFeatureEnabled("feature_notifications"); - - const askToJoinEnabled = useFeatureEnabled("feature_ask_to_join"); - - const videoClick = useCallback( - (ev: React.MouseEvent) => videoCallClick(ev, callOptions[0]), - [callOptions, videoCallClick], - ); - - const toggleCallButton = ( - - - - - - ); - - const joinCallButton = ( - - - - ); - - const callIconWithTooltip = ( - - - - ); - - const [menuOpen, setMenuOpen] = useState(false); - - const onOpenChange = useCallback( - (newOpen: boolean) => { - if (!videoCallDisabledReason) setMenuOpen(newOpen); - }, - [videoCallDisabledReason], - ); - - const startVideoCallButton = ( - <> - {/* Can be either a menu or just a button depending on the number of call options.*/} - {callOptions.length > 1 ? ( - - {callIconWithTooltip} - - } - side="left" - align="start" - > - {callOptions.map((option) => { - const { label, children } = getPlatformCallTypeProps(option); - return ( - videoCallClick(ev, option)} - Icon={VideoCallIcon} - onSelect={() => {} /* Dummy handler since we want the click event.*/} - /> - ); - })} - - ) : ( - - {callIconWithTooltip} - - )} - - ); - let voiceCallButton: JSX.Element | undefined = ( - - voiceCallClick(ev, callOptions[0])} - > - - - - ); - const closeLobbyButton = ( - - - - - - ); - let videoCallButton: JSX.Element | undefined = startVideoCallButton; - if (isConnectedToCall) { - videoCallButton = toggleCallButton; - } else if (isViewingCall) { - videoCallButton = closeLobbyButton; - } - - if (!showVideoCallButton) { - videoCallButton = undefined; - } - if (!showVoiceCallButton) { - voiceCallButton = undefined; - } - - const roomContext = useScopedRoomContext("mainSplitContentType"); - const isVideoRoom = calcIsVideoRoom(room); - const showChatButton = - isVideoRoom || - roomContext.mainSplitContentType === MainSplitContentType.MaximisedWidget || - roomContext.mainSplitContentType === MainSplitContentType.Call; - - const onAvatarClick = (): void => { - defaultDispatcher.dispatch({ - action: "open_room_settings", - initial_tab_id: RoomSettingsTab.General, - }); - }; - - return ( - <> - - - {/* We hide this from the tabIndex list as it is a pointer shortcut and superfluous for a11y */} - - - - - {additionalButtons?.map((props) => { - const label = props.label(); - - return ( - - { - event.stopPropagation(); - props.onClick(); - }} - > - {typeof props.icon === "function" ? props.icon() : props.icon} - - - ); - })} - - {isViewingCall && } - - {hasActiveCallSession && !isConnectedToCall && !isViewingCall ? ( - joinCallButton - ) : ( - <> - {!isVideoRoom && videoCallButton} - {!useElementCallExclusively && !isVideoRoom && voiceCallButton} - - )} - - {showChatButton && } - - - { - evt.stopPropagation(); - RightPanelStore.instance.showOrHidePhase(RightPanelPhases.ThreadPanel); - PosthogTrackers.trackInteraction("WebRoomHeaderButtonsThreadsButton", evt); - }} - aria-label={_t("common|threads")} - > - - - - {notificationsEnabled && ( - - { - evt.stopPropagation(); - RightPanelStore.instance.showOrHidePhase(RightPanelPhases.NotificationPanel); - }} - aria-label={_t("notifications|enable_prompt_toast_title")} - > - - - - )} - - - { - evt.stopPropagation(); - RightPanelStore.instance.showOrHidePhase(RightPanelPhases.RoomSummary); - }} - aria-label={_t("right_panel|room_summary_card|title")} - > - - - - - {!isDirectMessage && ( - - { - RightPanelStore.instance.showOrHidePhase(RightPanelPhases.MemberList); - e.stopPropagation(); - }} - aria-label={_t("common|n_members", { count: memberCount })} - > - {formatCount(memberCount)} - - - )} - - {askToJoinEnabled && } - - ); -} diff --git a/src/components/views/rooms/RoomHeader/RoomHeader.tsx b/src/components/views/rooms/RoomHeader/RoomHeader.tsx new file mode 100644 index 0000000000..206b7f2413 --- /dev/null +++ b/src/components/views/rooms/RoomHeader/RoomHeader.tsx @@ -0,0 +1,412 @@ +/* +Copyright 2024 New Vector Ltd. +Copyright 2023 The Matrix.org Foundation C.I.C. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import React, { useCallback, useMemo, useState } from "react"; +import { Body as BodyText, 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"; +import CloseCallIcon from "@vector-im/compound-design-tokens/assets/web/icons/close"; +import ThreadsIcon from "@vector-im/compound-design-tokens/assets/web/icons/threads-solid"; +import RoomInfoIcon from "@vector-im/compound-design-tokens/assets/web/icons/info-solid"; +import NotificationsIcon from "@vector-im/compound-design-tokens/assets/web/icons/notifications-solid"; +import VerifiedIcon from "@vector-im/compound-design-tokens/assets/web/icons/verified"; +import ErrorIcon from "@vector-im/compound-design-tokens/assets/web/icons/error"; +import PublicIcon from "@vector-im/compound-design-tokens/assets/web/icons/public"; +import { JoinRule, type Room } from "matrix-js-sdk/src/matrix"; +import { ViewRoomOpts } from "@matrix-org/react-sdk-module-api/lib/lifecycles/RoomViewLifecycle"; + +import { useRoomName } from "../../../../hooks/useRoomName.ts"; +import { RightPanelPhases } from "../../../../stores/right-panel/RightPanelStorePhases.ts"; +import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext.tsx"; +import { useRoomMemberCount, useRoomMembers } from "../../../../hooks/useRoomMembers.ts"; +import { _t } from "../../../../languageHandler.tsx"; +import { Flex } from "../../../utils/Flex.tsx"; +import { Box } from "../../../utils/Box.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"; +import FacePile from "../../elements/FacePile.tsx"; +import { useRoomState } from "../../../../hooks/useRoomState.ts"; +import RoomAvatar from "../../avatars/RoomAvatar.tsx"; +import { formatCount } from "../../../../utils/FormattingUtils.ts"; +import RightPanelStore from "../../../../stores/right-panel/RightPanelStore.ts"; +import PosthogTrackers from "../../../../PosthogTrackers.ts"; +import { VideoRoomChatButton } from "./VideoRoomChatButton.tsx"; +import { RoomKnocksBar } from "../RoomKnocksBar.tsx"; +import { isVideoRoom as calcIsVideoRoom } from "../../../../utils/video-rooms.ts"; +import { notificationLevelToIndicator } from "../../../../utils/notifications.ts"; +import { CallGuestLinkButton } from "./CallGuestLinkButton.tsx"; +import { ButtonEvent } from "../../elements/AccessibleButton.tsx"; +import WithPresenceIndicator, { useDmMember } from "../../avatars/WithPresenceIndicator.tsx"; +import { IOOBData } from "../../../../stores/ThreepidInviteStore.ts"; +import { MainSplitContentType } from "../../../structures/RoomView.tsx"; +import defaultDispatcher from "../../../../dispatcher/dispatcher.ts"; +import { RoomSettingsTab } from "../../dialogs/RoomSettingsDialog.tsx"; +import { useScopedRoomContext } from "../../../../contexts/ScopedRoomContext.tsx"; +import { ToggleableIcon } from "./toggle/ToggleableIcon.tsx"; +import { CurrentRightPanelPhaseContextProvider } from "../../../../contexts/CurrentRightPanelPhaseContext.tsx"; + +export default function RoomHeader({ + room, + additionalButtons, + oobData, +}: { + room: Room; + additionalButtons?: ViewRoomOpts["buttons"]; + oobData?: IOOBData; +}): JSX.Element { + const client = useMatrixClientContext(); + + const roomName = useRoomName(room); + const joinRule = useRoomState(room, (state) => state.getJoinRule()); + + const members = useRoomMembers(room, 2500); + const memberCount = useRoomMemberCount(room, { throttleWait: 2500 }); + + const { + voiceCallDisabledReason, + voiceCallClick, + videoCallDisabledReason, + videoCallClick, + toggleCallMaximized: toggleCall, + isViewingCall, + isConnectedToCall, + hasActiveCallSession, + callOptions, + 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(); + + const dmMember = useDmMember(room); + const isDirectMessage = !!dmMember; + const e2eStatus = useEncryptionStatus(client, room); + + const notificationsEnabled = useFeatureEnabled("feature_notifications"); + + const askToJoinEnabled = useFeatureEnabled("feature_ask_to_join"); + + const videoClick = useCallback( + (ev: React.MouseEvent) => videoCallClick(ev, callOptions[0]), + [callOptions, videoCallClick], + ); + + const toggleCallButton = ( + + + + + + ); + + const joinCallButton = ( + + + + ); + + const callIconWithTooltip = ( + + + + ); + + const [menuOpen, setMenuOpen] = useState(false); + + const onOpenChange = useCallback( + (newOpen: boolean) => { + if (!videoCallDisabledReason) setMenuOpen(newOpen); + }, + [videoCallDisabledReason], + ); + + const startVideoCallButton = ( + <> + {/* Can be either a menu or just a button depending on the number of call options.*/} + {callOptions.length > 1 ? ( + + {callIconWithTooltip} + + } + side="left" + align="start" + > + {callOptions.map((option) => { + const { label, children } = getPlatformCallTypeProps(option); + return ( + videoCallClick(ev, option)} + Icon={VideoCallIcon} + onSelect={() => {} /* Dummy handler since we want the click event.*/} + /> + ); + })} + + ) : ( + + {callIconWithTooltip} + + )} + + ); + let voiceCallButton: JSX.Element | undefined = ( + + voiceCallClick(ev, callOptions[0])} + > + + + + ); + const closeLobbyButton = ( + + + + + + ); + let videoCallButton: JSX.Element | undefined = startVideoCallButton; + if (isConnectedToCall) { + videoCallButton = toggleCallButton; + } else if (isViewingCall) { + videoCallButton = closeLobbyButton; + } + + if (!showVideoCallButton) { + videoCallButton = undefined; + } + if (!showVoiceCallButton) { + voiceCallButton = undefined; + } + + const roomContext = useScopedRoomContext("mainSplitContentType"); + const isVideoRoom = calcIsVideoRoom(room); + const showChatButton = + isVideoRoom || + roomContext.mainSplitContentType === MainSplitContentType.MaximisedWidget || + roomContext.mainSplitContentType === MainSplitContentType.Call; + + const onAvatarClick = (): void => { + defaultDispatcher.dispatch({ + action: "open_room_settings", + initial_tab_id: RoomSettingsTab.General, + }); + }; + + return ( + <> + + + + {/* We hide this from the tabIndex list as it is a pointer shortcut and superfluous for a11y */} + + + + + {additionalButtons?.map((props) => { + const label = props.label(); + + return ( + + { + event.stopPropagation(); + props.onClick(); + }} + > + {typeof props.icon === "function" ? props.icon() : props.icon} + + + ); + })} + + {isViewingCall && } + + {hasActiveCallSession && !isConnectedToCall && !isViewingCall ? ( + joinCallButton + ) : ( + <> + {!isVideoRoom && videoCallButton} + {!useElementCallExclusively && !isVideoRoom && voiceCallButton} + + )} + + {showChatButton && } + + + { + evt.stopPropagation(); + RightPanelStore.instance.showOrHidePhase(RightPanelPhases.ThreadPanel); + PosthogTrackers.trackInteraction("WebRoomHeaderButtonsThreadsButton", evt); + }} + aria-label={_t("common|threads")} + > + + + + {notificationsEnabled && ( + + { + evt.stopPropagation(); + RightPanelStore.instance.showOrHidePhase(RightPanelPhases.NotificationPanel); + }} + aria-label={_t("notifications|enable_prompt_toast_title")} + > + + + + )} + + + { + evt.stopPropagation(); + RightPanelStore.instance.showOrHidePhase(RightPanelPhases.RoomSummary); + }} + aria-label={_t("right_panel|room_summary_card|title")} + > + + + + + {!isDirectMessage && ( + + { + RightPanelStore.instance.showOrHidePhase(RightPanelPhases.MemberList); + e.stopPropagation(); + }} + aria-label={_t("common|n_members", { count: memberCount })} + > + {formatCount(memberCount)} + + + )} + + {askToJoinEnabled && } + + + ); +} diff --git a/src/components/views/rooms/RoomHeader/VideoRoomChatButton.tsx b/src/components/views/rooms/RoomHeader/VideoRoomChatButton.tsx index 0dbf62dfda..b1b8ae071e 100644 --- a/src/components/views/rooms/RoomHeader/VideoRoomChatButton.tsx +++ b/src/components/views/rooms/RoomHeader/VideoRoomChatButton.tsx @@ -18,6 +18,7 @@ import { NotificationLevel } from "../../../../stores/notifications/Notification import { RightPanelPhases } from "../../../../stores/right-panel/RightPanelStorePhases"; import { SDKContext } from "../../../../contexts/SDKContext"; import { ButtonEvent } from "../../elements/AccessibleButton"; +import { ToggleableIcon } from "./toggle/ToggleableIcon"; /** * Display a button to toggle timeline for video rooms @@ -54,7 +55,7 @@ export const VideoRoomChatButton: React.FC<{ room: Room }> = ({ room }) => { onClick={onClick} indicator={displayUnreadIndicator ? "default" : undefined} > - + ); diff --git a/src/components/views/rooms/RoomHeader/toggle/ToggleableIcon.tsx b/src/components/views/rooms/RoomHeader/toggle/ToggleableIcon.tsx new file mode 100644 index 0000000000..f90679b744 --- /dev/null +++ b/src/components/views/rooms/RoomHeader/toggle/ToggleableIcon.tsx @@ -0,0 +1,30 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import React from "react"; +import classNames from "classnames"; + +import { RightPanelPhases } from "../../../../../stores/right-panel/RightPanelStorePhases"; +import { useToggled } from "./useToggled"; + +type Props = { + Icon: React.ComponentType>; + phase: RightPanelPhases; +}; + +/** + * Use this component for room header icons that toggle different right panel phases. + * Will add a class to the icon when the specified phase is on. + */ +export function ToggleableIcon({ Icon, phase }: Props): React.ReactElement { + const toggled = useToggled(phase); + const highlightClass = classNames({ + mx_RoomHeader_toggled: toggled, + }); + + return ; +} diff --git a/src/components/views/rooms/RoomHeader/toggle/useToggled.tsx b/src/components/views/rooms/RoomHeader/toggle/useToggled.tsx new file mode 100644 index 0000000000..9d3a5ed0cb --- /dev/null +++ b/src/components/views/rooms/RoomHeader/toggle/useToggled.tsx @@ -0,0 +1,23 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { useContext } from "react"; + +import { RightPanelPhases } from "../../../../../stores/right-panel/RightPanelStorePhases"; +import { CurrentRightPanelPhaseContext } from "../../../../../contexts/CurrentRightPanelPhaseContext"; + +/** + * Hook to easily track whether a given right panel phase is toggled on/off. + */ +export function useToggled(phase: RightPanelPhases): boolean { + const context = useContext(CurrentRightPanelPhaseContext); + if (!context) { + return false; + } + const { currentPhase, isPanelOpen } = context; + return !!(isPanelOpen && currentPhase === phase); +} diff --git a/src/components/views/rooms/UserIdentityWarning.tsx b/src/components/views/rooms/UserIdentityWarning.tsx index 06586c2638..1933767778 100644 --- a/src/components/views/rooms/UserIdentityWarning.tsx +++ b/src/components/views/rooms/UserIdentityWarning.tsx @@ -5,16 +5,18 @@ 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, { useCallback, useEffect, useRef, useState } from "react"; -import { EventType, KnownMembership, MatrixEvent, Room, RoomStateEvent, RoomMember } from "matrix-js-sdk/src/matrix"; -import { CryptoApi, CryptoEvent, UserVerificationStatus } from "matrix-js-sdk/src/crypto-api"; -import { logger } from "matrix-js-sdk/src/logger"; +import React from "react"; +import { Room, RoomMember } from "matrix-js-sdk/src/matrix"; import { Button, Separator } from "@vector-im/compound-web"; +import classNames from "classnames"; import { _t } from "../../../languageHandler"; import MemberAvatar from "../avatars/MemberAvatar"; -import { useMatrixClientContext } from "../../../contexts/MatrixClientContext"; -import { useTypedEventEmitter } from "../../../hooks/useEventEmitter"; +import { + useUserIdentityWarningViewModel, + ViolationPrompt, +} from "../../viewmodels/rooms/UserIdentityWarningViewModel.tsx"; +import { ButtonEvent } from "../elements/AccessibleButton.tsx"; interface UserIdentityWarningProps { /** @@ -28,24 +30,6 @@ interface UserIdentityWarningProps { key: string; } -/** - * Does the given user's identity need to be approved? - */ -async function userNeedsApproval(crypto: CryptoApi, userId: string): Promise { - const verificationStatus = await crypto.getUserVerificationStatus(userId); - return verificationStatus.needsUserApproval; -} - -/** - * Whether the component is uninitialised, is in the process of initialising, or - * has completed initialising. - */ -enum InitialisationStatus { - Uninitialised, - Initialising, - Completed, -} - /** * Displays a banner warning when there is an issue with a user's identity. * @@ -53,283 +37,101 @@ enum InitialisationStatus { * button to acknowledge the change. */ export const UserIdentityWarning: React.FC = ({ room }) => { - const cli = useMatrixClientContext(); - const crypto = cli.getCrypto(); + const { currentPrompt, dispatchAction } = useUserIdentityWarningViewModel(room, room.roomId); - // The current room member that we are prompting the user to approve. - // `undefined` means we are not currently showing a prompt. - const [currentPrompt, setCurrentPrompt] = useState(undefined); + if (!currentPrompt) return null; - // Whether or not we've already initialised the component by loading the - // room membership. - const initialisedRef = useRef(InitialisationStatus.Uninitialised); - // Which room members need their identity approved. - const membersNeedingApprovalRef = useRef>(new Map()); - // For each user, we assign a sequence number to each verification status - // that we get, or fetch. - // - // Since fetching a verification status is asynchronous, we could get an - // update in the middle of fetching the verification status, which could - // mean that the status that we fetched is out of date. So if the current - // sequence number is not the same as the sequence number when we started - // the fetch, then we drop our fetched result, under the assumption that the - // update that we received is the most up-to-date version. If it is in fact - // not the most up-to-date version, then we should be receiving a new update - // soon with the newer value, so it will fix itself in the end. - // - // We also assign a sequence number when the user leaves the room, in order - // to prevent prompting about a user who leaves while we are fetching their - // verification status. - const verificationStatusSequencesRef = useRef>(new Map()); - const incrementVerificationStatusSequence = (userId: string): number => { - const verificationStatusSequences = verificationStatusSequencesRef.current; - const value = verificationStatusSequences.get(userId); - const newValue = value === undefined ? 1 : value + 1; - verificationStatusSequences.set(userId, newValue); - return newValue; + const [title, action] = getTitleAndAction(currentPrompt); + + const onButtonClick = (ev: ButtonEvent): void => { + ev.preventDefault(); + if (currentPrompt.type === "VerificationViolation") { + dispatchAction({ type: "WithdrawVerification", userId: currentPrompt.member.userId }); + } else { + dispatchAction({ type: "PinUserIdentity", userId: currentPrompt.member.userId }); + } }; - - // Update the current prompt. Select a new user if needed, or hide the - // warning if we don't have anyone to warn about. - const updateCurrentPrompt = useCallback((): undefined => { - const membersNeedingApproval = membersNeedingApprovalRef.current; - // We have to do this in a callback to `setCurrentPrompt` - // because this function could have been called after an - // `await`, and the `currentPrompt` that this function would - // have may be outdated. - setCurrentPrompt((currentPrompt) => { - // If we're already displaying a warning, and that user still needs - // approval, continue showing that user. - if (currentPrompt && membersNeedingApproval.has(currentPrompt.userId)) return currentPrompt; - - if (membersNeedingApproval.size === 0) { - if (currentPrompt) { - // If we were previously showing a warning, log that we've stopped doing so. - logger.debug("UserIdentityWarning: no users left that need approval"); - } - return undefined; - } - - // We pick the user with the smallest user ID. - const keys = Array.from(membersNeedingApproval.keys()).sort((a, b) => a.localeCompare(b)); - const selection = membersNeedingApproval.get(keys[0]!); - logger.debug(`UserIdentityWarning: now warning about user ${selection?.userId}`); - return selection; - }); - }, []); - - // Add a user to the membersNeedingApproval map, and update the current - // prompt if necessary. The user will only be added if they are actually a - // member of the room. If they are not a member, this function will do - // nothing. - const addMemberNeedingApproval = useCallback( - (userId: string, member?: RoomMember): void => { - if (userId === cli.getUserId()) { - // We always skip our own user, because we can't pin our own identity. - return; - } - member = member ?? room.getMember(userId) ?? undefined; - if (!member) return; - - membersNeedingApprovalRef.current.set(userId, member); - // We only select the prompt if we are done initialising, - // because we will select the prompt after we're done - // initialising, and we want to start by displaying a warning - // for the user with the smallest ID. - if (initialisedRef.current === InitialisationStatus.Completed) { - logger.debug( - `UserIdentityWarning: user ${userId} now needs approval; approval-pending list now [${Array.from(membersNeedingApprovalRef.current.keys())}]`, - ); - updateCurrentPrompt(); - } - }, - [cli, room, updateCurrentPrompt], + return warningBanner( + currentPrompt.type === "VerificationViolation", + memberAvatar(currentPrompt.member), + title, + action, + onButtonClick, ); +}; - // For each user in the list check if their identity needs approval, and if - // so, add them to the membersNeedingApproval map and update the prompt if - // needed. - const addMembersWhoNeedApproval = useCallback( - async (members: RoomMember[]): Promise => { - const verificationStatusSequences = verificationStatusSequencesRef.current; - - const promises: Promise[] = []; - - for (const member of members) { - const userId = member.userId; - const sequenceNum = incrementVerificationStatusSequence(userId); - promises.push( - userNeedsApproval(crypto!, userId).then((needsApproval) => { - if (needsApproval) { - // Only actually update the list if we have the most - // recent value. - if (verificationStatusSequences.get(userId) === sequenceNum) { - addMemberNeedingApproval(userId, member); - } - } - }), - ); - } - - await Promise.all(promises); - }, - [crypto, addMemberNeedingApproval], - ); - - // Remove a user from the membersNeedingApproval map, and update the current - // prompt if necessary. - const removeMemberNeedingApproval = useCallback( - (userId: string): void => { - membersNeedingApprovalRef.current.delete(userId); - logger.debug( - `UserIdentityWarning: user ${userId} no longer needs approval; approval-pending list now [${Array.from(membersNeedingApprovalRef.current.keys())}]`, +function getTitleAndAction(prompt: ViolationPrompt): [title: React.ReactNode, action: string] { + let title: React.ReactNode; + let action: string; + if (prompt.type === "VerificationViolation") { + if (prompt.member.rawDisplayName === prompt.member.userId) { + title = _t( + "encryption|verified_identity_changed_no_displayname", + { userId: prompt.member.userId }, + { + a: substituteATag, + b: substituteBTag, + }, + ); + } else { + title = _t( + "encryption|verified_identity_changed", + { displayName: prompt.member.rawDisplayName, userId: prompt.member.userId }, + { + a: substituteATag, + b: substituteBTag, + }, ); - updateCurrentPrompt(); - }, - [updateCurrentPrompt], - ); - - // Initialise the component. Get the room members, check which ones need - // their identity approved, and pick one to display. - const loadMembers = useCallback(async (): Promise => { - if (!crypto || initialisedRef.current !== InitialisationStatus.Uninitialised) { - return; } - // If encryption is not enabled in the room, we don't need to do - // anything. If encryption gets enabled later, we will retry, via - // onRoomStateEvent. - if (!(await crypto.isEncryptionEnabledInRoom(room.roomId))) { - return; + action = _t("encryption|withdraw_verification_action"); + } else { + if (prompt.member.rawDisplayName === prompt.member.userId) { + title = _t( + "encryption|pinned_identity_changed_no_displayname", + { userId: prompt.member.userId }, + { + a: substituteATag, + b: substituteBTag, + }, + ); + } else { + title = _t( + "encryption|pinned_identity_changed", + { displayName: prompt.member.rawDisplayName, userId: prompt.member.userId }, + { + a: substituteATag, + b: substituteBTag, + }, + ); } - initialisedRef.current = InitialisationStatus.Initialising; - - const members = await room.getEncryptionTargetMembers(); - await addMembersWhoNeedApproval(members); - - logger.info( - `Initialised UserIdentityWarning component for room ${room.roomId} with approval-pending list [${Array.from(membersNeedingApprovalRef.current.keys())}]`, - ); - updateCurrentPrompt(); - initialisedRef.current = InitialisationStatus.Completed; - }, [crypto, room, addMembersWhoNeedApproval, updateCurrentPrompt]); - - useEffect(() => { - loadMembers().catch((e) => { - logger.error("Error initialising UserIdentityWarning:", e); - }); - }, [loadMembers]); - - // When a user's verification status changes, we check if they need to be - // added/removed from the set of members needing approval. - const onUserVerificationStatusChanged = useCallback( - (userId: string, verificationStatus: UserVerificationStatus): void => { - // If we haven't started initialising, that means that we're in a - // room where we don't need to display any warnings. - if (initialisedRef.current === InitialisationStatus.Uninitialised) { - return; - } - - incrementVerificationStatusSequence(userId); - - if (verificationStatus.needsUserApproval) { - addMemberNeedingApproval(userId); - } else { - removeMemberNeedingApproval(userId); - } - }, - [addMemberNeedingApproval, removeMemberNeedingApproval], - ); - useTypedEventEmitter(cli, CryptoEvent.UserTrustStatusChanged, onUserVerificationStatusChanged); - - // We watch for encryption events (since we only display warnings in - // encrypted rooms), and for membership changes (since we only display - // warnings for users in the room). - const onRoomStateEvent = useCallback( - async (event: MatrixEvent): Promise => { - if (!crypto || event.getRoomId() !== room.roomId) { - return; - } - - const eventType = event.getType(); - if (eventType === EventType.RoomEncryption && event.getStateKey() === "") { - // Room is now encrypted, so we can initialise the component. - return loadMembers().catch((e) => { - logger.error("Error initialising UserIdentityWarning:", e); - }); - } else if (eventType !== EventType.RoomMember) { - return; - } - - // We're processing an m.room.member event - - if (initialisedRef.current === InitialisationStatus.Uninitialised) { - return; - } - - const userId = event.getStateKey(); - - if (!userId) return; - - if ( - event.getContent().membership === KnownMembership.Join || - (event.getContent().membership === KnownMembership.Invite && room.shouldEncryptForInvitedMembers()) - ) { - // Someone's membership changed and we will now encrypt to them. If - // their identity needs approval, show a warning. - const member = room.getMember(userId); - if (member) { - await addMembersWhoNeedApproval([member]).catch((e) => { - logger.error("Error adding member in UserIdentityWarning:", e); - }); - } - } else { - // Someone's membership changed and we no longer encrypt to them. - // If we're showing a warning about them, we don't need to any more. - removeMemberNeedingApproval(userId); - incrementVerificationStatusSequence(userId); - } - }, - [crypto, room, addMembersWhoNeedApproval, removeMemberNeedingApproval, loadMembers], - ); - useTypedEventEmitter(cli, RoomStateEvent.Events, onRoomStateEvent); - - if (!crypto || !currentPrompt) return null; - - const confirmIdentity = async (): Promise => { - await crypto.pinCurrentUserIdentity(currentPrompt.userId); - }; + action = _t("action|ok"); + } + return [title, action]; +} +function warningBanner( + isCritical: boolean, + avatar: React.ReactNode, + title: React.ReactNode, + action: string, + onButtonClick: (ev: ButtonEvent) => void, +): React.ReactNode { return ( -
+
- - - {currentPrompt.rawDisplayName === currentPrompt.userId - ? _t( - "encryption|pinned_identity_changed_no_displayname", - { userId: currentPrompt.userId }, - { - a: substituteATag, - b: substituteBTag, - }, - ) - : _t( - "encryption|pinned_identity_changed", - { displayName: currentPrompt.rawDisplayName, userId: currentPrompt.userId }, - { - a: substituteATag, - b: substituteBTag, - }, - )} - -
); -}; +} +function memberAvatar(member: RoomMember): React.ReactNode { + return ; +} function substituteATag(sub: string): React.ReactNode { return ( diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/utils.ts b/src/components/views/rooms/wysiwyg_composer/hooks/utils.ts index 52a61ac0e3..48b73d352b 100644 --- a/src/components/views/rooms/wysiwyg_composer/hooks/utils.ts +++ b/src/components/views/rooms/wysiwyg_composer/hooks/utils.ts @@ -92,7 +92,7 @@ export function handleEventWithAutocomplete( handled = true; break; case KeyBindingAction.CancelAutocomplete: - autocompleteRef.current.onEscape(event as {} as React.KeyboardEvent); + autocompleteRef.current.onEscape(event); handled = true; break; default: diff --git a/src/components/views/settings/CrossSigningPanel.tsx b/src/components/views/settings/CrossSigningPanel.tsx index 9ec9e9f6c1..5b7a39aad2 100644 --- a/src/components/views/settings/CrossSigningPanel.tsx +++ b/src/components/views/settings/CrossSigningPanel.tsx @@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details. */ import React from "react"; -import { ClientEvent, MatrixEvent } from "matrix-js-sdk/src/matrix"; +import { ClientEvent, EmptyObject, MatrixEvent } from "matrix-js-sdk/src/matrix"; import { logger } from "matrix-js-sdk/src/logger"; import { CryptoEvent } from "matrix-js-sdk/src/crypto-api"; @@ -33,10 +33,10 @@ interface IState { crossSigningReady?: boolean; } -export default class CrossSigningPanel extends React.PureComponent<{}, IState> { +export default class CrossSigningPanel extends React.PureComponent { private unmounted = false; - public constructor(props: {}) { + public constructor(props: EmptyObject) { super(props); this.state = { diff --git a/src/components/views/settings/CryptographyPanel.tsx b/src/components/views/settings/CryptographyPanel.tsx index beb08ab1e9..d0cabf7af9 100644 --- a/src/components/views/settings/CryptographyPanel.tsx +++ b/src/components/views/settings/CryptographyPanel.tsx @@ -8,6 +8,7 @@ Please see LICENSE files in the repository root for full details. import React, { lazy } from "react"; import { logger } from "matrix-js-sdk/src/logger"; +import { EmptyObject } from "matrix-js-sdk/src/matrix"; import { _t } from "../../../languageHandler"; import Modal from "../../../Modal"; @@ -19,8 +20,6 @@ import { SettingLevel } from "../../../settings/SettingLevel"; import { SettingsSubsection, SettingsSubsectionText } from "./shared/SettingsSubsection"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; -interface IProps {} - interface IState { /** The device's base64-encoded Ed25519 identity key, or: * @@ -30,11 +29,11 @@ interface IState { deviceIdentityKey: string | undefined | null; } -export default class CryptographyPanel extends React.Component { +export default class CryptographyPanel extends React.Component { public static contextType = MatrixClientContext; declare public context: React.ContextType; - public constructor(props: IProps, context: React.ContextType) { + public constructor(props: EmptyObject, context: React.ContextType) { super(props); if (!context.getCrypto()) { diff --git a/src/components/views/settings/EventIndexPanel.tsx b/src/components/views/settings/EventIndexPanel.tsx index 41845eb94e..c314f72f38 100644 --- a/src/components/views/settings/EventIndexPanel.tsx +++ b/src/components/views/settings/EventIndexPanel.tsx @@ -7,6 +7,7 @@ Please see LICENSE files in the repository root for full details. */ import React, { lazy } from "react"; +import { EmptyObject } from "matrix-js-sdk/src/matrix"; import { _t } from "../../../languageHandler"; import SdkConfig from "../../../SdkConfig"; @@ -28,8 +29,8 @@ interface IState { eventIndexingEnabled: boolean; } -export default class EventIndexPanel extends React.Component<{}, IState> { - public constructor(props: {}) { +export default class EventIndexPanel extends React.Component { + public constructor(props: EmptyObject) { super(props); this.state = { diff --git a/src/components/views/settings/FontScalingPanel.tsx b/src/components/views/settings/FontScalingPanel.tsx index e1a7f4902f..f2f1d6a93b 100644 --- a/src/components/views/settings/FontScalingPanel.tsx +++ b/src/components/views/settings/FontScalingPanel.tsx @@ -7,6 +7,7 @@ Please see LICENSE files in the repository root for full details. */ import React from "react"; +import { EmptyObject } from "matrix-js-sdk/src/matrix"; import EventTilePreview from "../elements/EventTilePreview"; import SettingsStore from "../../../settings/SettingsStore"; @@ -18,8 +19,6 @@ import { SettingsSubsection } from "./shared/SettingsSubsection"; import Field from "../elements/Field"; import { FontWatcher } from "../../../settings/watchers/FontWatcher"; -interface IProps {} - interface IState { browserFontSize: number; // String displaying the current selected fontSize. @@ -34,7 +33,7 @@ interface IState { avatarUrl?: string; } -export default class FontScalingPanel extends React.Component { +export default class FontScalingPanel extends React.Component { private readonly MESSAGE_PREVIEW_TEXT = _t("common|preview_message"); /** * Font sizes available (in px) @@ -43,7 +42,7 @@ export default class FontScalingPanel extends React.Component { private layoutWatcherRef?: string; private unmounted = false; - public constructor(props: IProps) { + public constructor(props: EmptyObject) { super(props); this.state = { diff --git a/src/components/views/settings/ImageSizePanel.tsx b/src/components/views/settings/ImageSizePanel.tsx index 8079ea1654..cae714d002 100644 --- a/src/components/views/settings/ImageSizePanel.tsx +++ b/src/components/views/settings/ImageSizePanel.tsx @@ -7,6 +7,7 @@ Please see LICENSE files in the repository root for full details. */ import React from "react"; +import { EmptyObject } from "matrix-js-sdk/src/matrix"; import SettingsStore from "../../../settings/SettingsStore"; import StyledRadioButton from "../elements/StyledRadioButton"; @@ -15,16 +16,12 @@ import { SettingLevel } from "../../../settings/SettingLevel"; import { ImageSize } from "../../../settings/enums/ImageSize"; import { SettingsSubsection } from "./shared/SettingsSubsection"; -interface IProps { - // none -} - interface IState { size: ImageSize; } -export default class ImageSizePanel extends React.Component { - public constructor(props: IProps) { +export default class ImageSizePanel extends React.Component { + public constructor(props: EmptyObject) { super(props); this.state = { diff --git a/src/components/views/settings/Notifications.tsx b/src/components/views/settings/Notifications.tsx index 6cf4f8abea..1b9d266a94 100644 --- a/src/components/views/settings/Notifications.tsx +++ b/src/components/views/settings/Notifications.tsx @@ -16,6 +16,7 @@ import { IThreepid, ThreepidMedium, LocalNotificationSettings, + EmptyObject, } from "matrix-js-sdk/src/matrix"; import { logger } from "matrix-js-sdk/src/logger"; @@ -108,8 +109,6 @@ interface IVectorPushRule { syncedVectorState?: VectorState; } -interface IProps {} - interface IState { phase: Phase; @@ -205,10 +204,10 @@ const NotificationActivitySettings = (): JSX.Element => { /** * The old, deprecated notifications tab view, only displayed if the user has the labs flag disabled. */ -export default class Notifications extends React.PureComponent { +export default class Notifications extends React.PureComponent { private settingWatchers: string[] = []; - public constructor(props: IProps) { + public constructor(props: EmptyObject) { super(props); this.state = { @@ -255,7 +254,7 @@ export default class Notifications extends React.PureComponent { this.settingWatchers.forEach((watcher) => SettingsStore.unwatchSetting(watcher)); } - public componentDidUpdate(prevProps: Readonly, prevState: Readonly): void { + public componentDidUpdate(prevProps: Readonly, prevState: Readonly): void { if (this.state.deviceNotificationsEnabled !== prevState.deviceNotificationsEnabled) { this.persistLocalNotificationSettings(this.state.deviceNotificationsEnabled); } @@ -291,7 +290,7 @@ export default class Notifications extends React.PureComponent { } } - private persistLocalNotificationSettings(enabled: boolean): Promise<{}> { + private persistLocalNotificationSettings(enabled: boolean): Promise { const cli = MatrixClientPeg.safeGet(); return cli.setAccountData(getLocalNotificationAccountDataEventType(cli.deviceId), { is_silenced: !enabled, diff --git a/src/components/views/settings/SecureBackupPanel.tsx b/src/components/views/settings/SecureBackupPanel.tsx index 3d24567832..722c3fe38f 100644 --- a/src/components/views/settings/SecureBackupPanel.tsx +++ b/src/components/views/settings/SecureBackupPanel.tsx @@ -10,6 +10,7 @@ Please see LICENSE files in the repository root for full details. import React, { lazy, ReactNode } from "react"; import { CryptoEvent, BackupTrustInfo, KeyBackupInfo } from "matrix-js-sdk/src/crypto-api"; import { logger } from "matrix-js-sdk/src/logger"; +import { EmptyObject } from "matrix-js-sdk/src/matrix"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { _t } from "../../../languageHandler"; @@ -60,10 +61,10 @@ interface IState { sessionsRemaining: number | null; } -export default class SecureBackupPanel extends React.PureComponent<{}, IState> { +export default class SecureBackupPanel extends React.PureComponent { private unmounted = false; - public constructor(props: {}) { + public constructor(props: EmptyObject) { super(props); this.state = { diff --git a/src/components/views/settings/SetIntegrationManager.tsx b/src/components/views/settings/SetIntegrationManager.tsx index 01dab79547..fbf9ff710f 100644 --- a/src/components/views/settings/SetIntegrationManager.tsx +++ b/src/components/views/settings/SetIntegrationManager.tsx @@ -8,6 +8,7 @@ Please see LICENSE files in the repository root for full details. import React from "react"; import { logger } from "matrix-js-sdk/src/logger"; +import { EmptyObject } from "matrix-js-sdk/src/matrix"; import { _t } from "../../../languageHandler"; import { IntegrationManagers } from "../../../integrations/IntegrationManagers"; @@ -19,15 +20,13 @@ import Heading from "../typography/Heading"; import { SettingsSubsectionText } from "./shared/SettingsSubsection"; import { UIFeature } from "../../../settings/UIFeature"; -interface IProps {} - interface IState { currentManager: IntegrationManagerInstance | null; provisioningEnabled: boolean; } -export default class SetIntegrationManager extends React.Component { - public constructor(props: IProps) { +export default class SetIntegrationManager extends React.Component { + public constructor(props: EmptyObject) { super(props); const currentManager = IntegrationManagers.sharedInstance().getPrimaryManager(); diff --git a/src/components/views/settings/discovery/DiscoverySettings.tsx b/src/components/views/settings/discovery/DiscoverySettings.tsx index 28913f289d..e87a1f147b 100644 --- a/src/components/views/settings/discovery/DiscoverySettings.tsx +++ b/src/components/views/settings/discovery/DiscoverySettings.tsx @@ -58,7 +58,7 @@ export const DiscoverySettings: React.FC = () => { agreedUrls: null, // From the startTermsFlow callback resolve: null, // Promise resolve function for startTermsFlow callback }); - const [hasTerms, setHasTerms] = useState(false); + const [mustAgreeToTerms, setMustAgreeToTerms] = useState(false); const getThreepidState = useCallback(async () => { setIsLoadingThreepids(true); @@ -103,7 +103,7 @@ export const DiscoverySettings: React.FC = () => { (policiesAndServices, agreedUrls, extraClassNames) => { return new Promise((resolve) => { setIdServerName(abbreviateUrl(idServerUrl)); - setHasTerms(true); + setMustAgreeToTerms(true); setRequiredPolicyInfo({ policiesAndServices, agreedUrls, @@ -113,7 +113,7 @@ export const DiscoverySettings: React.FC = () => { }, ); // User accepted all terms - setHasTerms(false); + setMustAgreeToTerms(false); } catch (e) { logger.warn( `Unable to reach identity server at ${idServerUrl} to check ` + `for terms in Settings`, @@ -126,7 +126,7 @@ export const DiscoverySettings: React.FC = () => { if (!SettingsStore.getValue(UIFeature.ThirdPartyID)) return null; - if (hasTerms && requiredPolicyInfo.policiesAndServices) { + if (mustAgreeToTerms && requiredPolicyInfo.policiesAndServices) { const intro = ( {_t("settings|general|discovery_needs_terms", { serverName: idServerName })} @@ -160,7 +160,7 @@ export const DiscoverySettings: React.FC = () => { medium={ThreepidMedium.Email} threepids={emails} onChange={getThreepidState} - disabled={!hasTerms} + disabled={mustAgreeToTerms} isLoading={isLoadingThreepids} /> @@ -174,7 +174,7 @@ export const DiscoverySettings: React.FC = () => { medium={ThreepidMedium.Phone} threepids={phoneNumbers} onChange={getThreepidState} - disabled={!hasTerms} + disabled={mustAgreeToTerms} isLoading={isLoadingThreepids} /> diff --git a/src/components/views/settings/encryption/RecoveryPanel.tsx b/src/components/views/settings/encryption/RecoveryPanel.tsx index cd89ba7617..129f698912 100644 --- a/src/components/views/settings/encryption/RecoveryPanel.tsx +++ b/src/components/views/settings/encryption/RecoveryPanel.tsx @@ -5,7 +5,7 @@ * Please see LICENSE files in the repository root for full details. */ -import React, { JSX, useCallback, useEffect, useState } from "react"; +import React, { JSX } from "react"; import { Button, InlineSpinner } from "@vector-im/compound-web"; import KeyIcon from "@vector-im/compound-design-tokens/assets/web/icons/key"; @@ -13,18 +13,15 @@ import { SettingsSection } from "../shared/SettingsSection"; import { _t } from "../../../../languageHandler"; import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext"; import { SettingsHeader } from "../SettingsHeader"; -import { accessSecretStorage } from "../../../../SecurityManager"; -import { SettingsSubheader } from "../SettingsSubheader"; +import { useAsyncMemo } from "../../../../hooks/useAsyncMemo"; /** * The possible states of the recovery panel. * - `loading`: We are checking the recovery key and the secrets. * - `missing_recovery_key`: The user has no recovery key. - * - `secrets_not_cached`: The user has a recovery key but the secrets are not cached. - * This can happen if we verified another device and secret-gossiping failed, or the other device itself lacked the secrets. * - `good`: The user has a recovery key and the secrets are cached. */ -type State = "loading" | "missing_recovery_key" | "secrets_not_cached" | "good"; +type State = "loading" | "missing_recovery_key" | "good"; interface RecoveryPanelProps { /** @@ -40,29 +37,18 @@ interface RecoveryPanelProps { * This component allows the user to set up or change their recovery key. */ export function RecoveryPanel({ onChangeRecoveryKeyClick }: RecoveryPanelProps): JSX.Element { - const [state, setState] = useState("loading"); - const isMissingRecoveryKey = state === "missing_recovery_key"; - const matrixClient = useMatrixClientContext(); - - const checkEncryption = useCallback(async () => { - const crypto = matrixClient.getCrypto()!; - - // Check if the user has a recovery key - const hasRecoveryKey = Boolean(await matrixClient.secretStorage.getDefaultKeyId()); - if (!hasRecoveryKey) return setState("missing_recovery_key"); - - // Check if the secrets are cached - const cachedSecrets = (await crypto.getCrossSigningStatus()).privateKeysCachedLocally; - const secretsOk = cachedSecrets.masterKey && cachedSecrets.selfSigningKey && cachedSecrets.userSigningKey; - if (!secretsOk) return setState("secrets_not_cached"); - - setState("good"); - }, [matrixClient]); - - useEffect(() => { - checkEncryption(); - }, [checkEncryption]); + const state = useAsyncMemo( + async () => { + // Check if the user has a recovery key + const hasRecoveryKey = Boolean(await matrixClient.secretStorage.getDefaultKeyId()); + if (hasRecoveryKey) return "good"; + else return "missing_recovery_key"; + }, + [matrixClient], + "loading", + ); + const isMissingRecoveryKey = state === "missing_recovery_key"; let content: JSX.Element; switch (state) { @@ -76,18 +62,6 @@ export function RecoveryPanel({ onChangeRecoveryKeyClick }: RecoveryPanelProps): ); break; - case "secrets_not_cached": - content = ( - - ); - break; case "good": content = ( + + ); +} diff --git a/src/components/views/settings/encryption/ResetIdentityPanel.tsx b/src/components/views/settings/encryption/ResetIdentityPanel.tsx index c9113e9fe7..b0a6b3fbf2 100644 --- a/src/components/views/settings/encryption/ResetIdentityPanel.tsx +++ b/src/components/views/settings/encryption/ResetIdentityPanel.tsx @@ -25,12 +25,21 @@ interface ResetIdentityPanelProps { * Called when the cancel button is clicked or when we go back in the breadcrumbs. */ onCancelClick: () => void; + + /** + * The variant of the panel to show. We show more warnings in the 'compromised' variant (no use in showing a user this + * warning if they have to reset because they no longer have their key) + * "compromised" is shown when the user chooses 'reset' explicitly in settings, usually because they believe their + * identity has been compromised. + * "forgot" is shown when the user has just forgotten their passphrase. + */ + variant: "compromised" | "forgot"; } /** * The panel for resetting the identity of the current user. */ -export function ResetIdentityPanel({ onCancelClick, onFinish }: ResetIdentityPanelProps): JSX.Element { +export function ResetIdentityPanel({ onCancelClick, onFinish, variant }: ResetIdentityPanelProps): JSX.Element { const matrixClient = useMatrixClientContext(); return ( @@ -44,7 +53,11 @@ export function ResetIdentityPanel({ onCancelClick, onFinish }: ResetIdentityPan
@@ -59,7 +72,7 @@ export function ResetIdentityPanel({ onCancelClick, onFinish }: ResetIdentityPan {_t("settings|encryption|advanced|breadcrumb_third_description")} - {_t("settings|encryption|advanced|breadcrumb_warning")} + {variant === "compromised" && {_t("settings|encryption|advanced|breadcrumb_warning")}}
.", @@ -2470,12 +2723,15 @@ "code_blocks_heading": "Bloky kódu", "compact_modern": "Použít kompaktnější \"moderní\" rozložení", "composer_heading": "Editor zpráv", + "default_timezone": "Výchozí nastavení prohlížeče (%(timezone)s )", + "dialog_title": "Nastavení: Předvolby", "enable_hardware_acceleration": "Povolit hardwarovou akceleraci", "enable_tray_icon": "Zobrazit ikonu v oznamovací oblasti a minimalizivat při zavření okna", "keyboard_heading": "Klávesové zkratky", "keyboard_view_shortcuts_button": "Pro zobrazení všech klávesových zkratek, klikněte zde.", "media_heading": "Obrázky, GIFy a videa", "presence_description": "Sdílejte své aktivity a stav s ostatními.", + "publish_timezone": "Zveřejnit časové pásmo na veřejném profilu", "rm_lifetime": "Platnost značky přečteno (ms)", "rm_lifetime_offscreen": "Platnost značky přečteno mimo obrazovku (ms)", "room_directory_heading": "Adresář místností", @@ -2483,7 +2739,8 @@ "show_avatars_pills": "Zobrazovat avatary ve zmínkách o uživatelích, místnostech a událostech", "show_polls_button": "Zobrazit tlačítko hlasování", "surround_text": "Ohraničit označený text při psaní speciálních znaků", - "time_heading": "Zobrazování času" + "time_heading": "Zobrazování času", + "user_timezone": "Nastavit časové pásmo" }, "prompt_invite": "Potvrdit odeslání pozvánky potenciálně neplatným Matrix ID", "replace_plain_emoji": "Automaticky nahrazovat textové emoji", @@ -2514,8 +2771,11 @@ "cross_signing_self_signing_private_key": "Vlastní podpisový klíč:", "cross_signing_user_signing_private_key": "Podpisový klíč uživatele:", "cryptography_section": "Šifrování", + "dehydrated_device_description": "Funkce offline zařízení umožňuje přijímat šifrované zprávy, i když nejste přihlášeni k žádnému zařízení.", + "dehydrated_device_enabled": "Offline zařízení povoleno", "delete_backup": "Smazat zálohu", "delete_backup_confirm_description": "Opravdu? Pokud klíče nejsou správně zálohované můžete přijít o šifrované zprávy.", + "dialog_title": "Nastavení: Zabezpečení a soukromí", "e2ee_default_disabled_warning": "Správce vašeho serveru vypnul ve výchozím nastavení koncové šifrování v soukromých místnostech a přímých zprávách.", "enable_message_search": "Povolit vyhledávání v šifrovaných místnostech", "encryption_section": "Šifrování", @@ -2593,6 +2853,7 @@ "device_unverified_description_current": "Ověřte svou aktuální relaci pro vylepšené zabezpečené zasílání zpráv.", "device_verified_description": "Tato relace je připravena na bezpečné zasílání zpráv.", "device_verified_description_current": "Vaše aktuální relace je připravena pro bezpečné zasílání zpráv.", + "dialog_title": "Nastavení: Relace", "error_pusher_state": "Nepodařilo se nastavit stav push oznámení", "error_set_name": "Nepodařilo se nastavit název relace", "filter_all": "Všechny", @@ -2609,6 +2870,7 @@ "inactive_sessions_list_description": "Zvažte odhlášení ze starých relací (%(inactiveAgeDays)s dní nebo starších), které již nepoužíváte.", "ip": "IP adresa", "last_activity": "Poslední aktivita", + "manage": "Spravovat tuto relaci", "mobile_session": "Relace mobilního zařízení", "n_sessions_selected": { "one": "%(count)s vybraná relace", @@ -2632,9 +2894,10 @@ "security_recommendations_description": "Zlepšete zabezpečení svého účtu dodržováním těchto doporučení.", "session_id": "ID sezení", "show_details": "Zobrazit podrobnosti", - "sign_in_with_qr": "Přihlásit se pomocí QR kódu", + "sign_in_with_qr": "Připojit nové zařízení", "sign_in_with_qr_button": "Zobrazit QR kód", - "sign_in_with_qr_description": "Toto zařízení můžete použít k přihlášení nového zařízení pomocí QR kódu. QR kód zobrazený na tomto zařízení musíte naskenovat pomocí odhlášeného zařízení.", + "sign_in_with_qr_description": "Pomocí QR kódu se přihlaste do jiného zařízení a nastavte zabezpečené zasílání zpráv.", + "sign_in_with_qr_unsupported": "Není podporováno vaším poskytovatelem účtu", "sign_out": "Odhlásit se z této relace", "sign_out_all_other_sessions": "Odhlásit se ze všech ostatních relací (%(otherSessionsCount)s)", "sign_out_confirm_description": { @@ -2674,7 +2937,9 @@ "show_redaction_placeholder": "Zobrazovat smazané zprávy", "show_stickers_button": "Tlačítko Zobrazit nálepky", "show_typing_notifications": "Zobrazovat oznámení „... právě píše...“", + "showbold": "Zobrazit veškerou aktivitu v seznamu místnosti (body nebo počet nepřečtených zpráv).", "sidebar": { + "dialog_title": "Nastavení: Postranní panel", "metaspaces_favourites_description": "Seskupte všechny své oblíbené místnosti a osoby na jednom místě.", "metaspaces_home_all_rooms": "Zobrazit všechny místnosti", "metaspaces_home_all_rooms_description": "Zobrazit všechny místnosti v Domovu, i když jsou v prostoru.", @@ -2683,10 +2948,14 @@ "metaspaces_orphans_description": "Seskupte všechny místnosti, které nejsou součástí prostoru, na jednom místě.", "metaspaces_people_description": "Seskupte všechny své kontakty na jednom místě.", "metaspaces_subsection": "Prostory pro zobrazení", + "metaspaces_video_rooms": "Video místnosti a konference", + "metaspaces_video_rooms_description": "Seskupte všechny soukromé videomístnosti a konference.", + "metaspaces_video_rooms_description_invite_extension": "Na konference můžete pozvat i lidi mimo matrix.", "spaces_explainer": "Prostory jsou způsoby, jak seskupit místnosti a lidi. Kromě prostor, ve kterých se nacházíte, můžete použít i některé předem vytvořené.", "title": "Postranní panel" }, "start_automatically": "Zahájit automaticky po přihlášení do systému", + "tac_only_notifications": "Zobrazení oznámení pouze v centru aktivity vlákna", "use_12_hour_format": "Zobrazovat čas v 12hodinovém formátu (např. 2:30 odp.)", "use_command_enter_send_message": "K odeslání zprávy použijte Command + Enter", "use_command_f_search": "Stiskněte Command + F k vyhledávání v časové ose", @@ -2700,6 +2969,7 @@ "audio_output_empty": "Nebyly rozpoznány žádné zvukové výstupy", "auto_gain_control": "Automatická úprava zesílení", "connection_section": "Připojení", + "dialog_title": "Nastavení: Hlas a video", "echo_cancellation": "Potlačení ozvěny", "enable_fallback_ice_server": "Povolit záložní asistenční server hovorů (%(server)s)", "enable_fallback_ice_server_description": "Platí pouze v případě, že váš domovský server tuto možnost nenabízí. Vaše IP adresa bude během hovoru sdílena.", @@ -2718,6 +2988,7 @@ "warning": "UPOZORNĚNÍ: " }, "share": { + "link_copied": "Odkaz zkopírován", "permalink_message": "Odkaz na vybranou zprávu", "permalink_most_recent": "Odkaz na nejnovější zprávu", "share_call": "Odkaz na pozvánku na konferenci", @@ -2963,14 +3234,21 @@ "one": "%(count)s odpověď", "other": "%(count)s odpovědí" }, + "empty_description": "Při najetí na zprávu použijte \"%(replyInThread)s\".", + "empty_title": "Vlákna pomáhají udržovat konverzace k tématu a snadno je sledovat.", "error_start_thread_existing_relation": "Nelze založit vlákno ve vlákně", + "mark_all_read": "Označit vše jako přečtené", "my_threads": "Moje vlákna", "my_threads_description": "Zobrazit všechna vlákna, kterých jste se zúčastnili", "open_thread": "Otevřít vlákno", "show_thread_filter": "Zobrazit:" }, "threads_activity_centre": { - "header": "Aktivita vláken" + "header": "Aktivita vláken", + "no_rooms_with_threads_notifs": "Zatím nemáte k dispozici místnosti s upozorněními na vlákna.", + "no_rooms_with_unread_threads": "Zatím nemáte místnosti s nepřečtenými vlákny.", + "release_announcement_description": "Oznámení o vláknech se přesunula, od nynějška je najdete zde.", + "release_announcement_header": "Centrum aktivity vláken" }, "time": { "about_day_ago": "před jedním dnem", @@ -3013,9 +3291,21 @@ }, "creation_summary_dm": "%(creator)s vytvořil(a) tuto přímou zprávu.", "creation_summary_room": "%(creator)s vytvořil(a) a nakonfiguroval(a) místnost.", + "decryption_failure": { + "blocked": "Odesílatel vám zablokoval příjem této zprávy, protože vaše zařízení není ověřeno.", + "historical_event_no_key_backup": "Historické zprávy nejsou v tomto zařízení k dispozici", + "historical_event_unverified_device": "Pro přístup k historickým zprávám je třeba toto zařízení ověřit.", + "historical_event_user_not_joined": "K této zprávě nemáte přístup", + "sender_identity_previously_verified": "Ověřená identita se změnila", + "sender_unsigned_device": "Šifrováno zařízením, které nebylo ověřeno jeho majitelem.", + "unable_to_decrypt": "Zprávu nelze dešifrovat" + }, "disambiguated_profile": "%(displayName)s (%(matrixId)s)", "download_action_decrypting": "Dešifrování", "download_action_downloading": "Stahování", + "download_failed": "Stažení se nezdařilo", + "download_failed_description": "Při stahování tohoto souboru došlo k chybě", + "e2e_state": "Stav šifrování mezi koncovými body", "edits": { "tooltip_label": "Upraveno v %(date)s. Klinutím zobrazíte změny.", "tooltip_sub": "Klikněte pro zobrazení úprav", @@ -3069,7 +3359,7 @@ }, "m.file": { "error_decrypting": "Chyba při dešifrování přílohy", - "error_invalid": "Neplatný soubor%(extra)s" + "error_invalid": "Neplatný soubor" }, "m.image": { "error": "Obrázek nelze zobrazit kvůli chybě", @@ -3140,7 +3430,7 @@ "unknown": "%(senderDisplayName)s změnil(a) pravidlo pro přístup hostů na %(rule)s" }, "m.room.history_visibility": { - "invited": "%(senderName)s nastavil(a) viditelnost budoucích zpráv v této místnosti pro všechny její členy, a to od chvíle jejich pozvání.", + "invited": "%(senderName)s nastavil(a) viditelnost budoucích zpráv v této místnosti pro všechny její členy od chvíle jejich pozvání.", "joined": "%(senderName)s nastavil(a) viditelnost budoucích zpráv v této místnosti pro všechny její členy od chvíle jejich vstupu.", "shared": "%(senderName)s nastavil(a) viditelnost budoucích zpráv v této místnosti pro všechny její členy.", "unknown": "%(senderName)s nastavil viditelnost budoucí zpráv v místnosti neznámým (%(visibility)s).", @@ -3256,7 +3546,8 @@ "reactions": { "add_reaction_prompt": "Přidat reakci", "custom_reaction_fallback_label": "Vlastní reakce", - "label": "%(reactors)s reagoval(a) na %(content)s" + "label": "%(reactors)s reagoval(a) na %(content)s", + "tooltip_caption": "reagoval s%(shortName)s" }, "read_receipt_title": { "one": "Viděl %(count)s člověk", @@ -3441,6 +3732,10 @@ "truncated_list_n_more": { "other": "A %(count)s dalších..." }, + "unsupported_browser": { + "description": "Pokud budete pokračovat, mohou některé funkce přestat fungovat a hrozí, že v budoucnu přijdete o data. Chcete-li pokračovat v používání %(brand)s, aktualizujte svůj prohlížeč.", + "title": "%(brand)s nepodporuje tento prohlížeč" + }, "unsupported_server_description": "Tento server používá starší verzi Matrix. Chcete-li používat %(brand)s bez možných problémů, aktualizujte Matrixu na %(version)s .", "unsupported_server_title": "Váš server není podporován", "update": { @@ -3458,6 +3753,12 @@ "toast_title": "Aktualizovat %(brand)s", "unavailable": "Nedostupné" }, + "update_room_access_modal": { + "description": "Chcete-li vytvořit sdílený odkaz, musíte hostům povolit, aby se k této místnosti připojili. Může se tak stát, že místnost bude méně bezpečná. Po dokončení hovoru můžete místnost opět učinit soukromou.", + "dont_change_description": "Případně můžete hovor uskutečnit v oddělené místnosti.", + "no_change": "Nechci měnit úroveň přístupu.", + "title": "Změna úrovně přístupu do místnosti" + }, "upload_failed_generic": "Soubor '%(fileName)s' se nepodařilo nahrát.", "upload_failed_size": "Soubor '%(fileName)s' je větší než povoluje limit domovského serveru", "upload_failed_title": "Nahrávání selhalo", @@ -3467,6 +3768,7 @@ "error_files_too_large": "Tyto soubory jsou příliš velké. Limit je %(limit)s.", "error_some_files_too_large": "Některé soubory jsou příliš velké. Limit je %(limit)s.", "error_title": "Chyba při nahrávání", + "not_image": "Soubor, který jste vybrali, není platný soubor obrázku.", "title": "Nahrát soubory", "title_progress": "Nahrát soubory (%(current)s z %(total)s)", "upload_all_button": "Nahrát vše", @@ -3493,6 +3795,7 @@ "deactivate_confirm_action": "Deaktivovat uživatele", "deactivate_confirm_description": "Deaktivování uživatele ho odhlásí a zabrání mu v opětovném přihlášení. Navíc bude odstraněn ze všech místností. Akci nelze vzít zpět. Opravdu chcete uživatele deaktivovat?", "deactivate_confirm_title": "Deaktivovat uživatele?", + "dehydrated_device_enabled": "Offline zařízení povoleno", "demote_button": "Degradovat", "demote_self_confirm_description_space": "Tuto změnu nebudete moci vrátit zpět, protože budete degradováni, pokud jste posledním privilegovaným uživatelem v daném prostoru, nebude možné znovu získat oprávnění.", "demote_self_confirm_room": "Tuto změnu nebudete moci vzít zpět, protože snižujete svoji vlastní hodnost, jste-li poslední privilegovaný uživatel v místnosti, bude nemožné vaši současnou hodnost získat zpět.", @@ -3509,6 +3812,7 @@ "error_revoke_3pid_invite_title": "Pozvání se nepovedlo zrušit", "hide_sessions": "Skrýt relace", "hide_verified_sessions": "Skrýt ověřené relace", + "ignore_button": "Ignorovat", "ignore_confirm_description": "Všechny zprávy a pozvánky od tohoto uživatele budou skryty. Opravdu je chcete ignorovat?", "ignore_confirm_title": "Ignorovat %(user)s", "invited_by": "Pozván od uživatele %(sender)s", @@ -3536,23 +3840,26 @@ "no_recent_messages_description": "Zkuste posunout časovou osu nahoru, jestli tam nejsou nějaké dřívější.", "no_recent_messages_title": "Nebyly nalezeny žádné nedávné zprávy od uživatele %(user)s" }, - "redact_button": "Odstranit nedávné zprávy", + "redact_button": "Odebrat zprávy", "revoke_invite": "Zrušit pozvání", "room_encrypted": "Zprávy jsou v této místnosti koncově šifrované.", "room_encrypted_detail": "Vaše zprávy jsou zabezpečené - pouze vy a jejich příjemci máte klíče potřebné k jejich přečtení.", "room_unencrypted": "Zprávy nejsou koncově šifrované.", "room_unencrypted_detail": "V šifrovaných místnostech jsou vaše zprávy bezpečné a pouze vy a příjemce má klíče k jejich rozšifrování.", - "share_button": "Sdílet odkaz na uživatele", + "send_message": "Poslat zprávu", + "share_button": "Sdílet profil", "unban_button_room": "Zrušit vykázání z místnosti", "unban_button_space": "Zrušit vykázání z prostoru", "unban_room_confirm_title": "Zrušit vykázání z %(roomName)s", "unban_space_everything": "Zrušit jejich vykázání všude, kde mám oprávnění", "unban_space_specific": "Zrušit jejich vykázání z konkrétních míst, kde mám oprávnění", "unban_space_warning": "Nebudou mít přístup ke všemu, čeho nejste správcem.", + "unignore_button": "Zrušit ignorování", "verify_button": "Ověřit uživatele", "verify_explainer": "Pro lepší bezpečnost, ověřte uživatele zkontrolováním jednorázového kódu na vašich zařízeních." }, "user_menu": { + "link_new_device": "Připojit nové zařízení", "settings": "Všechna nastavení", "switch_theme_dark": "Přepnout do tmavého režimu", "switch_theme_light": "Přepnout do světlého režimu" @@ -3604,6 +3911,9 @@ "legacy_call": "Zastaralý způsob hovoru", "maximise": "Vyplnit obrazovku", "maximise_call": "Maximalizovat hovor", + "metaspace_video_rooms": { + "conference_room_section": "Konference" + }, "minimise_call": "Minimalizovat hovor", "misconfigured_server": "Volání selhalo, protože je rozbitá konfigurace serveru", "misconfigured_server_description": "Požádejte správce svého domovského serveru (%(homeserverDomain)s) jestli by nemohl nakonfigurovat TURN server, aby volání fungovala spolehlivě.", @@ -3761,7 +4071,7 @@ "title": "Povolte tomuto widgetu ověřit vaši identitu" }, "popout": "Otevřít widget v novém okně", - "set_room_layout": "Nastavit všem rozložení mé místnosti", + "set_room_layout": "Nastavte rozložení pro každého", "shared_data_avatar": "URL vašeho profilového obrázku", "shared_data_device_id": "ID vašeho zařízení", "shared_data_lang": "Váš jazyk", diff --git a/src/i18n/strings/de_DE.json b/src/i18n/strings/de_DE.json index ec94fb08a8..1ee56bd724 100644 --- a/src/i18n/strings/de_DE.json +++ b/src/i18n/strings/de_DE.json @@ -734,6 +734,44 @@ "category_room": "Raum", "caution_colon": "Vorsicht:", "client_versions": "Anwendungsversionen", + "crypto": { + "4s_public_key_in_account_data": "in den Kontodaten", + "4s_public_key_not_in_account_data": "nicht gefunden", + "4s_public_key_status": "Öffentlicher Schlüssel des geheimen Speichers:", + "backup_key_cached": "lokal zwischengespeichert", + "backup_key_cached_status": "Backup-Schlüssel zwischengespeichert:", + "backup_key_not_stored": "nicht gespeichert", + "backup_key_stored": "im geheimen Speicher", + "backup_key_stored_status": "Backup-Schlüssel gespeichert:", + "backup_key_unexpected_type": "unerwarteter Typ", + "backup_key_well_formed": "wohlgeformt", + "cross_signing": "Kreuzsignatur", + "cross_signing_cached": "lokal zwischengespeichert", + "cross_signing_not_ready": "Kreuzsignatur ist nicht eingerichtet.", + "cross_signing_private_keys_in_storage": "im geheimen Speicher", + "cross_signing_private_keys_in_storage_status": "Überkreuzsignierung privater Schlüssel:", + "cross_signing_private_keys_not_in_storage": "nicht gefunden im Speicher", + "cross_signing_public_keys_on_device": "im Speicher", + "cross_signing_public_keys_on_device_status": "Überkreuzsignierung öffentlicher Schlüssel:", + "cross_signing_ready": "Kreuzsignatur ist einsatzbereit.", + "cross_signing_status": "Status der Kreuzsignatur", + "cross_signing_untrusted": "Ihr Konto verfügt über eine Cross-Signing-Identität im geheimen Speicher, diese wird von dieser Sitzung jedoch noch nicht als vertrauenswürdig eingestuft.", + "crypto_not_available": "Das kryptografische Modul ist nicht verfügbar", + "key_backup_active_version": "Aktive Backup Version:", + "key_backup_active_version_none": "Keine", + "key_backup_inactive_warning": "Für die Schlüssel dieser Session gibt es kein Backup", + "key_backup_latest_version": "Aktuelle Backup-Version auf dem Server:", + "key_storage": "Schlüsselspeicher", + "master_private_key_cached_status": "Privater Hauptschlüssel:", + "not_found": "nicht gefunden", + "not_found_locally": "nicht lokal gefunden", + "secret_storage_not_ready": "nicht bereit", + "secret_storage_ready": "bereit", + "secret_storage_status": "Geheimer Speicher:", + "self_signing_private_key_cached_status": "Selbstsignierender privater Schlüssel:", + "title": "Ende-zu-Ende Verschlüsselung", + "user_signing_private_key_cached_status": "Privater Schlüssel zur Benutzersignatur:" + }, "developer_mode": "Entwicklungsmodus", "developer_tools": "Entwicklungswerkzeuge", "edit_setting": "Einstellung bearbeiten", @@ -2421,6 +2459,24 @@ "enable_markdown": "Markdown aktivieren", "enable_markdown_description": "Beginne Nachrichten mit /plain, um sie ohne Markdown zu senden.", "encryption": { + "advanced": { + "breadcrumb_first_description": "Ihre Kontodaten, Kontakte, Einstellungen und Chat-Liste werden gespeichert", + "breadcrumb_page": "Verschlüsselung zurücksetzen", + "breadcrumb_second_description": "Sie verlieren jeglichen Nachrichtenverlauf, der nur auf dem Server gespeichert ist", + "breadcrumb_third_description": "Sie müssen alle Ihre vorhandenen Geräte und Kontakte erneut verifizieren", + "breadcrumb_title": "Sind Sie sicher, dass Sie Ihre Identität zurücksetzen möchten?", + "breadcrumb_warning": "Tun Sie dies nur, wenn Sie glauben, dass Ihr Konto kompromittiert wurde.", + "details_title": "Angaben zur Verschlüsselung", + "export_keys": "Schlüssel exportieren", + "import_keys": "Schlüssel importieren", + "other_people_device_description": "Senden Sie in verschlüsselten Räumen standardmäßig keine verschlüsselten Nachrichten an Dritte, bis Sie diese verifiziert haben", + "other_people_device_label": "Senden Sie niemals verschlüsselte Nachrichten an nicht verifizierte Geräte", + "other_people_device_title": "Geräte anderer Personen", + "reset_identity": "Kryptografische Identität zurücksetzen", + "session_id": "Sitzungs-ID:", + "session_key": "Sitzungsschlüssel:", + "title": "Advanced" + }, "device_not_verified_button": "Dieses Gerät verifizieren", "device_not_verified_description": "Sie müssen dieses Gerät verifizieren, um Ihre Verschlüsselungseinstellungen einsehen zu können.", "device_not_verified_title": "Gerät nicht verifiziert", diff --git a/src/i18n/strings/el.json b/src/i18n/strings/el.json index 1aca5d0776..3bef29313f 100644 --- a/src/i18n/strings/el.json +++ b/src/i18n/strings/el.json @@ -19,7 +19,9 @@ "add": "Προσθήκη", "add_existing_room": "Προσθήκη υπάρχοντος δωματίου", "add_people": "Προσθήκη ατόμων", + "apply": "Εφαρμογή", "approve": "Έγκριση", + "ask_to_join": "Αίτημα συμμετοχής", "back": "Πίσω", "call": "Κλήση", "cancel": "Ακύρωση", @@ -39,6 +41,7 @@ "create_account": "Δημιουργία Λογαριασμού", "decline": "Απόρριψη", "delete": "Διαγραφή", + "deny": "Άρνηση", "disable": "Απενεργοποίηση", "disconnect": "Αποσύνδεση", "dismiss": "Απόρριψη", @@ -79,11 +82,13 @@ "pause": "Παύση", "pin": "Καρφίτσα", "play": "Αναπαραγωγή", + "proceed": "Συνέχεια", "quote": "Παράθεση", "react": "Αντίδραση", "refresh": "Ανανέωση", "register": "Εγγραφή", "reject": "Απόρριψη", + "reload": "Επαναφόρτωση", "remove": "Αφαίρεση", "rename": "Μετονομασία", "reply": "Απάντηση", @@ -99,6 +104,7 @@ "search": "Αναζήτηση", "send_report": "Αποστολή αναφοράς", "share": "Διαμοιρασμός", + "show": "Εμφάνιση", "show_advanced": "Εμφάνιση προχωρημένων", "show_all": "Εμφάνιση όλων", "sign_in": "Σύνδεση", @@ -122,6 +128,7 @@ "upload": "Μεταφόρτωση", "verify": "Επαλήθευση", "view": "Προβολή", + "view_all": "Προβολή Όλων", "view_list": "Προβολή λίστας", "view_message": "Προβολή μηνύματος", "view_source": "Προβολή κώδικα", @@ -353,9 +360,11 @@ "other": "και %(count)s άλλοι..." }, "appearance": "Εμφάνιση", + "application": "Εφαρμογή", "are_you_sure": "Είστε σίγουροι;", "attachment": "Επισύναψη", "authentication": "Πιστοποίηση", + "beta": "Beta", "camera": "Κάμερα", "cameras": "Κάμερες", "capabilities": "Δυνατότητες", @@ -365,6 +374,7 @@ "dark": "Σκούρο", "description": "Περιγραφή", "deselect_all": "Αποεπιλογή όλων", + "device": "Συσκευή", "edited": "επεξεργάστηκε", "email_address": "Ηλεκτρονική διεύθυνση", "emoji": "Εικονίδια", @@ -390,11 +400,14 @@ "labs": "Πειραματικά", "legal": "Νομικό", "light": "Ανοιχτό", + "loading": "Φόρτωση...", "location": "Τοποθεσία", "low_priority": "Χαμηλής προτεραιότητας", + "matrix": "Matrix", "message": "Μήνυμα", "message_layout": "Διάταξη μηνύματος", "microphone": "Μικρόφωνο", + "model": "Μοντέλο", "modern": "Μοντέρνο", "mute": "Σίγαση", "n_members": { @@ -417,6 +430,7 @@ "password": "Κωδικός πρόσβασης", "people": "Άτομα", "preferences": "Προτιμήσεις", + "presence": "Παρουσία", "preview_message": "Είσαι ο καλύτερος!", "privacy": "Ιδιωτικότητα", "private": "Ιδιωτικό", @@ -458,9 +472,13 @@ "unmute": "Άρση σίγασης", "unnamed_room": "Ανώνυμο δωμάτιο", "unnamed_space": "Χώρος χωρίς όνομα", + "unverified": "Μη επαληθευμένη", + "user": "Χρήστης", "user_avatar": "Εικόνα προφίλ", "username": "Όνομα χρήστη", "verification_cancelled": "Η επαλήθευση ακυρώθηκε", + "verified": "Επαληθευμένη", + "version": "Έκδοση", "video": "Βίντεο", "video_room": "Δωμάτια βίντεο", "view_message": "Προβολή μηνύματος", @@ -483,10 +501,17 @@ "edit_composer_label": "Επεξεργασία μηνύματος", "format_bold": "Έντονα", "format_code_block": "Μπλοκ κώδικα", + "format_decrease_indent": "Μείωση εσοχής", + "format_increase_indent": "Αύξηση εσοχής", "format_inline_code": "Κωδικός", "format_insert_link": "Εισαγωγή συνδέσμου", + "format_italic": "Πλάγια", "format_italics": "Πλάγια", + "format_link": "Σύνδεσμος", + "format_ordered_list": "Αριθμημένη λίστα", "format_strikethrough": "Διαγράμμιση", + "format_underline": "Υπογράμμιση", + "format_unordered_list": "Λίστα με κουκκκίδες", "no_perms_notice": "Δεν έχετε δικαιώματα για να δημοσιεύσετε σε αυτό το δωμάτιο", "placeholder": "Στείλτε ένα μήνυμα…", "placeholder_encrypted": "Αποστολή κρυπτογραφημένου μηνύματος…", @@ -579,6 +604,11 @@ "subspace_join_rule_public_description": "Οποιοσδήποτε θα μπορεί να βρει και να εγγραφεί σε αυτόν τον χώρο, όχι μόνο μέλη του .", "subspace_join_rule_restricted_description": "Οποιοσδήποτε στο θα μπορεί να βρει και να συμμετάσχει σε αυτό το δωμάτιο." }, + "credits": { + "default_cover_photo": "Η προεπιλεγμένη φωτογραφία εξωφύλλου είναι ο © Jesús Roncero που χρησιμοποιείται σύμφωνα με τους όρους του CC-BY-SA 4.0.", + "twemoji": "Η τέχνη εμότζι Twemoji είναι © Twitter, Inc and other contributors υπό τους όρους του CC-BY 4.0.", + "twemoji_colr": "Η γραμματοσειρά twemoji-colr είναι του © Ιδρύματος Mozilla που χρησιμοποιείται σύμφωνα με τους όρους του Apache 2.0." + }, "devtools": { "active_widgets": "Ενεργές Μικροεφαρμογές", "category_other": "Άλλα", @@ -589,6 +619,7 @@ "developer_tools": "Εργαλεία προγραμματιστή", "edit_setting": "Επεξεργασία ρύθμισης", "edit_values": "Επεξεργασία τιμών", + "empty_string": "", "event_content": "Περιεχόμενο συμβάντος", "event_id": "ID συμβάντος: %(eventId)s", "event_sent": "Το συμβάν στάλθηκε!", @@ -600,12 +631,32 @@ "failed_to_load": "Αποτυχία φόρτωσης.", "failed_to_save": "Αποτυχία αποθήκευσης ρυθμίσεων.", "failed_to_send": "Αποτυχία αποστολής συμβάντος!", + "id": "ID: ", "invalid_json": "Δε μοιάζει με έγκυρο JSON.", "level": "Επίπεδο", + "main_timeline": "Κύριο χρονοδιάγραμμα", + "no_receipt_found": "Δεν βρέθηκε απόδειξη", + "notification_state": "Η κατάσταση ειδοποίησης είναι %(notificationState)s", + "notifications_debug": "Αποσφαλμάτωση ειδοποιήσεων", "number_of_users": "Αριθμός χρηστών", "original_event_source": "Αρχική πηγή συμβάντος", + "room_encrypted": "Το δωμάτιο είναι κρυπτογραφημένο ✅", "room_id": "ID δωματίου: %(roomId)s", + "room_not_encrypted": "Το δωμάτιο δεν είναι κρυπτογραφημένο 🚨", + "room_notifications_dot": "Σημείο: ", + "room_notifications_highlight": "Αποκορύφωμα: ", + "room_notifications_last_event": "Τελευταίο γεγονός:", + "room_notifications_sender": "Αποστολέας: ", + "room_notifications_thread_id": "Αναγνωριστικό νήματος: ", + "room_notifications_total": "Σύνολο: ", + "room_notifications_type": "Τύπος: ", + "room_status": "Κατάσταση δωματίου", + "room_unread_status_count": { + "one": "Κατάσταση μη αναγνωσμένων δωματίου: %(status)s, πλήθος: %(count)s", + "other": "Κατάσταση μη αναγνωσμένων δωματίου: %(status)s, πλήθος: %(count)s" + }, "save_setting_values": "Αποθήκευση τιμών ρύθμισης", + "see_history": "Εμφάνιση ιστορικού", "send_custom_account_data_event": "Αποστολή προσαρμοσμένου συμβάντος δεδομένων λογαριασμού", "send_custom_room_account_data_event": "Αποστολή προσαρμοσμένου συμβάντος δεδομένων λογαριασμού δωματίου", "send_custom_state_event": "Αποστολή προσαρμοσμένου συμβάντος κατάστασης", @@ -624,10 +675,16 @@ "other": "<%(count)s χώροι>" }, "state_key": "Κλειδί κατάστασης", + "thread_root_id": "Thread Root ID: %(threadRootId)s", + "threads_timeline": "Χρονοδιάγραμμα νημάτων", "title": "Εργαλεία προγραμματιστή", "toggle_event": "μεταβολή συμβάντος", "toolbox": "Εργαλειοθήκη", "use_at_own_risk": "Αυτό το UI ΔΕΝ ελέγχει τους τύπους των τιμών. Χρησιμοποιήστε το με δική σας ευθύνη.", + "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): ", "value": "Τιμή", "value_colon": "Τιμή:", "value_in_this_room": "Τιμή σε αυτό το δωμάτιο", @@ -770,6 +827,7 @@ "prompt_self": "Ξεκινήστε ξανά την επαλήθευση από την ειδοποίηση.", "prompt_unencrypted": "Σε κρυπτογραφημένα δωμάτια, επαληθεύστε όλους τους χρήστες για να βεβαιωθείτε ότι είναι ασφαλές.", "prompt_user": "Ξεκινήστε ξανά την επαλήθευση από το προφίλ τους.", + "qr_or_sas": "%(qrCode)s ή %(emojiCompare)s", "qr_or_sas_header": "Επαληθεύστε αυτήν τη συσκευή συμπληρώνοντας ένα από τα παρακάτω:", "qr_prompt": "Σαρώστε αυτόν τον μοναδικό κωδικό", "qr_reciprocate_same_shield_device": "Σχεδόν έτοιμοι! Εμφανίζεται η ίδια ασπίδα και στην άλλη συσκευή σας;", @@ -882,12 +940,21 @@ "dm_send": "Αναμονή απάντησης", "user": "Ο %(senderName)s ξεκίνησε μια κλήση", "you": "Ξεκινήσατε μία κλήση" - } + }, + "m.emote": "* %(senderName)s %(emote)s", + "m.reaction": { + "user": "Ο χρήστης %(sender)s αντέδρασε με %(reaction)s στο %(message)s", + "you": "Αντέδρασες με %(reaction)s στο %(message)s" + }, + "m.sticker": "%(senderName)s: %(stickerName)s", + "m.text": "%(senderName)s: %(message)s" }, "export_chat": { "cancelled": "Η Εξαγωγή ακυρώθηκε", "cancelled_detail": "Η εξαγωγή ακυρώθηκε με επιτυχία", "confirm_stop": "Είστε βέβαιοι ότι θέλετε να διακόψετε την εξαγωγή των δεδομένων σας; Εάν το κάνετε, θα πρέπει να ξεκινήσετε από την αρχή.", + "creating_html": "Δημιουργία HTML...", + "creating_output": "Δημιουργία εξόδου...", "creator_summary": "Ο %(creatorName)s δημιούργησε αυτό το δωμάτιο.", "current_timeline": "Τρέχον χρονοδιάγραμμα", "enter_number_between_min_max": "Εισαγάγετε έναν αριθμό μεταξύ %(min)s και %(max)s", @@ -911,21 +978,28 @@ "one": "Ανακτήθηκαν %(count)s συμβάντα από %(total)s", "other": "Ανακτήθηκαν %(count)s συμβάντα από %(total)s" }, + "fetching_events": "Ανάκτηση συμβάντων...", "file_attached": "Tο αρχείο επισυνάφθηκε", "format": "Μορφή", "from_the_beginning": "Από την αρχή", "generating_zip": "Δημιουργία ZIP", + "html": "HTML", + "html_title": "Εξαγμένα δεδομένα", "include_attachments": "Συμπεριλάβετε Συνημμένα", + "json": "JSON", "media_omitted": "Τα μέσα παραλείφθηκαν", "media_omitted_file_size": "Τα μέσα παραλείφθηκαν - υπέρβαση του ορίου μεγέθους αρχείου", "messages": "Μηνύματα", + "next_page": "Επόμενη ομάδα μηνυμάτων", "num_messages": "Αριθμός μηνυμάτων", "num_messages_min_max": "Ο αριθμός των μηνυμάτων μπορεί να είναι μόνο ένας αριθμός μεταξύ %(min)s και %(max)s", "number_of_messages": "Καθορίστε έναν αριθμό μηνυμάτων", + "previous_page": "Προηγούμενη ομάδα μηνυμάτων", "processing_event_n": "Επεξεργασία συμβάντος %(number)s από %(total)s", "select_option": "Επιλέξτε από τις παρακάτω επιλογές για να εξαγάγετε συνομιλίες από το χρονολόγιό σας", "size_limit": "Όριο Μεγέθους", "size_limit_min_max": "Το μέγεθος μπορεί να είναι μόνο ένας αριθμός μεταξύ %(min)s MB και %(max)s MB", + "starting_export": "Έναρξη εξαγωγής...", "successful": "Επιτυχής Εξαγωγή", "successful_detail": "Η εξαγωγή σας ήταν επιτυχής. Βρείτε τη στο φάκελο Λήψεις.", "text": "Απλό κείμενο", @@ -1057,10 +1131,12 @@ }, "keyboard": { "activate_button": "Ενεργοποίηση επιλεγμένου κουμπιού", + "alt": "Alt", "autocomplete_cancel": "Ακύρωση αυτόματης συμπλήρωσης", "autocomplete_force": "Εξαναγκασμός ολοκλήρωσης", "autocomplete_navigate_next": "Επόμενη πρόταση αυτόματης συμπλήρωσης", "autocomplete_navigate_prev": "Προηγούμενη πρόταση αυτόματης συμπλήρωσης", + "backspace": "Πίσω διάστημα", "cancel_reply": "Ακύρωση απάντησης σε μήνυμα", "category_autocomplete": "Αυτόματη συμπλήρωση", "category_calls": "Κλήσεις", @@ -1079,7 +1155,11 @@ "composer_toggle_link": "Σύνδεσμος", "composer_toggle_quote": "Εναλλαγή Παράθεσης", "composer_undo": "Αναίρεση επεξεργασίας", + "control": "Ctrl", "dismiss_read_marker_and_jump_bottom": "Παραβλέψτε το δείκτη ανάγνωσης και μεταβείτε στο τέλος", + "end": "Τέλος", + "enter": "Enter", + "escape": "Esc", "go_home_view": "Μεταβείτε στην Αρχική προβολή", "home": "Αρχική", "jump_first_message": "Μετάβαση στο πρώτο μήνυμα", @@ -1095,6 +1175,8 @@ "next_unread_room": "Επόμενο μη αναγνωσμένο δωμάτιο ή ΑΜ", "number": "[αριθμός]", "open_user_settings": "Άνοιγμα ρυθμίσεων χρήστη", + "page_down": "Σελίδα προς τα κάτω", + "page_up": "Σελίδα προς τα πάνω", "prev_room": "Προηγούμενο δωμάτιο ή ΑΜ", "prev_unread_room": "Προηγούμενο μη αναγνωσμένο δωμάτιο ή ΑΜ", "room_list_collapse_section": "Σύμπτυξη ενότητας λίστας δωματίων", @@ -1106,6 +1188,7 @@ "scroll_up_timeline": "Κύλιση προς τα πάνω στη γραμμή χρόνου", "search": "Αναζήτηση (πρέπει να είναι ενεργοποιημένη)", "send_sticker": "Αποστολή αυτοκόλλητου", + "shift": "Shift", "space": "Χώρος", "switch_to_space": "Εναλλαγή σε χώρο με αριθμό", "toggle_hidden_events": "Εναλλαγή ορατότητας κρυφού συμβάντος", @@ -1117,6 +1200,8 @@ "upload_file": "Μεταφόρτωση αρχείου" }, "labs": { + "allow_screen_share_only_mode": "Να επιτρέπεται μόνο η λειτουργία κοινής χρήσης οθόνης", + "ask_to_join": "Ενεργοποίηση αίτησης συμμετοχής", "automatic_debug_logs": "Αυτόματη αποστολή αρχείων καταγραφής εντοπισμού σφαλμάτων για οποιοδήποτε σφάλμα", "automatic_debug_logs_decryption": "Αυτόματη αποστολή αρχείων καταγραφής εντοπισμού σφαλμάτων για σφάλματα αποκρυπτογράφησης", "automatic_debug_logs_key_backup": "Αυτόματη αποστολή αρχείων καταγραφής εντοπισμού σφαλμάτων όταν η δημιουργία αντίγραφου κλειδιού ασφαλείας δεν λειτουργεί", @@ -1128,7 +1213,12 @@ "bridge_state_manager": "Αυτή τη γέφυρα τη διαχειρίζεται ο .", "bridge_state_workspace": "Χώρος εργασίας: ", "click_for_info": "Κλικ για περισσότερες πληροφορίες", + "currently_experimental": "Προς το παρόν πειραματικό.", "custom_themes": "Υποστήριξη προσθήκης προσαρμοσμένων θεμάτων", + "dynamic_room_predecessors": "Δυναμικοί προκάτοχοι δωματίων", + "element_call_video_rooms": "Δωμάτια βίντεο κλήσεων Element", + "feature_wysiwyg_composer_description": "Χρήση εμπλουτισμένου κειμένου αντί για Markdown στον συντάκτη μηνυμάτων.", + "group_calls": "Νέα εμπειρία ομαδικής κλήσης", "group_developer": "Προγραμματιστής", "group_encryption": "Κρυπτογράφηση", "group_experimental": "Πειραματικό", @@ -1141,18 +1231,31 @@ "group_threads": "Νήματα", "group_voip": "Φωνή & Βίντεο", "group_widgets": "Μικροεφαρμογές", + "hidebold": "Απόκρυψη κουκκίδας ειδοποίησης (εμφάνιση μόνο σημάτων αριθμών)", + "html_topic": "Εμφάνιση HTML αναπαράστασης θεμάτων δωματίου", "join_beta": "Συμμετοχή στη beta", "jump_to_date": "Μετάβαση στην ημερομηνία (προσθέτει /μετάβαση στην ημερομηνία και μετάβαση στις κεφαλίδες ημερομηνίας)", + "jump_to_date_msc_support": "Απαιτεί ο διακομιστής σου να υποστηρίζει το MSC3030", "latex_maths": "Εμφανίστε μαθηματικά LaTeX σε μηνύματα", "leave_beta": "Αποχώρηση από τη beta", + "location_share_live": "Κοινή χρήση τρέχουσας τοποθεσίας", + "location_share_live_description": "Προσωρινή υλοποίηση. Οι τοποθεσίες παραμένουν στο ιστορικό δωματίων.", + "mjolnir": "Νέοι τρόποι να αγνοείς τους ανθρώπους", "msc3531_hide_messages_pending_moderation": "Επιτρέψτε στους επόπτες να αποκρύψουν μηνύματα που βρίσκονται σε εκκρεμότητα.", + "notification_settings": "Νέες Ρυθμίσεις Ειδοποιήσεων", + "report_to_moderators": "Αναφορά στους συντονιστές", + "sliding_sync": "Λειτουργία Sliding Sync", + "sliding_sync_description": "Υπό ενεργή ανάπτυξη, δεν μπορεί να απενεργοποιηθεί.", + "under_active_development": "Υπό ενεργή ανάπτυξη.", "video_rooms": "Δωμάτια βίντεο", "video_rooms_a_new_way_to_chat": "Ένας νέος τρόπος για συνομιλία μέσω φωνής και βίντεο με το %(brand)s.", + "video_rooms_always_on_voip_channels": "Οι αίθουσες βίντεο είναι πάντα-ενεργά κανάλια VoIP ενσωματωμένα σε ένα δωμάτιο στο %(brand)s", "video_rooms_beta": "Οι αίθουσες βίντεο είναι μια λειτουργία beta", "video_rooms_faq1_answer": "Χρησιμοποιήστε το κουμπί “+” στην ενότητα δωματίων του αριστερού πάνελ.", "video_rooms_faq1_question": "Πώς μπορώ να δημιουργήσω ένα δωμάτιο βίντεο;", "video_rooms_faq2_answer": "Ναι, το χρονοδιάγραμμα της συνομιλίας εμφανίζεται δίπλα στο βίντεο.", - "video_rooms_faq2_question": "Μπορώ να χρησιμοποιήσω τη συνομιλία κειμένου παράλληλα με τη βιντεοκλήση;" + "video_rooms_faq2_question": "Μπορώ να χρησιμοποιήσω τη συνομιλία κειμένου παράλληλα με τη βιντεοκλήση;", + "wysiwyg_composer": "Συντάκτης εμπλουτισμένου κειμένου" }, "labs_mjolnir": { "advanced_warning": "⚠ Αυτές οι ρυθμίσεις προορίζονται για προχωρημένους χρήστες.", @@ -1694,6 +1797,8 @@ "events_default": "Στείλτε μηνύματα", "invite": "Προσκαλέστε χρήστες", "kick": "Καταργήστε χρήστες", + "m.call": "Έναρξη κλήσεων %(brand)s", + "m.call.member": "Συμμετοχή σε κλήσεις %(brand)s", "m.reaction": "Στείλτε αντιδράσεις", "m.room.avatar": "Αλλαγή εικόνας δωματίου", "m.room.avatar_space": "Αλλαγή εικόνας Χώρου", @@ -1858,7 +1963,7 @@ "custom_font_description": "Ορίστε το όνομα μιας γραμματοσειράς που είναι εγκατεστημένη στο σύστημά σας και o %(brand)s θα προσπαθήσει να τη χρησιμοποιήσει.", "custom_font_name": "Όνομα γραμματοσειράς συστήματος", "custom_font_size": "Χρησιμοποιήστε προσαρμοσμένο μέγεθος", - "custom_theme_error_downloading": "Σφάλμα κατά τη λήψη πληροφοριών θέματος.", + "custom_theme_error_downloading": "Σφάλμα κατά τη λήψη θέματος.", "custom_theme_invalid": "Μη έγκυρο σχήμα θέματος.", "font_size": "Μέγεθος γραμματοσειράς", "image_size_default": "Προεπιλογή", @@ -1874,8 +1979,10 @@ "big_emoji": "Ενεργοποίηση μεγάλων emoji στη συνομιλία", "code_block_expand_default": "Αναπτύξτε τα μπλοκ κώδικα από προεπιλογή", "code_block_line_numbers": "Εμφάνιση αριθμών γραμμής σε μπλοκ κώδικα", + "disable_historical_profile": "Εμφάνιση τρέχουσας εικόνας προφίλ και ονόματος για χρήστες στο ιστορικό μηνυμάτων", "emoji_autocomplete": "Ενεργοποιήστε τις προτάσεις Emoji κατά την πληκτρολόγηση", "enable_markdown": "Ενεργοποίηση Markdown", + "enable_markdown_description": "Έναρξη μηνυμάτων με /plain για αποστολή χωρίς markdown.", "general": { "account_management_section": "Διαχείριση λογαριασμών", "account_section": "Λογαριασμός", @@ -1887,6 +1994,7 @@ "add_msisdn_confirm_sso_button": "Επιβεβαιώστε την προσθήκη αυτού του αριθμού τηλεφώνου με την χρήση Single Sign On για να επικυρώσετε την ταυτότητα σας.", "add_msisdn_dialog_title": "Προσθήκη Τηλεφωνικού Αριθμού", "add_msisdn_instructions": "Ένα μήνυμα sms έχει σταλεί στο +%(msisdn)s. Παρακαλώ εισαγάγετε τον κωδικό επαλήθευσης που περιέχει.", + "add_msisdn_misconfigured": "Η ροή προσθήκης / δέσμευσης με MSISDN δεν έχει ρυθμιστεί σωστά", "confirm_adding_email_body": "Πιέστε το κουμπί από κάτω για να επιβεβαιώσετε την προσθήκη της διεύθυνσης ηλ. ταχυδρομείου.", "confirm_adding_email_title": "Επιβεβαιώστε την προσθήκη διεύθυνσης ηλ. ταχυδρομείου", "deactivate_confirm_body": "Είστε βέβαιοι ότι θέλετε να απενεργοποιήσετε τον λογαριασμό σας; Αυτό είναι μη αναστρέψιμο.", @@ -1896,12 +2004,15 @@ "deactivate_confirm_content_2": "Δεν θα μπορείτε πλέον να συνδεθείτε", "deactivate_confirm_content_3": "Κανείς δε θα μπορεί να επαναχρησιμοποιήσει το όνομα χρήστη σας (MXID), συμπεριλαμβανομένου εσάς: αυτό το όνομα χρήστη θα παραμείνει μη διαθέσιμο", "deactivate_confirm_content_4": "Θα αποχωρήσετε από όλα τα δωμάτια και τις συνομιλίες σας", + "deactivate_confirm_content_5": "Θα αφαιρεθείς από τον διακομιστή ταυτότητας: οι φίλοι σου δεν θα μπορούν πλέον να σε βρίσκουν με το email ή τον αριθμό τηλεφώνου σου", + "deactivate_confirm_content_6": "Τα παλιά σου μηνύματα θα εξακολουθούν να είναι ορατά σε άτομα που τα έλαβαν, όπως τα email που έστειλες στο παρελθόν. Θα 'θελες να αποκρύψεις τα απεσταλμένα σου μηνύματα από άτομα που συμμετέχουν στα δωμάτια στο μέλλον;", "deactivate_confirm_continue": "Επιβεβαίωση απενεργοποίησης λογαριασμού", + "deactivate_confirm_erase_label": "Απόκρυψη των μηνυμάτων μου από νέους συμμετέχοντες", "deactivate_section": "Απενεργοποίηση λογαριασμού", "deactivate_warning": "Η απενεργοποίηση του λογαριασμού σας είναι μια μόνιμη ενέργεια — να είστε προσεκτικοί!", - "discovery_email_empty": "Οι επιλογές εντοπισμού θα εμφανιστούν μόλις προσθέσετε ένα email παραπάνω.", + "discovery_email_empty": "Οι επιλογές εντοπισμού θα εμφανιστούν μόλις προσθέσεις ένα email παραπάνω.", "discovery_email_verification_instructions": "Επαληθεύστε τον σύνδεσμο στα εισερχόμενα σας", - "discovery_msisdn_empty": "Οι επιλογές εντοπισμού θα εμφανιστούν μόλις προσθέσετε έναν αριθμό τηλεφώνου παραπάνω.", + "discovery_msisdn_empty": "Οι επιλογές ανακάλυψης θα εμφανιστούν μόλις προσθέσεις έναν αριθμό τηλεφώνου.", "discovery_needs_terms": "Αποδεχτείτε τους Όρους χρήσης του διακομιστή ταυτότητας (%(serverName)s), ώστε να μπορείτε να είστε ανιχνεύσιμοι μέσω της διεύθυνσης ηλεκτρονικού ταχυδρομείου ή του αριθμού τηλεφώνου.", "email_address_in_use": "Η διεύθυνση ηλ. αλληλογραφίας χρησιμοποιείται ήδη", "email_address_label": "Διεύθυνση Email", @@ -1917,20 +2028,28 @@ "error_invalid_email_detail": "Δεν μοιάζει με μια έγκυρη διεύθυνση ηλεκτρονικής αλληλογραφίας", "error_msisdn_verification": "Αδυναμία επαλήθευσης του αριθμού τηλεφώνου.", "error_password_change_403": "Δεν ήταν δυνατή η αλλαγή του κωδικού πρόσβασης. Είναι σωστός ο κωδικός πρόσβασης;", + "error_password_change_http": "%(errorMessage)s (Κατάσταση HTTP %(httpStatus)s)", + "error_password_change_title": "Σφάλμα αλλαγής κωδικού πρόσβασης", + "error_password_change_unknown": "Άγνωστο σφάλμα αλλαγής κωδικού πρόσβασης (%(stringifiedError)s)", "error_remove_3pid": "Αδυναμία αφαίρεσης πληροφοριών επαφής", "error_revoke_email_discovery": "Δεν είναι δυνατή η ανάκληση της κοινής χρήσης για τη διεύθυνση ηλεκτρονικού ταχυδρομείου", "error_revoke_msisdn_discovery": "Αδυναμία ανάκληση της κοινής χρήσης για τον αριθμό τηλεφώνου", "error_share_email_discovery": "Δεν είναι δυνατή η κοινή χρήση της διεύθυνσης email", "error_share_msisdn_discovery": "Αδυναμία κοινής χρήσης του αριθμού τηλεφώνου", - "language_section": "Γλώσσα και περιοχή", + "identity_server_no_token": "Δεν βρέθηκε διακριτικό πρόσβασης ταυτότητας", + "identity_server_not_set": "Ο διακομιστής ταυτότητας δεν έχει οριστεί", + "language_section": "Γλώσσα", "msisdn_in_use": "Αυτός ο αριθμός τηλεφώνου είναι ήδη σε χρήση", "msisdn_label": "Αριθμός Τηλεφώνου", "msisdn_verification_field_label": "Κωδικός επαλήθευσης", "msisdn_verification_instructions": "Εισαγάγετε τον κωδικό επαλήθευσης που εστάλη μέσω μηνύματος sms.", "msisdns_heading": "Τηλεφωνικοί αριθμοί", + "oidc_manage_button": "Διαχείριση λογαριασμού", + "password_change_section": "Ορίστε έναν νέο κωδικό πρόσβασης λογαριασμού...", "password_change_success": "Ο κωδικός πρόσβασης σας άλλαξε με επιτυχία.", "remove_email_prompt": "Κατάργηση %(email)s;", - "remove_msisdn_prompt": "Κατάργηση %(phone)s;" + "remove_msisdn_prompt": "Κατάργηση %(phone)s;", + "spell_check_locale_placeholder": "Επιλογή τοπικών ρυθμίσεων" }, "image_thumbnails": "Εμφάνιση προεπισκοπήσεων/μικρογραφιών για εικόνες", "inline_url_previews_default": "Ενεργοποιήστε τις ενσωματωμένες προεπισκοπήσεις URL από προεπιλογή", @@ -1982,18 +2101,46 @@ "title": "Πληκτρολόγιο" }, "notifications": { + "default_setting_description": "Αυτή η ρύθμιση θα εφαρμοστεί από προεπιλογή σε όλα τα δωμάτιά σου.", + "default_setting_section": "Θέλω να ειδοποιούμαι για (Προεπιλεγμένη Ρύθμιση)", + "desktop_notification_message_preview": "Εμφάνιση προεπισκόπησης μηνύματος στην ειδοποίηση επιφάνειας εργασίας", + "email_description": "Λάβε μια περίληψη των αναπάντητων ειδοποιήσεων μέσω email", + "email_section": "Σύνοψη email", + "email_select": "Επέλεξε σε ποια email θες να στείλεις περιλήψεις. Διαχειρίσου τα email σου στα .", "enable_audible_notifications_session": "Ενεργοποιήστε τις ηχητικές ειδοποιήσεις για αυτήν τη συνεδρία", "enable_desktop_notifications_session": "Ενεργοποιήστε τις ειδοποιήσεις στον υπολογιστή για αυτήν τη συνεδρία", "enable_email_notifications": "Ενεργοποίηση ειδοποιήσεων email για %(email)s", + "enable_notifications_account": "Ενεργοποίηση ειδοποιήσεων για αυτόν τον λογαριασμό", + "enable_notifications_account_detail": "Απενεργοποίησε για να απενεργοποιήσεις τις ειδοποιήσεις σε όλες τις συσκευές και συνεδρίες σου", + "enable_notifications_device": "Ενεργοποίηση ειδοποιήσεων για αυτήν τη συσκευή", "error_loading": "Παρουσιάστηκε σφάλμα κατά τη φόρτωση των ρυθμίσεων ειδοποιήσεων σας.", "error_permissions_denied": "Το %(brand)s δεν έχει δικαιώματα για αποστολή ειδοποιήσεων - παρακαλούμε ελέγξτε τις ρυθμίσεις του περιηγητή σας", "error_permissions_missing": "Δεν δόθηκαν δικαιώματα αποστολής ειδοποιήσεων στο %(brand)s - παρακαλούμε προσπαθήστε ξανά", "error_saving": "Σφάλμα κατά την αποθήκευση των προτιμήσεων ειδοποιήσεων", "error_saving_detail": "Παρουσιάστηκε σφάλμα κατά την αποθήκευση των προτιμήσεων ειδοποίησης.", "error_title": "Αδυναμία ενεργοποίησης των ειδοποιήσεων", + "error_updating": "Παρουσιάστηκε σφάλμα κατά την ενημέρωση των προτιμήσεων ειδοποίησης. Προσπάθησε να αλλάξεις ξανά την επιλογή σου.", + "invites": "Προσκλήθηκε σ' ένα δωμάτιο", + "keywords": "Εμφάνιση ενός σήματος όταν χρησιμοποιούνται λέξεις-κλειδιά σε ένα δωμάτιο.", + "keywords_prompt": "Εισήγαγε λέξεις-κλειδιά εδώ ή χρησιμοποίησε για παραλλαγές ορθογραφίας ή ψευδώνυμα", + "labs_notice_prompt": "Ενημέρωση: Απλοποιήσαμε τις Ρυθμίσεις ειδοποιήσεων για να διευκολύνουμε την εύρεση επιλογών. Ορισμένες προσαρμοσμένες ρυθμίσεις που έχεις επιλέξει στο παρελθόν δεν εμφανίζονται εδώ, αλλά εξακολουθούν να είναι ενεργές. Εάν προχωρήσεις, ορισμένες από τις ρυθμίσεις σου ενδέχεται να αλλάξουν. Μάθε περισσότερα", + "mentions_keywords": "Επισημάνσεις και Λέξεις-κλειδιά", + "mentions_keywords_only": "Μόνο επισημάνσεις και λέξεις-κλειδιά", "messages_containing_keywords": "Μηνύματα που περιέχουν λέξεις-κλειδιά", "noisy": "Δυνατά", + "notices": "Μηνύματα που αποστέλλονται από bots", + "notify_at_room": "Ειδοποίησέ όταν κάποιος αναφέρει χρησιμοποιώντας @room", + "notify_keyword": "Ειδοποίηση όταν κάποιος χρησιμοποιεί μια λέξη-κλειδί", + "notify_mention": "Ειδοποίηση όταν κάποιος επισημαίνει με @displayname ή %(mxid)s", + "other_section": "Άλλα πράγματα που πιστεύουμε ότι μπορεί να σε ενδιαφέρουν:", + "people_mentions_keywords": "Άτομα, Επισημάνσεις και Λέξεις-κλειδιά", + "play_sound_for_description": "Εφαρμόζεται από προεπιλογή σε όλα τα δωμάτια σε όλες τις συσκευές.", + "play_sound_for_section": "Αναπαραγωγή ήχου για", "push_targets": "Στόχοι ειδοποιήσεων", + "quick_actions_mark_all_read": "Επισήμανση όλων των μηνυμάτων ως αναγνωσμένων", + "quick_actions_reset": "Επαναφορά στις προεπιλεγμένες ρυθμίσεις", + "quick_actions_section": "Γρήγορες Ενέργειες", + "room_activity": "Νέα δραστηριότητα δωματίου, αναβαθμίσεις και μηνύματα κατάστασης", "rule_call": "Πρόσκληση σε κλήση", "rule_contains_display_name": "Μηνύματα που περιέχουν το όνομα μου", "rule_contains_user_name": "Μηνύματα που περιέχουν το όνομα χρήστη μου", @@ -2005,9 +2152,11 @@ "rule_roomnotif": "Μηνύματα που περιέχουν @δωμάτιο", "rule_suppress_notices": "Μηνύματα από bots", "rule_tombstone": "Όταν τα δωμάτια αναβαθμίζονται", - "show_message_desktop_notification": "Εμφάνιση του μηνύματος στην ειδοποίηση στον υπολογιστή" + "show_message_desktop_notification": "Εμφάνιση του μηνύματος στην ειδοποίηση στον υπολογιστή", + "voip": "Κλήσεις ήχου και Βίντεο" }, "preferences": { + "Electron.enableHardwareAcceleration": "Ενεργοποίηση επιτάχυνσης υλικού (επανεκκίνηση %(appName)s για να τεθεί σε ισχύ)", "always_show_menu_bar": "Να εμφανίζεται πάντα η μπάρα μενού παραθύρου", "autocomplete_delay": "Καθυστέρηση αυτόματης συμπλήρωσης (ms)", "code_blocks_heading": "Μπλοκ κώδικα", @@ -2018,9 +2167,12 @@ "keyboard_heading": "Συντομεύσεις πληκτρολογίου", "keyboard_view_shortcuts_button": "Για να δείτε όλες τις συντομεύσεις πληκτρολογίου, κάντε κλικ εδώ.", "media_heading": "Εικόνες, GIF και βίντεο", + "presence_description": "Μοιράσου τη δραστηριότητα και την κατάστασή σου με άλλους.", "rm_lifetime": "Διάρκεια του Δείκτη Ανάγνωσης (ms)", "rm_lifetime_offscreen": "Διάρκεια Δείκτη εκτός οθόνης (ms)", + "room_directory_heading": "Κατάλογος δωματίων", "room_list_heading": "Λίστα δωματίων", + "show_avatars_pills": "Εμφάνιση άβαταρ σε αναφορές χρηστών, δωματίων και εκδηλώσεων", "show_polls_button": "Εμφάνιση κουμπιού δημοσκοπήσεων", "surround_text": "Περιτριγυριστείτε το επιλεγμένο κείμενο κατά την πληκτρολόγηση ειδικών χαρακτήρων", "time_heading": "Εμφάνιση ώρας" @@ -2097,6 +2249,8 @@ "session_key": "Κλειδί συνεδρίας:", "strict_encryption": "Μη στέλνετε ποτέ κρυπτογραφημένα μηνύματα σε μη επαληθευμένες συνεδρίες από αυτήν τη συνεδρία" }, + "send_read_receipts": "Αποστολή αποδείξεων ανάγνωσης", + "send_read_receipts_unsupported": "Ο διακομιστής σου δεν υποστηρίζει την απενεργοποίηση αποστολής αποδείξεων ανάγνωσης.", "send_typing_notifications": "Αποστολή ειδοποιήσεων πληκτρολόγησης", "sessions": { "confirm_sign_out": { @@ -2118,10 +2272,12 @@ "session_id": "Αναγνωριστικό συνεδρίας", "verify_session": "Επαλήθευση συνεδρίας" }, + "show_avatar_changes": "Εμφάνιση αλλαγών εικόνας προφίλ", "show_breadcrumbs": "Εμφάνιση συντομεύσεων σε δωμάτια που προβλήθηκαν πρόσφατα πάνω από τη λίστα δωματίων", "show_chat_effects": "Εμφάνιση εφέ συνομιλίας (κινούμενα σχέδια κατά τη λήψη π.χ. κομφετί)", "show_displayname_changes": "Εμφάνιση αλλαγών εμφανιζόμενου ονόματος", "show_join_leave": "Εμφάνιση μηνυμάτων συμμετοχής/αποχώρησης (προσκλήσεις/αφαιρέσεις/απαγορεύσεις δεν επηρεάζονται)", + "show_nsfw_content": "Εμφάνιση περιεχομένου NSFW", "show_read_receipts": "Εμφάνιση αποδείξεων ανάγνωσης που έχουν αποσταλεί από άλλους χρήστες", "show_redaction_placeholder": "Εμφάνιση πλαισίου θέσης για μηνύματα που έχουν αφαιρεθεί", "show_stickers_button": "Εμφάνιση κουμπιού αυτοκόλλητων", @@ -2144,14 +2300,25 @@ "use_control_enter_send_message": "Χρησιμοποιήστε Ctrl + Enter για να στείλετε ένα μήνυμα", "use_control_f_search": "Χρησιμοποιήστε τα πλήκτρα Ctrl + F για αναζήτηση στο χρονοδιάγραμμα", "voip": { + "allow_p2p": "Να επιτρέπεται η χρήση Peer-to-Peer για κλήσεις 1:1", + "allow_p2p_description": "Όταν είναι ενεργό, το άλλο άτομο ενδέχεται να μπορεί να δει τη διεύθυνση IP σου", "audio_input_empty": "Δεν εντοπίστηκε μικρόφωνο", "audio_output": "Έξοδος ήχου", "audio_output_empty": "Δεν εντοπίστηκαν Έξοδοι Ήχου", + "auto_gain_control": "Αυτόματος έλεγχος gain", + "connection_section": "Σύνδεση", + "echo_cancellation": "Ακύρωση ηχούς", + "enable_fallback_ice_server_description": "Ισχύει μόνο εάν ο οικιακός διακομιστής σου δεν προσφέρει ένα. Η διεύθυνση IP σου θα κοινοποιηθεί κατά τη διάρκεια μιας κλήσης.", "mirror_local_feed": "Αντικατοπτρίστε την τοπική ροή βίντεο", "missing_permissions_prompt": "Λείπουν δικαιώματα πολυμέσων, κάντε κλικ στο κουμπί παρακάτω για να αιτηθείτε.", + "noise_suppression": "Καταστολή θορύβου", "request_permissions": "Ζητήστε άδειες πολυμέσων", "title": "Φωνή & Βίντεο", - "video_input_empty": "Δεν εντοπίστηκε κάμερα" + "video_input_empty": "Δεν εντοπίστηκε κάμερα", + "video_section": "Ρυθμίσεις βίντεο", + "voice_agc": "Αυτόματη ρύθμιση της έντασης του μικροφώνου", + "voice_processing": "Επεξεργασία φωνής", + "voice_section": "Ρυθμίσεις φωνής" }, "warn_quit": "Προειδοποιήστε πριν την παραίτηση" }, @@ -2199,6 +2366,8 @@ "lenny": "Προ-εισάγει ( ͡° ͜ʖ ͡°) σε ένα μήνυμα απλού κειμένου", "me": "Εμφανίζει την ενέργεια", "msg": "Στέλνει ένα μήνυμα στον δοσμένο χρήστη", + "myavatar": "Αλλάζει την εικόνα προφίλ σου σ' όλα τα δωμάτια", + "myroomavatar": "Αλλάζει την εικόνα προφίλ σου μόνο στο τρέχον δωμάτιο", "myroomnick": "Αλλάζει το εμφανιζόμενο ψευδώνυμο μόνο στο παρόν δωμάτιο", "nick": "Αλλάζει το ψευδώνυμο χρήστη", "no_active_call": "Δεν υπάρχει ενεργή κλήση σε αυτό το δωμάτιο", @@ -2394,6 +2563,7 @@ "about_minute_ago": "σχεδόν ένα λεπτό πριν", "date_at_time": "%(date)s στις %(time)s", "few_seconds_ago": "λίγα δευτερόλεπτα πριν", + "hours_minutes_seconds_left": "απομένουν %(hours)sώ %(minutes)sλ %(seconds)sδλ", "in_about_day": "περίπου μια μέρα από τώρα", "in_about_hour": "περίπου μία ώρα από τώρα", "in_about_minute": "περίπου ένα λεπτό από τώρα", @@ -2402,13 +2572,17 @@ "in_n_hours": "%(num)s ώρες από τώρα", "in_n_minutes": "%(num)s λεπτά από τώρα", "left": "%(timeRemaining)s απομένουν", + "minutes_seconds_left": "απομένουν %(minutes)sλ %(seconds)sδλ", "n_days_ago": "%(num)s μέρες πριν", "n_hours_ago": "%(num)s ώρες πριν", "n_minutes_ago": "%(num)s λεπτά πριν", "seconds_left": "%(seconds)ss απομένουν", "short_days": "%(value)sμέρες", + "short_days_hours_minutes_seconds": "%(days)sη %(hours)sώ %(minutes)sλ %(seconds)sδλ", "short_hours": "%(value)sώρες", + "short_hours_minutes_seconds": "%(hours)sώ %(minutes)sλ %(seconds)sδλ", "short_minutes": "%(value)s'", + "short_minutes_seconds": "%(minutes)sλ %(seconds)sδλ", "short_seconds": "%(value)s\"" }, "timeline": { @@ -2445,6 +2619,10 @@ "error_processing_voice_message": "Σφάλμα επεξεργασίας του φωνητικού μηνύματος", "unnamed_audio": "Ήχος χωρίς όνομα" }, + "m.call": { + "video_call_started": "Ξεκίνησε βιντεοκλήση στο %(roomName)s", + "video_call_started_unsupported": "Ξεκίνησε βιντεοκλήση στο %(roomName)s. (δεν υποστηρίζεται απ' αυτόν τον περιηγητή)" + }, "m.call.hangup": { "dm": "Τέλος κλήσης" }, @@ -2537,6 +2715,7 @@ }, "m.room.join_rules": { "invite": "Ο %(senderDisplayName)s άλλαξε το δωμάτιο σε \"μόνο με πρόσκληση\".", + "knock": "Ο χρήστης %(senderDisplayName)s άλλαξε τον κανόνα σύνδεσης για αίτημα συμμετοχής.", "public": "Ο %(senderDisplayName)s έκανε το δωμάτιο δημόσιο για όποιον γνωρίζει τον σύνδεσμο.", "restricted": "Ο %(senderDisplayName)s άλλαξε τους κανόνες σύνδεσης στο δωμάτιο.", "restricted_settings": "Ο %(senderDisplayName)s άλλαξε τους κανόνες σύνδεσης στο δωμάτιο. Δείτε τις ρυθμίσεις.", @@ -2549,6 +2728,7 @@ "ban_reason": "Ο %(senderName)s απέκλεισε τον/την %(targetName)s: %(reason)s", "change_avatar": "Ο %(senderName)s άλλαξε τη φωτογραφία του προφίλ του", "change_name": "Ο/η %(oldDisplayName)s άλλαξε το εμφανιζόμενο όνομα σε %(displayName)s", + "change_name_avatar": "Ο χρήστης %(oldDisplayName)s άλλαξε το εμφανιζόμενο όνομα και την εικόνα προφίλ του", "invite": "Ο/η %(senderName)s προσκάλεσε τον/την %(targetName)s", "join": "Ο/η %(targetName)s συνδέθηκε στο δωμάτιο", "kick": "%(senderName)s αφαιρέθηκε %(targetName)s", @@ -2671,6 +2851,14 @@ "one": "αποκλείστηκαν", "other": "αποκλείστηκαν %(count)s φορές" }, + "changed_avatar": { + "one": "Ο χρήστης %(oneUser)s άλλαξε την εικόνα προφίλ του", + "other": "Οι %(oneUser)s άλλαξαν τις φωτογραφίες προφίλ τους %(count)s φορές" + }, + "changed_avatar_multiple": { + "one": "Ο χρήστης %(severalUsers)sάλλαξε την εικόνα του προφίλ του", + "other": "Οι %(severalUsers)sάλλαξαν τις φωτογραφίες προφίλ τους %(count)s φορές" + }, "changed_name": { "one": "%(oneUser)sάλλαξε το όνομα τους", "other": "%(oneUser)sάλλαξε το όνομα τους %(count)s φορές" @@ -2679,6 +2867,7 @@ "one": "%(severalUsers)sάλλαξαν το όνομα τους", "other": "%(severalUsers)sάλλαξαν το όνομα τους %(count)s φορές" }, + "format": "%(nameList)s %(transitionList)s", "hidden_event": { "one": "%(oneUser)sέστειλε ένα κρυφό μήνυμα", "other": "%(oneUser)sέστειλε %(count)s κρυφά μηνύματα" @@ -2954,6 +3143,7 @@ "hide_sidebar_button": "Απόκρυψη πλαϊνής μπάρας", "input_devices": "Συσκευές εισόδου", "join_button_tooltip_connecting": "Συνδέεται", + "maximise": "Γέμισμα οθόνης", "misconfigured_server": "Η κλήση απέτυχε λόγω της λανθασμένης διάρθρωσης του διακομιστή", "misconfigured_server_description": "Παρακαλείστε να ρωτήσετε τον διαχειριστή του κεντρικού διακομιστή σας (%(homeserverDomain)s) να ρυθμίσουν έναν διακομιστή πρωτοκόλλου TURN ώστε οι κλήσεις να λειτουργούν απρόσκοπτα.", "more_button": "Περισσότερα", @@ -2973,6 +3163,7 @@ "screenshare_window": "Παράθυρο εφαρμογής", "show_sidebar_button": "Εμφάνιση πλαϊνής μπάρας", "silence": "Σίγαση", + "silenced": "Οι ειδοποιήσεις σιωπήθηκαν", "start_screenshare": "Ξεκινήστε να μοιράζεστε την οθόνη σας", "stop_screenshare": "Σταματήστε να μοιράζεστε την οθόνη σας", "too_many_calls": "Πάρα Πολλές Κλήσεις", @@ -2993,6 +3184,7 @@ "user_busy_description": "Ο χρήστης που καλέσατε είναι απασχολημένος.", "user_is_presenting": "%(sharerName)s παρουσιάζει", "video_call": "Βιντεοκλήση", + "video_call_started": "Ξεκίνησε η βιντεοκλήση", "voice_call": "Φωνητική κλήση", "you_are_presenting": "Παρουσιάζετε" }, diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index ba0634f618..d2d977c934 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1058,8 +1058,11 @@ "waiting_other_user": "Waiting for %(displayName)s to verify…" }, "verification_requested_toast_title": "Verification requested", + "verified_identity_changed": "%(displayName)s's (%(userId)s) verified identity has changed. Learn more", + "verified_identity_changed_no_displayname": "%(userId)s's verified identity has changed. Learn more", "verify_toast_description": "Other users may not trust it", - "verify_toast_title": "Verify this session" + "verify_toast_title": "Verify this session", + "withdraw_verification_action": "Withdraw verification" }, "error": { "admin_contact": "Please contact your service administrator to continue using this service.", @@ -1623,7 +1626,7 @@ "one": "%(count)s Member", "other": "%(count)s Members" }, - "filter_placeholder": "Filter room members", + "filter_placeholder": "Search room members", "invite_button_no_perms_tooltip": "You do not have permission to invite users", "invited_label": "Invited", "no_matches": "No matches", @@ -2360,7 +2363,7 @@ "enable_element_call_no_permissions_tooltip": "You do not have sufficient permissions to change this." } }, - "room_summary_card_back_action_label": "Room information", + "room_summary_card_back_action_label": "Room info", "scalar": { "error_create": "Unable to create widget.", "error_membership": "You are not in this room.", @@ -2466,6 +2469,7 @@ "breadcrumb_second_description": "You will lose any message history that’s stored only on the server", "breadcrumb_third_description": "You will need to verify all your existing devices and contacts again", "breadcrumb_title": "Are you sure you want to reset your identity?", + "breadcrumb_title_forgot": "Forgot your recovery key? You’ll need to reset your identity.", "breadcrumb_warning": "Only do this if you believe your account has been compromised.", "details_title": "Encryption details", "export_keys": "Export keys", @@ -3495,7 +3499,10 @@ "sent": "%(senderName)s sent an invitation to %(targetDisplayName)s to join the room." }, "m.room.tombstone": "%(senderDisplayName)s upgraded this room.", - "m.room.topic": "%(senderDisplayName)s changed the topic to \"%(topic)s\".", + "m.room.topic": { + "changed": "%(senderDisplayName)s changed the topic to \"%(topic)s\".", + "removed": "%(senderDisplayName)s removed the topic." + }, "m.sticker": "%(senderDisplayName)s sent a sticker.", "m.video": { "error_decrypting": "Error decrypting video" diff --git a/src/i18n/strings/et.json b/src/i18n/strings/et.json index c61df1d003..0dd556c40e 100644 --- a/src/i18n/strings/et.json +++ b/src/i18n/strings/et.json @@ -87,7 +87,7 @@ "ok": "Sobib", "open": "Ava", "pause": "Peata", - "pin": "Nööpnõel", + "pin": "Tõsta esile", "play": "Esita", "proceed": "Jätka", "quote": "Tsiteeri", @@ -110,6 +110,7 @@ "save": "Salvesta", "search": "Otsing", "send_report": "Saada veateade", + "set_avatar": "Seadista profiilipilt", "share": "Jaga", "show": "Näita", "show_advanced": "Näita lisaseadistusi", @@ -128,11 +129,12 @@ "try_again": "Proovi uuesti", "unban": "Taasta ligipääs", "unignore": "Lõpeta eiramine", - "unpin": "Eemalda klammerdus", + "unpin": "Eemalda esiletõstmine", "unsubscribe": "Lõpeta liitumine", "update": "Uuenda", "upgrade": "Uuenda", "upload": "Laadi üles", + "upload_file": "Laadi fail üles", "verify": "Verifitseeri", "view": "Näita", "view_all": "Näita kõiki", @@ -227,6 +229,7 @@ }, "misconfigured_body": "Palu, et sinu %(brand)s'u haldur kontrolliks sinu seadistusi võimalike vigaste või topeltkirjete osas.", "misconfigured_title": "Sinu %(brand)s'i seadistused on paigast ära", + "mobile_create_account_title": "Sa oled loomas kasutajakontot koduserveris %(hsName)s", "msisdn_field_description": "Teades sinu kontaktinfot võivad teised kutsuda sind osalema jututubades", "msisdn_field_label": "Telefon", "msisdn_field_number_invalid": "See telefoninumber ei tundu õige olema, palun kontrolli ta üle ja proovi uuesti", @@ -244,12 +247,40 @@ "phone_label": "Telefon", "phone_optional_label": "Telefoninumber (kui soovid)", "qr_code_login": { + "check_code_explainer": "Sellega verifitseerime, et ühendus sinu teise seadmega on turvaline.", + "check_code_heading": "Sisesta teises seadmes kuvatav number", + "check_code_input_label": "2-kohaline kood", + "check_code_mismatch": "Numbrid ei klapi", "completing_setup": "Lõpetame uue seadme seadistamise", + "error_etag_missing": "Tekkis ootamatu viga. Selle põhjuseks võivad olla brauseri lisamoodul, proksiserveri seadistused või koduserveri vigased seadistused.", + "error_expired": "Sisselogimine aegus. Palun proovi uuesti.", + "error_expired_title": "Sisselogimine ei jõudnud õigeaegselt lõpule", + "error_insecure_channel_detected": "Uue seadmega ei saanud turvalist ühendust luua. Sinu teised seadmed on endiselt turvalised ja nende pärast ei pea muretsema.", + "error_insecure_channel_detected_instructions": "Mis nüüd?", + "error_insecure_channel_detected_instructions_1": "Proovi QR-koodiga teise seadmesse uuesti sisse logida, juhuks kui tegemist oli võrguprobleemiga", + "error_insecure_channel_detected_instructions_2": "Kui sul tekib sama probleem uuesti, proovi teist WiFi-võrku või kasuta wifi asemel mobiilset andmesidet", + "error_insecure_channel_detected_instructions_3": "Kui see ei aita, logi sisse käsitsi", + "error_insecure_channel_detected_title": "Ühendus pole turvaline", + "error_other_device_already_signed_in": "Sa ei pea enam midagi muud tegema.", + "error_other_device_already_signed_in_title": "Sinu muu seade on juba sisse logitud", "error_rate_limited": "Liiga palju päringuid napis ajavahemikus. Enne uuesti proovimist palun oota veidi.", - "error_unexpected": "Tekkis teadmata viga.", - "scan_code_instruction": "Loe QR-koodi seadmega, kus sa oled Matrix'i võrgust välja loginud.", - "scan_qr_code": "Loe QR-koodi", - "select_qr_code": "Vali „%(scanQRCode)s“", + "error_unexpected": "Tekkis teadmata viga. Päring sinu muu seadme ühendamiseks on katkestatud.", + "error_unsupported_protocol": "See seade ei võimalda teise seadmesse sisse logida QR-koodi alusel.", + "error_unsupported_protocol_title": "Muu seade ei ühildu selle funktsionaalsusega", + "error_user_cancelled": "Sisselogimine on teises seadmes katkestatud.", + "error_user_cancelled_title": "Sisselogimispäring on tühistatud", + "error_user_declined": "Sa keeldusid teises seadmes sisselogimispäringust.", + "error_user_declined_title": "Sa keeldusid sisselogimast", + "follow_remaining_instructions": "Teise seadme verifitseerimiseks järgi ülejäänud juhiseid", + "open_element_other_device": "Ava %(brand)s oma teises seadmes", + "point_the_camera": "Suuna kaamera siin näidatud QR-koodi peale", + "scan_code_instruction": "Skaneeri QR-koodi teise seadmega", + "scan_qr_code": "Logi sisse QR-koodi alusel", + "security_code": "Turvakood", + "security_code_prompt": "Kui seda küsitakse, sisesta teises seadmes allolev kood.", + "select_qr_code": "Val i „%(scanQRCode)s“", + "unsupported_explainer": "Sinu teenusepakkuja ei toeta võimalust logida sisse QR-koodi abil.", + "unsupported_heading": "QR-koodi kasutamine pole toetatud", "waiting_for_device": "Ootame, et teine seade logiks võrku" }, "register_action": "Loo konto", @@ -278,7 +309,7 @@ "sign_out_other_devices": "Logi kõik oma seadmed võrgust välja" }, "reset_password_action": "Lähtesta salasõna", - "reset_password_button": "Unustasid parooli?", + "reset_password_button": "Unustasid salasõna?", "reset_password_email_field_description": "Kasuta e-posti aadressi ligipääsu taastamiseks oma kontole", "reset_password_email_field_required_invalid": "Sisesta e-posti aadress (nõutav selles koduserveris)", "reset_password_email_not_associated": "Sinu e-posti aadress ei tundu olema selles koduserveris seotud Matrixi kasutajatunnusega.", @@ -338,6 +369,8 @@ "email_resend_prompt": "Sa pole kirja saanud? Saada uuesti", "email_resent": "Uuesti saadetud!", "fallback_button": "Alusta autentimist", + "mas_cross_signing_reset_cta": "Mine oma kasutajakonto andmete juurde", + "mas_cross_signing_reset_description": "Lähtesta oma võrguidentiteet oma teenusepakkuja abil ning tule siis siia tagasi ja vajuta „Proovi uuesti“.", "msisdn": "Saatsime tekstisõnumi telefoninumbrile %(msisdn)s", "msisdn_token_incorrect": "Vigane tunnusluba", "msisdn_token_prompt": "Palun sisesta seal kuvatud kood:", @@ -427,6 +460,7 @@ "beta": "Beetaversioon", "camera": "Kaamera", "cameras": "Kaamerad", + "cancel": "Loobu", "capabilities": "Funktsionaalsused ja võimed", "copied": "Kopeeritud!", "credits": "Tänuavaldused", @@ -461,11 +495,13 @@ "legal": "Juriidiline teave", "light": "Hele", "loading": "Laadime…", + "lobby": "Ooteruum", "location": "Asukoht", "low_priority": "Vähetähtis", "matrix": "Matrix", "message": "Sõnum", "message_layout": "Sõnumite paigutus", + "message_timestamp_invalid": "Vigane ajatempel", "microphone": "Mikrofon", "model": "Mudel", "modern": "Moodne", @@ -503,10 +539,13 @@ "qr_code": "QR kood", "random": "Juhuslik", "reactions": "Reageerimised", + "recommended": "Soovitatud", "report_a_bug": "Teata veast", "room": "Jututuba", "room_name": "Jututoa nimi", "rooms": "Jututoad", + "save": "Salvesta", + "saved": "Salvestatud", "saving": "Salvestame…", "secure_backup": "Turvaline varundus", "security": "Turvalisus", @@ -524,7 +563,7 @@ "suggestions": "Soovitused", "support": "Toeta", "system_alerts": "Süsteemi teated", - "theme": "Teema", + "theme": "Kujundus", "thread": "Jutulõng", "threads": "Jutulõngad", "timeline": "Ajajoon", @@ -535,6 +574,7 @@ "unnamed_room": "Ilma nimeta jututuba", "unnamed_space": "Nimetu kogukonnakeskus", "unverified": "Verifitseerimata", + "updating": "Uuendame...", "user": "Kasutaja", "user_avatar": "Profiilipilt", "username": "Kasutajanimi", @@ -663,7 +703,7 @@ "private_space_description": "Privaatne kogukonnakeskus sinu ja sinu kaasteeliste jaoks", "public_description": "Avaliku ligipääsuga kogukonnakeskus", "public_heading": "Sinu avalik kogukonnakeskus", - "search_public_button": "Avalike ruumide otsing", + "search_public_button": "Avalike kogukondade otsing", "setup_rooms_community_description": "Teeme siis iga teema jaoks oma jututoa.", "setup_rooms_community_heading": "Mida sa sooviksid arutada %(spaceName)s kogukonnakeskuses?", "setup_rooms_description": "Sa võid ka hiljem siia luua uusi jututubasid või lisada olemasolevaid.", @@ -694,6 +734,44 @@ "category_room": "Jututuba", "caution_colon": "Hoiatus:", "client_versions": "Klientrakenduste versioonid", + "crypto": { + "4s_public_key_in_account_data": "kasutajakonto andmete juures", + "4s_public_key_not_in_account_data": "ei leidu", + "4s_public_key_status": "Turvalise andmeruumi avalik võti:", + "backup_key_cached": "puhverdatud kohalikus seadmes", + "backup_key_cached_status": "Varukoopia võtmed on puhverdatud:", + "backup_key_not_stored": "pole salvestatud", + "backup_key_stored": "krüptitud salvestusruumis", + "backup_key_stored_status": "Varukoopia võti on salvestatud:", + "backup_key_unexpected_type": "ebatavalist tüüpi", + "backup_key_well_formed": "reeglipärane", + "cross_signing": "Risttunnustamine", + "cross_signing_cached": "puhverdatud kohalikus seadmes", + "cross_signing_not_ready": "Risttunnustamine on seadistamata.", + "cross_signing_private_keys_in_storage": "turvalises andmeruumis", + "cross_signing_private_keys_in_storage_status": "Risttunnustamise privaatvõtmed:", + "cross_signing_private_keys_not_in_storage": "ei leidu turvalises andmeruumis", + "cross_signing_public_keys_on_device": "mälus", + "cross_signing_public_keys_on_device_status": "Risttunnustamise avalikud võtmed:", + "cross_signing_ready": "Risttunnustamine on kasutusvalmis.", + "cross_signing_status": "Risttunnustamise olek:", + "cross_signing_untrusted": "Sinu kasutajakonto risttunnustamise identiteet on krüptitud andmehoidlas olemas, aga see sessioon teda veel ei usalda.", + "crypto_not_available": "Krüptomoodul pole saadaval", + "key_backup_active_version": "Varukoopia aktiivne versioon:", + "key_backup_active_version_none": "Puudub", + "key_backup_inactive_warning": "See sessioon ei varunda sinu krüptovõtmeid.", + "key_backup_latest_version": "Varukoopia viimane versioon serveris:", + "key_storage": "Võtmete krüptitud andmeruum", + "master_private_key_cached_status": "Üldine privaatvõti:", + "not_found": "ei leidu", + "not_found_locally": "ei leidu kohalikus seadmes", + "secret_storage_not_ready": "pole valmis", + "secret_storage_ready": "on valmis", + "secret_storage_status": "Krüptitud andmeruum:", + "self_signing_private_key_cached_status": "Privaatvõti allkirjastamiseks sinu nimel:", + "title": "Läbiv krüptimine", + "user_signing_private_key_cached_status": "Kasutaja privaatvõti allkirjastamiseks:" + }, "developer_mode": "Arendusrežiim", "developer_tools": "Arendusvahendid", "edit_setting": "Muuda seadistust", @@ -710,7 +788,7 @@ "failed_to_load": "Laadimine ei õnnestunud.", "failed_to_save": "Seadistuste salvestamine ei õnnestunud.", "failed_to_send": "Päringu või sündmuse saatmine ei õnnestunud!", - "id": "ID:", + "id": "ID: ", "invalid_json": "See ei tundu olema korrektse json-andmestikuna.", "level": "Tase", "low_bandwidth_mode": "Vähese ribalaiusega režiim", @@ -733,6 +811,7 @@ "room_notifications_type": "Tüüp: ", "room_status": "Jututoa sõnumite olek", "room_unread_status_count": { + "one": "Lugemata sõnumite olek jututoas: %(status)s, kokku: %(count)s", "other": "Lugemata sõnumite olek jututoas: %(status)s, kokku: %(count)s" }, "save_setting_values": "Salvesta seadistuste väärtused", @@ -838,14 +917,18 @@ "title": "Kas hävitame risttunnustamise võtmed?", "warning": "Risttunnustamise võtmete kustutamine on tegevus, mida ei saa tagasi pöörata. Kõik sinu verifitseeritud vestluskaaslased näevad seejärel turvateateid. Kui sa just pole kaotanud ligipääsu kõikidele oma seadmetele, kust sa risttunnustamist oled teinud, siis sa ilmselgelt ei peaks kustutamist ette võtma." }, + "enter_recovery_key": "Sisesta taastevõti", "event_shield_reason_authenticity_not_guaranteed": "Selle krüptitud sõnumi autentsus pole selles seadmes tagatud.", "event_shield_reason_mismatched_sender_key": "Krüptitud verifitseerimata sessiooni poolt", - "event_shield_reason_unknown_device": "Krüpteeritud tundmatu või kustutatud seadme poolt.", - "event_shield_reason_unsigned_device": "Krüpteeritud seadme poolt, mida selle omanik ei ole verifitseerinud.", - "event_shield_reason_unverified_identity": "Krüpteeritud verifitseerimata kasutaja poolt.", + "event_shield_reason_unknown_device": "Krüptitud tundmatu või kustutatud seadme poolt.", + "event_shield_reason_unsigned_device": "Krüptitud seadme poolt, mida selle omanik ei ole verifitseerinud.", + "event_shield_reason_unverified_identity": "Krüptitud verifitseerimata kasutaja poolt.", "export_unsupported": "Sinu brauser ei toeta vajalikke krüptoteeke", + "forgot_recovery_key": "Kas unustasid taastevõtme?", "import_invalid_keyfile": "See ei ole sobilik võtmefail %(brand)s'i jaoks", "import_invalid_passphrase": "Autentimine ebaõnnestus: kas salasõna pole õige?", + "key_storage_out_of_sync": "Sinu krüptovõtmete hoidla pole sünkroonis.", + "key_storage_out_of_sync_description": "Säilitamaks ligipääsu vestluste ja krüptovõtmete varukoopiale, palun sisesta kinnituseks oma taastevõti.", "messages_not_secure": { "cause_1": "Sinu koduserver", "cause_2": "Sinu poolt verifitseeritava kasutaja koduserver", @@ -861,6 +944,8 @@ "warning": "Kui sa ei ole ise uusi taastamise meetodeid lisanud, siis võib olla tegemist ründega sinu konto vastu. Palun vaheta koheselt oma kasutajakonto salasõna ning määra seadistustes uus taastemeetod." }, "not_supported": "", + "pinned_identity_changed": "Kasutaja %(displayName)s (%(userId)s) võrguidentiteet tundub olema muutunud. Lisateave", + "pinned_identity_changed_no_displayname": "Kasutaja %(userId)s võrguidentiteet tundub olema muutunud. Lisateave", "recovery_method_removed": { "description_1": "Oleme tuvastanud, et selles sessioonis ei leidu turvafraasi ega krüptitud sõnumite turvavõtit.", "description_2": "Kui sa tegid seda juhuslikult, siis sa võid selles sessioonis uuesti seadistada sõnumite krüptimise, mille tulemusel krüptime uuesti kõik sõnumid ja loome uue taastamise meetodi.", @@ -868,6 +953,9 @@ "warning": "Kui sa ei ole ise taastamise meetodeid eemaldanud, siis võib olla tegemist ründega sinu konto vastu. Palun vaheta koheselt oma kasutajakonto salasõna ning määra seadistustes uus taastemeetod." }, "reset_all_button": "Unustasid või oled kaotanud kõik võimalused ligipääsu taastamiseks? Lähtesta kõik ühe korraga", + "set_up_recovery": "Seadista krüptovõtmete taastamine", + "set_up_recovery_later": "Mitte praegu", + "set_up_recovery_toast_description": "Kui peaksid kaotama ligipääsu oma seadmetele, siis siinloodava taastevõtmega saad taastada ligipääsu oma krüptitud sõnumitele.", "set_up_toast_description": "Hoia ära, et kaotad ligipääsu krüptitud sõnumitele ja andmetele", "set_up_toast_title": "Võta kasutusele turvaline varundus", "setup_secure_backup": { @@ -923,6 +1011,7 @@ "qr_reciprocate_same_shield_device": "Peaaegu valmis! Kas sinu teine seade kuvab sama kilpi?", "qr_reciprocate_same_shield_user": "Peaaegu valmis! Kas %(displayName)s kuvab sama kilpi?", "request_toast_accept": "Verifitseeri sessioon", + "request_toast_accept_user": "Verifitseeri kasutaja", "request_toast_decline_counter": "Eira (%(counter)s)", "request_toast_detail": "%(deviceId)s ip-aadressil %(ip)s", "reset_proceed_prompt": "Jätka kustutamisega", @@ -948,7 +1037,7 @@ "unverified_sessions_toast_description": "Tagamaks, et su konto on sinu kontrolli all, vaata andmed üle", "unverified_sessions_toast_reject": "Hiljem", "unverified_sessions_toast_title": "Sul on verifitseerimata sessioone", - "verification_description": "Tagamaks ligipääsu oma krüptitud sõnumitele ja tõestamaks oma isikut teistele kasutajatale, verifitseeri end.", + "verification_description": "Tagamaks ligipääsu oma krüptitud sõnumitele ja tõestamaks oma isikut teistele kasutajatale, verifitseeri end. Kui kasutad mobiilirakendust, siis palun ava see enne jätkamist.", "verification_dialog_title_device": "Verifitseeri oma teine seade", "verification_dialog_title_user": "Verifitseerimispäring", "verification_skip_warning": "Ilma verifitseerimiseta sul puudub ligipääs kõikidele oma sõnumitele ning teised ei näe sinu kasutajakontot usaldusväärsena.", @@ -1015,7 +1104,8 @@ "error_app_open_in_another_tab_title": "%(brand)s'i on kasutatav teisel vahekaardil", "error_app_opened_in_another_window": "%(brand)s on avatud teises aknas. Klõpsa \"%(label)s\", et kasutada siin %(brand)s ja katkestada teise akna ühendus.", "error_database_closed_description": { - "for_desktop": "Andmekandja maht võib olla täis saanud. Palun tee ruumi juurde ja laadi leht uuesti." + "for_desktop": "Andmekandja maht võib olla täis saanud. Palun tee ruumi juurde ja laadi leht uuesti.", + "for_web": "Kui sa kustutasid brauseris puhverdatud andmed, siis selline teade on ootuspärane. Lisaks on võimalik, et %(brand)s on avatud mõnes teises vahekaardis või sinu seadme kõvakettaruum on otsas. Palun tee seadmesse ruumi ja laadi uuesti" }, "error_database_closed_title": "%(brand)s lõpetas ootamatult töö", "error_dialog": { @@ -1053,7 +1143,15 @@ "you": "Sa reageerisid %(message)s sõnumile %(reaction)s'ga" }, "m.sticker": "%(senderName)s: %(stickerName)s", - "m.text": "%(senderName)s: %(message)s" + "m.text": "%(senderName)s: %(message)s", + "prefix": { + "audio": "Helifail", + "file": "Fail", + "image": "Pilt", + "poll": "Küsitlus", + "video": "Video" + }, + "preview": "%(prefix)s: %(preview)s" }, "export_chat": { "cancelled": "Eksport on katkestatud", @@ -1176,7 +1274,19 @@ "other": "Kogukonnas %(spaceName)s ja %(count)s's muus kogukonnas." }, "incompatible_browser": { - "title": "Sellele brauserile puudub tugi" + "continue": "Jätka ikkagi", + "description": "%(brand)s kasutab sellist funktsionaalsust, mida ei leidu sinu brauseris. %(detail)s", + "detail_can_continue": "Kui jätkad, siis mingi osa funktsionaalsusest ei pruugi enam toimida ja tekib risk, et kaotad tulevikus osa andmetest.", + "detail_no_continue": "Kui sa ei pruugi brauseri viimast versiooni, siis proovi teda uuendada ja katseta uuesti.", + "learn_more": "Lisateave", + "linux": "Linux", + "macos": "Mac", + "supported_browsers": "Kõige paremini toimib veebirakendus brauserites Chrome, Firefox, Edge ja Safari.", + "title": "Sellele brauserile puudub tugi", + "use_desktop_heading": "Selle asemel kasuta %(brand)s töölauaversiooni", + "use_mobile_heading": "Selle asemel kasuta %(brand)s nutiseadmeversiooni", + "use_mobile_heading_after_desktop": "Või kasuta meie rakendust nutiseadmetele", + "windows": "Windows (%(bits)s-bit)" }, "info_tooltip_title": "Teave", "integration_manager": { @@ -1216,8 +1326,8 @@ "error_permissions_space": "Sul pole õigusi siia kogukonda osalejate kutsumiseks.", "error_profile_undisclosed": "Kasutaja võib olla, aga ka võib mitte olla olemas", "error_transfer_multiple_target": "Kõnet on võimalik edasi suunata vaid ühele kasutajale.", - "error_unfederated_room": "See jututuba on föderatsioonita. Te ei saa kutsuda inimesi välistest serveritest.", - "error_unfederated_space": "See ruum on föderatsioonita. Te ei saa kutsuda inimesi välistest serveritest.", + "error_unfederated_room": "See jututuba on föderatsioonita. Sa ei saa kutsuda inimesi välistest serveritest.", + "error_unfederated_space": "See kogukond on föderatsioonita. Sa ei saa kutsuda inimesi välistest serveritest.", "error_unknown": "Tundmatu serveriviga", "error_user_not_found": "Sellist kasutajat pole olemas", "error_version_unsupported_room": "Kasutaja koduserver ei toeta selle jututoa versiooni.", @@ -1300,12 +1410,14 @@ "navigate_next_message_edit": "Muutmiseks liigu järgmise sõnumi juurde", "navigate_prev_history": "Eelmine viimati külastatud jututuba või kogukond", "navigate_prev_message_edit": "Muutmiseks liigu eelmise sõnumi juurde", + "next_landmark": "Mine kasutajaliidese järgmise olulise tähise juurde", "next_room": "Järgmine otsevestlus või jututuba", "next_unread_room": "Järgmine lugemata otsevestlus või jututuba", "number": "[number]", "open_user_settings": "Ava kasutaja seadistused", "page_down": "Page Down", "page_up": "Page Up", + "prev_landmark": "Mine kasutajaliidese eelmise olulise tähise juurde", "prev_room": "Eelmine otsevestlus või jututuba", "prev_unread_room": "Eelmine lugemata otsevestlus või jututuba", "room_list_collapse_section": "Ahenda jututubade loendi valikut", @@ -1346,12 +1458,15 @@ "bridge_state_workspace": "Tööruum: ", "click_for_info": "Lisateabe jaoks klõpsi", "currently_experimental": "Parasjagu katsejärgus.", - "custom_themes": "Toeta kohandatud teemade lisamist", + "custom_themes": "Kohandatud kujunduste lisamise võimalus", "dynamic_room_predecessors": "Jututoa dünaamilised eellased", "dynamic_room_predecessors_description": "Võta kasutusele MSC3946 (jututoa ajaloo aeglane laadimine)", "element_call_video_rooms": "Element Call videotoad", + "exclude_insecure_devices": "Sõnumite saatmisel ja vastuvõtmisel välista ebaturvalised seadmed", + "exclude_insecure_devices_description": "Kui see režiim on kasutusel, siis krüptitud sõnumeid ei jagata verifitseerimata seadmetega ja verifitseerimata seadmetest saabunud sõnumite puhul näidatakse vaid veateadet. Palun arvesta, et selle töörežiimi puhul sa ilmselt ei saa suhelda kasutajatega, kes pole kõiki oma seadmeid korrektselt verifitseerinud.", "experimental_description": "Soovid katsetada? Proovi meie uusimaid arendusmõtteid. Need funktsionaalsused pole üldsegi veel valmis, nad võivad toimida puudulikult, võivad muutuda või sootuks lõpetamata jääda. Lisateavet leiad siit.", "experimental_section": "Varased arendusjärgud", + "extended_profiles_msc_support": "See eeldab, et koduserver toetab MSC4133 spetsifikatsiooni", "feature_disable_call_per_sender_encryption": "Lülita Element Call'i kasutamisel krüptimine kasutajakohaselt välja", "feature_wysiwyg_composer_description": "Sõnumite kirjutamisel kasuta Markdown'i asemel täisfunktsionaalset küljendust.", "group_calls": "Uus rühmakõnede lahendus", @@ -1363,8 +1478,9 @@ "group_profile": "Profiil", "group_rooms": "Jututoad", "group_spaces": "Kogukonnakeskused", - "group_themes": "Teemad", + "group_themes": "Kujundused", "group_threads": "Jutulõngad", + "group_ui": "Kasutajaliides", "group_voip": "Heli ja video", "group_widgets": "Vidinad", "hidebold": "Peida teavituse täpp (ja näita loendure)", @@ -1381,15 +1497,20 @@ "mjolnir": "Uued võimalused osalejate eiramiseks", "msc3531_hide_messages_pending_moderation": "Luba modereerimist ootavate sõnumite peitmist.", "notification_settings": "Uued teavituste seadistused", + "notification_settings_beta_caption": "Võtame kasutusele senisest lihtsama viisi teavituste seadistamiseks. Kohanda rakendust %(brand)s nii nagu soovid.", "notification_settings_beta_title": "Teavituste seadistused", "notifications": "Kasuta jututoa päises teavituste riba", + "release_announcement": "Teave uue versiooni kohta", + "render_reaction_images": "Kujuta reaktsioonides ka kohandatud pilte", + "render_reaction_images_description": "Mõnikord nimetatakse neid ka „kohandatud emotikonideks“.", "report_to_moderators": "Teata moderaatoritele", "report_to_moderators_description": "Kui jututoas on modereerimine kasutusel, siis nupust „Teata sisust“ avaneva vormi abil saad jututoa reegleid rikkuvast sisust teatada moderaatoritele.", "sliding_sync": "Järkjärgulise sünkroniseerimise režiim", "sliding_sync_description": "Aktiivselt arendamisel ega ole võimalik välja lülitada.", "sliding_sync_disabled_notice": "Väljalülitamiseks logi Matrix'i võrgust välja ja seejärel tagasi", - "sliding_sync_server_no_support": "Selle funktsionaalsuse tugi on sinu koduserveris puudu", + "sliding_sync_server_no_support": "Selle funktsionaalsuse tugi on sinu koduserveris puudu!", "under_active_development": "Aktiivselt arendamisel.", + "unrealiable_e2e": "Krüptitud jututubades pole see töökindel", "video_rooms": "Videotoad", "video_rooms_a_new_way_to_chat": "Uus võimalus videovestlusteks rakenduses %(brand)s.", "video_rooms_always_on_voip_channels": "Videotoad on kogu aeg saadaval VoIP kanalid, mis on lõimitud jututubadega ja kasutatavad rakenduses %(brand)s.", @@ -1398,6 +1519,7 @@ "video_rooms_faq1_question": "Kuidas ma saan luua videotoa?", "video_rooms_faq2_answer": "Jah, tekstivestluse ajajoon on kuvatud videovaate kõrval.", "video_rooms_faq2_question": "Kas ma saan videokõne ajal ka tekstisõnumeid saata?", + "video_rooms_feedbackSubheading": "Täname, et proovid beetaversiooni, palun kirjelda nii palju üksikasju kui võimalik, et saaksime seda funktsionaalsust täiustada.", "wysiwyg_composer": "Kujundatud teksti toimeti" }, "labs_mjolnir": { @@ -1438,6 +1560,8 @@ "last_person_warning": "Sa oled siin viimane osaleja. Kui sa nüüd lahkud, siis mitte keegi, kaasa arvatud sa ise, ei saa hiljem enam liituda.", "leave_room_question": "Kas oled kindel, et soovid lahkuda jututoast „%(roomName)s“?", "leave_space_question": "Kas oled kindel, et soovid lahkuda kogukonnakeskusest „%(spaceName)s“?", + "room_leave_admin_warning": "Sa oled ainus selle jututoa haldaja. Kui sa siit lahkud, ei saa keegi teine jututoa seadistusi muuta ega muid olulisi toiminguid teha.", + "room_leave_mod_warning": "Sa oled ainus moderaator selles jututoas. Kui sa siis lahkud, ei saa keegi teine jututoa seadistusi muuta ega muid olulisi toiminguid teha.", "room_rejoin_warning": "See ei ole avalik jututuba. Ilma kutseta sa ei saa uuesti liituda.", "space_rejoin_warning": "See ei ole avalik kogukonnakeskus. Ilma kutseta sa ei saa uuesti liituda." }, @@ -1495,12 +1619,19 @@ "toggle_attribution": "Lülita omistamine sisse või välja" }, "member_list": { + "count": { + "one": "%(count)s liige", + "other": "%(count)s liiget" + }, "filter_placeholder": "Filtreeri jututoa liikmeid", "invite_button_no_perms_tooltip": "Sul pole õigusi kutse saatmiseks teistele kasutajatele", + "invited_label": "Kutsutud", + "no_matches": "Vasteid pole", "power_label": "%(userName)s (õigused %(powerLevelNumber)s)" }, "member_list_back_action_label": "Jututoa liikmed", "message_edit_dialog_title": "Sõnumite muutmised", + "migrating_crypto": "Oota veel üks viiv. Meil on pooleli %(brand)s uuendamine, misjärel kasutatav krüpto on kiirem ja töökindlam.", "mobile_guide": { "toast_accept": "Kasuta rakendust", "toast_description": "%(brand)s toimib nutiseadme veebibrauseris kastseliselt. Parima kasutajakogemuse ja uusima funktsionaalsuse jaoks kasuta meie rakendust.", @@ -1526,7 +1657,10 @@ "keyword": "Märksõnad", "keyword_new": "Uus märksõna", "level_activity": "Aktiivsuse alusel", + "level_highlight": "Tõsta esile", + "level_muted": "Summutatud", "level_none": "Ei ühelgi juhul", + "level_notification": "Teavitus", "level_unsent": "Saatmata", "mark_all_read": "Märgi kõik loetuks", "mentions_and_keywords": "@mainimiste ja võtmesõnade puhul", @@ -1614,7 +1748,8 @@ "online": "Võrgus", "online_for": "Võrgus %(duration)s", "unknown": "Teadmata olek", - "unknown_for": "Teadmata olek viimased %(duration)s" + "unknown_for": "Teadmata olek viimased %(duration)s", + "unreachable": "Kasutaja koduserver pole kättesaadav" }, "quick_settings": { "all_settings": "Kõik seadistused", @@ -1642,6 +1777,7 @@ "report_content": { "description": "Sellest sõnumist teatamine saadab tema unikaalse sõnumi tunnuse sinu koduserveri haldurile. Kui selle jututoa sõnumid on krüptitud, siis sinu koduserveri haldur ei saa lugeda selle sõnumi teksti ega vaadata seal leiduvaid faile ja pilte.", "disagree": "Ma ei nõustu sisuga", + "error_create_room_moderation_bot": "Modereerimisbotiga ei saa jututuba luua", "hide_messages_from_user": "Selle valikuga peidad kõik antud kasutaja praegused ja tulevased sõnumid.", "ignore_user": "Eira kasutajat", "illegal_content": "Seadustega keelatud sisu", @@ -1649,6 +1785,8 @@ "nature": "Palun vali rikkumise olemus ja kirjelda mis teeb selle sõnumi kuritahtlikuks.", "nature_disagreement": "Selle kasutaja loodud sisu on vale.\nJututoa moderaatorid saavad selle kohta teate.", "nature_illegal": "Selle kasutaja tegevus on seadusevastane, milleks võib olla doksimine ehk teiste eraeluliste andmete avaldamine või vägivallaga ähvardamine.\nJututoa moderaatorid saavad selle kohta teate ning nad võivad sellest teatada ka ametivõimudele.", + "nature_nonstandard_admin": "See jututoa on pühendatud illegaalsele või mürgisele sisule või moderaatorid ei suuda sellist sisu ohjeldada.\nSellest teatatakse %(homeserver)s haldajatele.", + "nature_nonstandard_admin_encrypted": "See jututoa on pühendatud illegaalsele või mürgisele sisule või moderaatorid ei suuda sellist sisu ohjeldada.\nSellest teatatakse %(homeserver)s haldajatele. Haldajatel EI ole võimalik lugeda selle jututoa krüpteeritud sisu.", "nature_other": "Mõni muu põhjus. Palun kirjelda seda detailsemalt.\nJututoa moderaatorid saavad selle kohta teate.", "nature_spam": "See kasutaja spämmib jututuba reklaamidega, reklaamlinkidega või propagandaga.\nJututoa moderaatorid saavad selle kohta teate.", "nature_toxic": "Selle kasutaja tegevus on äärmiselt ebasobilik, milleks võib olla teiste jututoas osalejate solvamine, peresõbralikku jututuppa täiskasvanutele mõeldud sisu lisamine või muul viisil jututoa reeglite rikkumine.\nJututoa moderaatorid saavad selle kohta teate.", @@ -1682,14 +1820,38 @@ "restore_failed_error": "Varukoopiast taastamine ei õnnestu" }, "right_panel": { - "add_integrations": "Lisa vidinaid, võrgusildu ja roboteid", + "add_integrations": "Lisa laiendusi", + "add_topic": "Lisa teema", + "extensions_button": "Laiendused", + "extensions_empty_description": "Laienduste otsimiseks ja siia jututuppa lisamiseks klõpsi linki „%(addIntegrations)s“", + "extensions_empty_title": "Paranda oma tõhusust lisatarvikute, vidinate, võrgusildade ja robotite lisamise abil", "files_button": "Failid", "pinned_messages": { + "empty_description": "Siia lisamiseks vali sõnumi ning vajuta nuppu „%(pinAction)s“", + "empty_title": "Et olulisi sõnumeid oleks lihtsam leida, tõsta nad esile", + "header": { + "one": "1 esiletõstetud sõnum", + "other": "%(count)s esiletõstetud sõnumit" + }, "limits": { - "other": "Sa saad kinnitada kuni %(count)s vidinat" - } + "one": "", + "other": "Sa saad esile tõsta kuni %(count)s vidinat" + }, + "menu": "Ava menüü", + "release_announcement": { + "close": "Sobib", + "description": "Leiad kõik esiletõstetud sõnumid siit. Uute sõnumite siia lisamiseks liigu vajaliku sõnumi kohale ja vali „Tõsta esile“.", + "title": "Kõik uued esiletõstetud sõnumid" + }, + "reply_thread": "Vasta jutulõngas", + "unpin_all": { + "button": "Eemalda kõik esiletõstetud sõnumid", + "content": "Kas sa oled kindel, et soovid kõik esiletõstetud sõnumid eemaldad? Seda tegevust ei saa tagasi pöörata.", + "title": "Kas eemaldame kõik esiletõstetud sõnumid?" + }, + "view": "Vaata ajajoonel" }, - "pinned_messages_button": "Klammerdatud", + "pinned_messages_button": "Esiletõstetud sõnumid", "poll": { "active_heading": "Käimasolevad küsitlused", "empty_active": "Selles jututoas pole käimasolevaid küsitlusi", @@ -1714,7 +1876,7 @@ "view_in_timeline": "Näita küsitlust ajajoonel", "view_poll": "Vaata küsitlust" }, - "polls_button": "Küsitluste ajalugu", + "polls_button": "Küsitlused", "room_summary_card": { "title": "Jututoa teave" }, @@ -1743,6 +1905,7 @@ "forget": "Unusta jututuba ära", "low_priority": "Vähetähtis", "mark_read": "Märgi loetuks", + "mark_unread": "Märgi mitteloetuks", "notifications_default": "Sobita vaikimisi seadistusega", "notifications_mute": "Summuta jututuba", "title": "Jututoa eelistused", @@ -1785,8 +1948,15 @@ "forget_room": "Unusta see jututuba", "forget_space": "Unusta see kogukond", "header": { + "n_people_asking_to_join": { + "one": "Üks huviline soovib liituda", + "other": "%(count)s huvilist soovivad liituda" + }, "room_is_public": "See jututuba on avalik" }, + "header_avatar_open_settings_label": "Ava jututoa seadistused", + "header_face_pile_tooltip": "Lülita liikmete nimekiri sisse/välja", + "header_untrusted_label": "Pole usaldusväärne", "inaccessible": "See jututuba või kogukond pole hetkel ligipääsetav.", "inaccessible_name": "Jututuba %(roomName)s ei ole parasjagu kättesaadav.", "inaccessible_subtitle_1": "Proovi hiljem uuesti või küsi jututoa või kogukonna haldurilt, kas sul on ligipääs olemas.", @@ -1812,7 +1982,7 @@ "invite_reject_ignore": "Hülga ja eira kasutaja", "invite_sent_to_email": "See kutse saadeti e-posti aadressile %(email)s", "invite_sent_to_email_room": "Kutse %(roomName)s jututuppa saadeti %(email)s e-posti aadressile", - "invite_subtitle": " kutsus sind", + "invite_subtitle": " saatis sulle kutse", "invite_this_room": "Kutsu siia jututuppa", "invite_title": "Kas sa soovid liitud jututoaga %(roomName)s?", "inviter_unknown": "Teadmata olek", @@ -1834,6 +2004,8 @@ "kicked_by": "%(memberName)s eemaldas sinu liikmelisuse", "kicked_from_room_by": "%(memberName)s eemaldas sind %(roomName)s jututoast", "knock_cancel_action": "Tühista liitumissoov", + "knock_denied_subtitle": "Kuna sulle on ligipääs keelatud, siis sa ei saa uuesti liituda ilma jututoa haldaja või moderaatori kutseta.", + "knock_denied_title": "Sulle on ligipääs keelatud", "knock_message_field_placeholder": "Sõnum (kui soovid lisada)", "knock_prompt": "Küsi võimalust liitumiseks?", "knock_prompt_name": "Küsi luba liitumiseks jututoaga %(roomName)s?", @@ -1853,11 +2025,24 @@ "not_found_title": "Seda jututuba või kogukonda pole olemas.", "not_found_title_name": "Jututuba %(roomName)s ei ole olemas.", "peek_join_prompt": "Sa vaatad jututoa %(roomName)s eelvaadet. Kas soovid sellega liituda?", + "pinned_message_badge": "Esiletõstetud sõnum", + "pinned_message_banner": { + "button_close_list": "Sulge loend", + "button_view_all": "Vaata kõiki", + "description": "Selles jututoas on esiletõstetud sõnumeid. Nende vaatamiseks klõpsi.", + "go_to_message": "Vaata esiletõstetud sõnumit ajajoonel.", + "title": "%(index)s of %(length)s Esiletõstetud sõnumid" + }, "read_topic": "Teema lugemiseks klõpsi", "rejecting": "Hülgan kutset…", "rejoin_button": "Liitu uuesti", "search": { "all_rooms_button": "Otsi kõikidest jututubadest", + "placeholder": "Otsi sõnumeid…", + "summary": { + "one": "„“ päringule leidub 1 vastus", + "other": "„“ päringule leidub %(count)s vastust" + }, "this_room_button": "Otsi sellest jututoast" }, "status_bar": { @@ -1914,8 +2099,8 @@ }, "show_less": "Näita vähem", "show_n_more": { - "other": "Näita veel %(count)s sõnumit", - "one": "Näita veel %(count)s sõnumit" + "one": "Näita veel %(count)s vestlust", + "other": "Näita veel %(count)s vestlust" }, "show_previews": "Näita sõnumite eelvaateid", "sort_by": "Järjestamisviis", @@ -1993,6 +2178,8 @@ "error_deleting_alias_description": "Selle aadressi kustutamisel tekkis viga. See kas juba on kustutatud või tekkis ajutine tõrge.", "error_deleting_alias_description_forbidden": "Sinul pole õigusi selle aadressi kustutamiseks.", "error_deleting_alias_title": "Viga aadresi kustutamisel", + "error_publishing": "Jututoa avaldamine ei õnnestunud", + "error_publishing_detail": "Jututoa avaldamisel tekkis viga", "error_save_space_settings": "Kogukonnakeskuse seadistuste salvestamine ei õnnestunud.", "error_updating_alias_description": "Jututoa lisaaadressi uuendamisel tekkis viga. See kas pole serveris lubatud või tekkis mingi ajutine viga.", "error_updating_canonical_alias_description": "Jututoa põhiaadressi uuendamisel tekkis viga. See kas pole serveris lubatud või tekkis mingi ajutine viga.", @@ -2029,6 +2216,12 @@ "upload_sound_label": "Laadi üles oma helifail", "uploaded_sound": "Üleslaaditud heli" }, + "people": { + "knock_empty": "Päringuid pole", + "knock_section": "Soovides liitumist", + "see_less": "Näita vähem", + "see_more": "Näita rohkem" + }, "permissions": { "add_privileged_user_description": "Lisa selles jututoas ühele või mitmele kasutajale täiendavaid õigusi", "add_privileged_user_filter_placeholder": "Vali kasutajad sellest jututoast…", @@ -2056,7 +2249,7 @@ "m.room.history_visibility": "Muuda vestlusajaloo nähtavust", "m.room.name": "Muuda jututoa nime", "m.room.name_space": "Muuda kogukonna nime", - "m.room.pinned_events": "Halda klammerdatud sündmusi", + "m.room.pinned_events": "Halda esiletõstetud sündmusi", "m.room.power_levels": "Muuda õigusi", "m.room.redaction": "Eemalda minu saadetud sõnumid", "m.room.server_acl": "Muuda serveri ligipääsuõigusi", @@ -2212,6 +2405,8 @@ "access_token_detail": "Sinu pääsuluba annab täismahulise ligipääsu sinu kasutajakontole. Palun ära jaga seda teistega.", "brand_version": "%(brand)s'i versioon:", "clear_cache_reload": "Tühjenda puhver ja laadi uuesti", + "crypto_version": "Krüpto versioon:", + "dialog_title": "Seadistused: Abiteave ja info meie kohta", "help_link": "Kui otsid lisateavet %(brand)s'i kasutamise kohta, palun vaata siia.", "homeserver": "Koduserveri aadress %(homeserverUrl)s", "identity_server": "Isikutuvastusserveri aadress %(identityServerUrl)s", @@ -2220,22 +2415,35 @@ } }, "settings": { + "account": { + "dialog_title": "Seadistused: Kasutajakonto", + "title": "Kasutajakonto" + }, "all_rooms_home": "Näita kõiki jututubasid avalehel", "all_rooms_home_description": "Kõik sinu jututoad on nähtavad avalehel.", "always_show_message_timestamps": "Alati näita sõnumite ajatempleid", "appearance": { + "bundled_emoji_font": "Kasuta rakendusega kaasa pandud emotikonide kirjatüüpi", + "compact_layout": "Näita teksti ja sõnumeid kompaktsena", + "compact_layout_description": "Selle eelistuse jaoks pead kasutama moodsat paigutust.", "custom_font": "Kasuta süsteemset fonti", "custom_font_description": "Vali sinu seadmes leiduv fondi nimi ning %(brand)s proovib seda kasutada.", "custom_font_name": "Süsteemse fondi nimi", "custom_font_size": "Kasuta kohandatud suurust", - "custom_theme_error_downloading": "Viga teema teabefaili allalaadimisel.", - "custom_theme_invalid": "Vigane teemafail.", + "custom_theme_add": "Lisa kohandatud kujundus", + "custom_theme_downloading": "Laadime alla kohandatud kujundust…", + "custom_theme_error_downloading": "Viga kujunduse allalaadimisel", + "custom_theme_help": "Sisesta kohandatud kujunduse aadress.", + "custom_theme_invalid": "Vigane kujundusefail.", + "dialog_title": "Seadistused: Välimus", "font_size": "Fontide suurus", + "font_size_default": "%(fontSize)s (vaikimisi)", + "high_contrast": "Kontrastne kujundus", "image_size_default": "Tavaline", "image_size_large": "Suur", "layout_bubbles": "Jutumullid", - "layout_irc": "IRC (katseline)", - "match_system_theme": "Kasuta süsteemset teemat", + "layout_irc": "IRC (katseline )", + "match_system_theme": "Kasuta süsteemset kujundust", "timeline_image_size": "Piltide suurus ajajoonel" }, "automatic_language_detection_syntax_highlight": "Kasuta süntaksi esiletõstmisel automaatset keeletuvastust", @@ -2245,9 +2453,60 @@ "code_block_expand_default": "Vaikimisi kuva koodiblokid tervikuna", "code_block_line_numbers": "Näita koodiblokkides reanumbreid", "disable_historical_profile": "Sõnumite ajaloos leiduvate kasutajate puhul näita kehtivat tunnuspilti ning nime", + "discovery": { + "title": "Kuidas on võimalik sind leida" + }, "emoji_autocomplete": "Näita kirjutamise ajal emoji-soovitusi", "enable_markdown": "Kasuta Markdown-süntaksit", "enable_markdown_description": "Vormindamata teksti koostamiseks alusta sõnumeid /plain käsuga.", + "encryption": { + "advanced": { + "breadcrumb_first_description": "Sinu kasutajakonto andmed, kontaktid, eelistused ja vestluste loend säiluvad", + "breadcrumb_page": "Lähtesta krüptimine", + "breadcrumb_second_description": "Sa kaotad ligipääsu sõnumite ajalooole, mis on salvestatud vaid serveris", + "breadcrumb_third_description": "Sa pead kõik oma olemasolevad seadmed ja kontaktid uuesti verifitseerima", + "breadcrumb_title": "Kas sa oled kindel, et soovid oma krüptoidentiteeti lähtestada?", + "breadcrumb_warning": "Tee seda ainult siis, kui arvad, et sinu kasutajakonto võib olla ohustatud kolmandate osapoolet poolt.", + "details_title": "Krüptimise üksikasjad", + "export_keys": "Ekspordi võtmed", + "import_keys": "Impordi võtmed", + "other_people_device_description": "Vaikimisi ei saadeta krüptitud jututubadest sõnumeid verifitseerimata kasutajatele", + "other_people_device_label": "Ära iialgi saada krüptitud sõnumeid verifitseerimata seadmetesse", + "other_people_device_title": "Teiste kasutajate seadmed", + "reset_identity": "Lähtesta krüptoidentiteet", + "session_id": "Sessiooni tunnus:", + "session_key": "Sessioonivõti:", + "title": "Täiendav teave" + }, + "device_not_verified_button": "Verifitseeri see seade", + "device_not_verified_description": "Oma krüptoseadistuste nägemiseks palun verifitseeri see seade.", + "device_not_verified_title": "Seade on verifitseerimata", + "dialog_title": "Seadistused:Krüptimine", + "recovery": { + "change_recovery_confirm_button": "Kinnita uus taastevõti", + "change_recovery_confirm_description": "Toimingu lõpetamiseks palun sisesta alljärgnevalt oma uus taastevõti. Senine taastevõti enam ei toimi.", + "change_recovery_confirm_title": "Sisesta oma taastevõti", + "change_recovery_key": "Muuda taastevõtit", + "change_recovery_key_description": "Palun salvesta see taastevõti turvalisel viisil. Muutuse kinnitamiseks klõpsi „Jätka“.", + "change_recovery_key_title": "Kas muudame taastevõtit?", + "description": "Kui sa oled kaotanud ligipääsu kõikidele oma olemasolevatele seadmetele, siis sa saad taastevõtme abil taastada ligipääsu oma krüptoidentiteedile ja sõnumite ajaloole.", + "enter_key_error": "Sinu sisestatud taastevõti pole korrektne.", + "enter_recovery_key": "Sisesta taastevõti", + "key_storage_warning": "Sinu võtmehoidla pole sünkroonis. Vea parandamiseks palun klõpsi järgnevat nuppu.", + "save_key_description": "Ära jaga seda mitte kellegagi!", + "save_key_title": "Taastevõti", + "set_up_recovery": "Seadista taastamine", + "set_up_recovery_confirm_button": "Lõpeta seadistamine", + "set_up_recovery_confirm_description": "Taastamise seadistamise lõpetamiseks palun sisesta eelmises vaates näidatud taastevõti.", + "set_up_recovery_confirm_title": "Kinnitamiseks sisesta oma taastevõti", + "set_up_recovery_description": "Sinu krüptovõtmete hoidlat kaitseb taastevõti. Kui peale seadistamist peaksid vajama uut taastevõtit, siis saad ta uuesti luua valikust „%(changeRecoveryKeyButton)s“.", + "set_up_recovery_save_key_description": "Palun märgi see taastevõti üles ja hoia teda turvaliselt, näiteks digitaalses salasõnalaekas, krüptitud märkmetes või vana kooli seifis.", + "set_up_recovery_save_key_title": "Palun salvesta oma taastevõti turvalisel viisil", + "set_up_recovery_secondary_description": "Kui klõpsid nuppu „Jätka“, loome me sulle uue taastevõtme.", + "title": "Taastamine" + }, + "title": "Krüptimine" + }, "general": { "account_management_section": "Kontohaldus", "account_section": "Kasutajakonto", @@ -2260,6 +2519,14 @@ "add_msisdn_dialog_title": "Lisa telefoninumber", "add_msisdn_instructions": "Saatsime tekstisõnumi numbrile +%(msisdn)s. Palun sisesta seal kuvatud kontrollkood.", "add_msisdn_misconfigured": "„Add“ ja „bind“ meetodid MSISDN jaoks on valesti seadistatud", + "allow_spellcheck": "Kasuta õigekontrolli", + "application_language": "Rakenduse keel", + "application_language_reload_hint": "Teise keele valimisel rakendus käivitub uuesti", + "avatar_remove_progress": "Eemaldame pilti...", + "avatar_save_progress": "Laadime pilti üles...", + "avatar_upload_error_text": "Failivormingu tugi puudub või fail on suurem, kui %(size)s.", + "avatar_upload_error_text_generic": "See failivorming ei pruugi olla toetatud.", + "avatar_upload_error_title": "Tunnuspildi faili üleslaadimine ei õnnestunud", "confirm_adding_email_body": "Klõpsi järgnevat nuppu e-posti aadressi lisamise kinnitamiseks.", "confirm_adding_email_title": "Kinnita e-posti aadressi lisamine", "deactivate_confirm_body": "Kas sa oled kindel, et soovid oma konto sulgeda? Seda tegevust ei saa hiljem tagasi pöörata.", @@ -2275,10 +2542,13 @@ "deactivate_confirm_erase_label": "Peida minu sõnumid uute liitujate eest", "deactivate_section": "Deaktiveeri konto", "deactivate_warning": "Kuna kasutajakonto dektiveerimist ei saa tagasi pöörata, siis palun ole ettevaatlik!", - "discovery_email_empty": "Otsinguvõimaluste loend kuvatakse, kui oled ülale sisestanud e-posti aadressi.", + "discovery_email_empty": "Otsinguvõimaluste loend kuvatakse, kui oled sisestanud e-posti aadressi.", "discovery_email_verification_instructions": "Verifitseeri klõpsides viidet saabunud e-kirjas", - "discovery_msisdn_empty": "Otsinguvõimaluste loend kuvatakse, kui oled ülale sisestanud telefoninumbri.", + "discovery_msisdn_empty": "Otsinguvõimaluste loend kuvatakse, kui oled sisestanud telefoninumbri.", "discovery_needs_terms": "Selleks, et sind võiks leida e-posti aadressi või telefoninumbri alusel, nõustu isikutuvastusserveri (%(serverName)s) kasutustingimustega.", + "discovery_needs_terms_title": "Võimalda teistel Matrixi võrgu kasutajatel sind leida", + "display_name": "Kuvatav nimi", + "display_name_error": "Kuvatava nime määramine ei õnnestu", "email_address_in_use": "See e-posti aadress on juba kasutusel", "email_address_label": "E-posti aadress", "email_not_verified": "Sinu e-posti aadress pole veel verifitseeritud", @@ -2303,7 +2573,7 @@ "error_share_msisdn_discovery": "Telefoninumbri jagamine ei õnnestunud", "identity_server_no_token": "Ei leidu tunnusluba isikutuvastusserveri jaoks", "identity_server_not_set": "Isikutuvastusserver on määramata", - "language_section": "Keel ja piirkond", + "language_section": "Keel", "msisdn_in_use": "See telefoninumber on juba kasutusel", "msisdn_label": "Telefoninumber", "msisdn_verification_field_label": "Verifikatsioonikood", @@ -2312,9 +2582,15 @@ "oidc_manage_button": "Halda kasutajakontot", "password_change_section": "Määra kontole uus salasõna…", "password_change_success": "Sinu salasõna muutmine õnnestus.", + "personal_info": "Isiklik teave", + "profile_subtitle": "Nii kuvatakse sind teistele selle rakenduse ja Matrixi võrgu kasutajatele.", + "profile_subtitle_oidc": "Sinu kasutajakontot hallatakse eraldi isikutuvastusserveris ning kõiki sinu isiklikke andmeid ei saa siin muuta.", "remove_email_prompt": "Eemalda %(email)s?", "remove_msisdn_prompt": "Eemalda %(phone)s?", - "spell_check_locale_placeholder": "Vali lokaat" + "spell_check_locale_placeholder": "Vali lokaat", + "unable_to_load_emails": "E-posti aadresside laadimine ei õnnestu", + "unable_to_load_msisdns": "Telefoninumbrite laadimine ei õnnestu", + "username": "Kasutajanimi" }, "image_thumbnails": "Näita piltide eelvaateid või väikepilte", "inline_url_previews_default": "Luba URL'ide vaikimisi eelvaated", @@ -2370,12 +2646,20 @@ "phrase_strong_enough": "Suurepärane! See paroolifraas on piisavalt kange" }, "keyboard": { + "dialog_title": "Seadistused: Klaviatuur", "title": "Klaviatuur" }, + "labs": { + "dialog_title": "Seadistused: Katsed" + }, + "labs_mjolnir": { + "dialog_title": "Seadistused: Eiratud kasutajad" + }, "notifications": { "default_setting_description": "See seadistus kehtib vaikimisi kõikides sinu jututubades.", "default_setting_section": "Soovin teavitusi (vaikimisi seadistused)", "desktop_notification_message_preview": "Näita sõnumi eelvaadet töölauakeskkonnale omases teavituses", + "dialog_title": "Seadistused: Teavitused", "email_description": "Palu saata e-posti teel ülevaade märkamata teavitustest", "email_section": "E-kirja kokkuvõte", "email_select": "Vali e-posti aadressid, millele soovid kokkuvõtet saada. E-posti aadresse saad hallata seadistuste alajaotuses .", @@ -2428,17 +2712,21 @@ "voip": "Kõned ja videokõned" }, "preferences": { + "Electron.enableHardwareAcceleration": "Kasuta riistvaralist kiirendust (jõustamiseks käivita %(appName)s uuesti)", "always_show_menu_bar": "Näita aknas alati menüüriba", "autocomplete_delay": "Viivitus automaatsel sõnalõpetusel (ms)", "code_blocks_heading": "Lähtekoodi lõigud", "compact_modern": "Kasuta kompaktsemat moodsat kasutajaliidest", "composer_heading": "Sõnumite kirjutamine", + "default_timezone": "Brauseri vaikimisi ajavöönd (%(timezone)s)", + "dialog_title": "Seadistused: Eelistused", "enable_hardware_acceleration": "Kasuta riistvaralist kiirendust", "enable_tray_icon": "Näita süsteemisalve ikooni ja Element'i akna sulgemisel minimeeri ta salve", "keyboard_heading": "Kiirklahvid", "keyboard_view_shortcuts_button": "Vaata siit kõiki kiirklahve.", "media_heading": "Pildid, gif'id ja videod", "presence_description": "Jaga teistega oma olekut ja tegevusi.", + "publish_timezone": "Avalda oma ajavööd oma avalikus profiilis", "rm_lifetime": "Lugemise markeri iga (ms)", "rm_lifetime_offscreen": "Lugemise markeri iga, kui Element pole fookuses (ms)", "room_directory_heading": "Jututubade loend", @@ -2446,13 +2734,15 @@ "show_avatars_pills": "Näita tunnuspilte kasutajate, jututubade ja sündmuste mainimistes", "show_polls_button": "Näita küsitluste nuppu", "surround_text": "Erimärkide sisestamisel märgista valitud tekst", - "time_heading": "Aegade kuvamine" + "time_heading": "Aegade kuvamine", + "user_timezone": "Seadista ajavöönd" }, "prompt_invite": "Hoiata enne kutse saatmist võimalikule vigasele Matrix'i kasutajatunnusele", "replace_plain_emoji": "Automaatelt asenda vormindamata tekst emotikoniga", "security": { "4s_public_key_in_account_data": "kasutajakonto andmete hulgas", "4s_public_key_status": "Turvahoidla avalik võti:", + "analytics_description": "Vigade tuvastamiseks palun jaga meiega anonüümseid andmeid. Isiklikke andmeid me ei kogu ja kolmandad osapooled ei ole sellega seotud.", "backup_key_cached_status": "Varukoopia võti on puhverdatud:", "backup_key_stored_status": "Varukoopia võti on salvestatud:", "backup_key_unexpected_type": "tundmatut tüüpi", @@ -2476,8 +2766,11 @@ "cross_signing_self_signing_private_key": "Sinu privaatvõtmed:", "cross_signing_user_signing_private_key": "Kasutaja privaatvõti:", "cryptography_section": "Krüptimine", + "dehydrated_device_description": "Võrguühenduseta seadme funktsionaalsus võimaldab saada krüptitud sõnumeid ka siis, kui sa pole ühtegi seadmesse sisse loginud", + "dehydrated_device_enabled": "Võrguühenduseta seadme funktsionaalsus on sisse lülitatud", "delete_backup": "Kustuta varukoopia", "delete_backup_confirm_description": "Kas sa oled kindel? Kui sul muud varundust pole, siis kaotad ligipääsu oma krüptitud sõnumitele.", + "dialog_title": "Seadistused: Turvalisus ja privaatsus", "e2ee_default_disabled_warning": "Sinu serveri haldur on lülitanud läbiva krüptimise omavahelistes jututubades ja otsesõnumites välja.", "enable_message_search": "Võta kasutusele sõnumite otsing krüptitud jututubades", "encryption_section": "Krüptimine", @@ -2487,14 +2780,17 @@ "ignore_users_section": "Eiratud kasutajad", "import_megolm_keys": "Impordi E2E läbiva krüptimise võtmed jututubade jaoks", "key_backup_active": "See sessioon varundab sinu krüptovõtmeid.", + "key_backup_active_version": "Aktiivse varukoopia versioon:", "key_backup_active_version_none": "Ei ühelgi juhul", "key_backup_algorithm": "Algoritm:", + "key_backup_can_be_restored": "Seda varukoopiat saab sellesse sessiooni taastada", "key_backup_complete": "Kõik krüptovõtmed on varundatud", "key_backup_connect": "Seo see sessioon krüptovõtmete varundusega", "key_backup_connect_prompt": "Enne väljalogimist seo see sessioon krüptovõtmete varundusega. Kui sa seda ei tee, siis võid kaotada võtmed, mida kasutatakse vaid siin sessioonis.", "key_backup_in_progress": "Varundan %(sessionsRemaining)s krüptovõtmeid…", "key_backup_inactive": "See sessioon ei varunda sinu krüptovõtmeid, aga sul on olemas varundus, millest saad taastada ning millele saad võtmeid lisada.", "key_backup_inactive_warning": "Sinu selle sessiooni krüptovõtmeid ei varundata.", + "key_backup_latest_version": "Serveris leiduv viimane varukoopia:", "message_search_disable_warning": "Kui see seadistus pole kasutusel, siis krüptitud jututubade sõnumeid otsing ei vaata.", "message_search_disabled": "Turvaliselt puhverda krüptitud sõnumid kohalikku arvutisse ja võimalda kasutada neid otsingus.", "message_search_enabled": { @@ -2527,6 +2823,7 @@ "send_read_receipts_unsupported": "Sinu koduserver ei võimalda lugemisteatiste keelamist.", "send_typing_notifications": "Anna märku teisele osapoolele, kui mina sõnumit kirjutan", "sessions": { + "best_security_note": "Parima turvalisuse nimel verifitseeri kõik oma sessioonid ning logi välja neist, mida sa enam ei kasuta või ei tunne ära.", "browser": "Brauser", "confirm_sign_out": { "one": "Kinnita selle seadme väljalogimine", @@ -2551,6 +2848,7 @@ "device_unverified_description_current": "Turvalise sõnumivahetuse nimel palun verifitseeri oma praegune sessioon.", "device_verified_description": "See sessioon on valmis turvaliseks sõnumivahetuseks.", "device_verified_description_current": "Sinu praegune sessioon on valmis turvaliseks sõnumivahetuseks.", + "dialog_title": "Seadistused: Sessioonid", "error_pusher_state": "Tõuketeavituste teenuse oleku määramine ei õnnestunud", "error_set_name": "Sessiooni nime määramine ei õnnestunud", "filter_all": "Kõik", @@ -2567,6 +2865,7 @@ "inactive_sessions_list_description": "Võimalusel logi välja vanadest seanssidest (%(inactiveAgeDays)s päeva või vanemad), mida sa enam ei kasuta.", "ip": "IP-aadress", "last_activity": "Viimati kasutusel", + "manage": "Halda seda sessiooni", "mobile_session": "Nutirakendus", "n_sessions_selected": { "one": "%(count)s sessioon valitud", @@ -2590,9 +2889,10 @@ "security_recommendations_description": "Kui järgid neid soovitusi, siis sa parandad oma kasutajakonto turvalisust.", "session_id": "Sessiooni tunnus", "show_details": "Näita üksikasju", - "sign_in_with_qr": "Logi sisse QR-koodi abil", + "sign_in_with_qr": "Seosta uus seade", "sign_in_with_qr_button": "Näita QR-koodi", - "sign_in_with_qr_description": "Sa saad kasutada seda seadet mõne muu seadme logimiseks Matrix'i võrku QR-koodi alusel. Selleks skaneeri võrgust väljalogitud seadmega seda QR-koodi.", + "sign_in_with_qr_description": "Kasuta QR-koodi teise seadmesse sisse logimiseks ja turvalise sõnumivahetuse seadistamiseks.", + "sign_in_with_qr_unsupported": "Seda võimalust ei toeta sinu teenusepakkuja", "sign_out": "Logi sellest sessioonist välja", "sign_out_all_other_sessions": "Logi kõikidest ülejäänud sessioonidest välja: %(otherSessionsCount)s sessioon(i)", "sign_out_confirm_description": { @@ -2613,6 +2913,7 @@ "unverified_sessions_explainer_1": "Kontrollimata sessioonid on sessioonid, kuhu on sinu volitustega sisse logitud, kuid mida ei ole risttuvastamisega kontrollitud.", "unverified_sessions_explainer_2": "Kuna nende näol võib olla tegemist võimaliku konto volitamata kasutamisega, siis palun tee kindlaks, et need sessioonid on sulle tuttavad.", "unverified_sessions_list_description": "Turvalise sõnumvahetuse nimel verifitseeri kõik oma sessioonid ning logi neist välja, mida sa enam ei kasuta või ei tunne enam ära.", + "url": "URL", "verified_session": "Verifitseeritud sessioon", "verified_sessions": "Verifitseeritud sessioonid", "verified_sessions_explainer_1": "Verifitseeritud sessioonideks loetakse Element'is või mõnes muus Matrix'i rakenduses selliseid sessioone, kus sa kas oled sisestanud oma salafraasi või tuvastanud end mõne teise oma verifitseeritud sessiooni abil.", @@ -2631,7 +2932,9 @@ "show_redaction_placeholder": "Näita kustutatud sõnumite asemel kohatäidet", "show_stickers_button": "Näita kleepsude nuppu", "show_typing_notifications": "Anna märku, kui teine osapool sõnumit kirjutab", + "showbold": "Näita üldist aktiivsust jututubade loendis (punktidena või lugemata sõnumite arvuna)", "sidebar": { + "dialog_title": "Seadistused: Külgpaan", "metaspaces_favourites_description": "Koonda oma olulised sõbrad ning lemmikjututoad ühte kohta.", "metaspaces_home_all_rooms": "Näita kõiki jututubasid", "metaspaces_home_all_rooms_description": "Näita kõiki oma jututubasid avalehel ka siis kui nad on osa mõnest kogukonnast.", @@ -2640,9 +2943,14 @@ "metaspaces_orphans_description": "Koonda ühte kohta kõik oma jututoad, mis ei kuulu mõnda kogukonda.", "metaspaces_people_description": "Koonda oma olulised sõbrad ühte kohta.", "metaspaces_subsection": "Näidatavad kogukonnakeskused", + "metaspaces_video_rooms": "Videojututoad ja -konverentsid", + "metaspaces_video_rooms_description": "Rühmita kõik privaatsed videotoad ja -konverentsid", + "metaspaces_video_rooms_description_invite_extension": "Konverentsiosalejaid võid kutsuda ka väljastpoolt Matrix'i võrku", + "spaces_explainer": "Kogukonnad on võimalus jututubade ja inimeste ühendamiseks. Lisaks nendele, mille liige sa juba olev, võid kasutada süsteemi poolt loodud kogukondi.", "title": "Külgpaan" }, "start_automatically": "Käivita Element automaatselt peale arvutisse sisselogimist", + "tac_only_notifications": "Näita teavitusi vaid jutulõngade ülevaates", "use_12_hour_format": "Näita ajatempleid 12-tunnises vormingus (näiteks 2:30pl)", "use_command_enter_send_message": "Sõnumi saatmiseks vajuta Command + Enter klahve", "use_command_f_search": "Ajajoonelt otsimiseks kasuta Command+F klahve", @@ -2656,6 +2964,7 @@ "audio_output_empty": "Ei leidnud ühtegi heliväljundit", "auto_gain_control": "Automaatne esitusvaljuse tundlikkus", "connection_section": "Ühendus", + "dialog_title": "Seadistused: Heli ja video", "echo_cancellation": "Kaja eemaldamine", "enable_fallback_ice_server": "Varuvariandina luba kasutada ka teist kõnehõlbustusserverit (%(server)s)", "enable_fallback_ice_server_description": "On kasutusel vaid siis, kui sinu koduserver sellist teenust ei võimalda. Seeläbi jagatakse kõne ajal sinu seadme IP-aadressi.", @@ -2674,8 +2983,12 @@ "warning": "HOIATUS: " }, "share": { + "link_copied": "Link on kopeeritud", "permalink_message": "Viide valitud sõnumile", "permalink_most_recent": "Viide kõige viimasele sõnumile", + "share_call": "Konverentsikõne kutse", + "share_call_subtitle": "Link välistele kasutajatele, kes saavad kõnega liituda nii, et neil ei pea olema Matrix'i kontot:", + "title_link": "Jaga linki", "title_message": "Jaga jututoa sõnumit", "title_room": "Jaga jututuba", "title_user": "Jaga viidet kasutaja kohta" @@ -2701,7 +3014,9 @@ "devtools": "Avab arendusvahendite akna", "discardsession": "Sunnib loobuma praeguse krüptitud jututoa rühmavestluse seansist", "error_invalid_rendering_type": "Viga käsu täitmisel: visualiseerimise tüüpi ei leidu (%(renderingType)s)", + "error_invalid_room": "Käsu täitmine ei õnnestunud: Ei suuda leida jututuba (%(roomId)s)", "error_invalid_runfn": "Viga käsu täitmisel: Kaldkriipsuga käsku ei ole võimalik töödelda.", + "error_invalid_user_in_room": "Jututoast ei õnnestu leida kasutajat", "help": "Näitab käskude loendit koos kirjeldustega", "help_dialog_title": "Abiteave käskude kohta", "holdcall": "Jätab kõne selles jututoas ootele", @@ -2846,7 +3161,7 @@ "network_dropdown_available_valid": "Tundub õige", "network_dropdown_remove_server_adornment": "Eemalda server „%(roomServer)s“", "network_dropdown_required_invalid": "Sisesta serveri nimi", - "network_dropdown_selected_label": "Näita: Matrix'i jututoad", + "network_dropdown_selected_label": "Näita: Matrixi jututube", "network_dropdown_selected_label_instance": "Näita: %(instance)s jututuba %(server)s serveris", "network_dropdown_your_server_description": "Sinu server" } @@ -2861,6 +3176,7 @@ }, "create_new_room_button": "Loo uus jututuba", "failed_querying_public_rooms": "Avalike jututubade tuvastamise päring ei õnnestunud", + "failed_querying_public_spaces": "Päring avalike kogukondade tuvastamiseks ei õnnestunud", "group_chat_section_title": "Muud valikud", "heading_with_query": "Otsinguks kasuta „%(query)s“", "heading_without_query": "Otsingusõna", @@ -2869,6 +3185,7 @@ "message_search_section_title": "Muud otsingud", "other_rooms_in_space": "Muud jututoad %(spaceName)s kogukonnad", "public_rooms_label": "Avalikud jututoad", + "public_spaces_label": "Avalikud kogukonnad", "recent_searches_section_title": "Hiljutised otsingud", "recently_viewed_section_title": "Hiljuti vaadatud", "remove_filter": "Eemalda otsingufilter „%(filter)s“", @@ -2912,12 +3229,22 @@ "one": "%(count)s vastus", "other": "%(count)s vastust" }, + "empty_description": "Sõnumi kohtmenüüst vali „%(replyInThread)s“", + "empty_title": "Jutulõngad aitavad hoida vestlusi teemakohastena ja hallatavatena.", "error_start_thread_existing_relation": "Jutulõnga ei saa luua sõnumist, mis juba on jutulõnga osa", + "mark_all_read": "Märgi kõik loetuks", "my_threads": "Minu jutulõngad", "my_threads_description": "Näitab kõiki jutulõngasid, kus sa oled osalenud", "open_thread": "Ava jutulõng", "show_thread_filter": "Näita:" }, + "threads_activity_centre": { + "header": "Jutulõngade ülevaade", + "no_rooms_with_threads_notifs": "Pole veel ühtegi jutulõngakohase teavitusega jututuba.", + "no_rooms_with_unread_threads": "Pole veel ühtegi lugemata jutulõngaga jututuba.", + "release_announcement_description": "Jutulõngade teavitused leiduvad nüüd uues kohas. Nüüd leiad nad siit.", + "release_announcement_header": "Jutulõngade ülevaade" + }, "time": { "about_day_ago": "umbes päev tagasi", "about_hour_ago": "umbes tund aega tagasi", @@ -2959,8 +3286,21 @@ }, "creation_summary_dm": "%(creator)s alustas seda otsesuhtlust.", "creation_summary_room": "%(creator)s lõi ja seadistas jututoa.", + "decryption_failure": { + "blocked": "Saatja on blokeerinud võimaluse, et sa saaksid selle sõnumi", + "historical_event_no_key_backup": "Varasemad sõnumid pole selles seadmes loetavad", + "historical_event_unverified_device": "Varasemate sõnumite nägemiseks pead selle seadme verifitseerima", + "historical_event_user_not_joined": "Sul puudub ligipääs sellele sõnumile", + "sender_identity_previously_verified": "Verifitseeritud võrguidentiteet on muutunud", + "sender_unsigned_device": "Krüptitud seadme poolt, mida tema omanik pole verifitseerinud.", + "unable_to_decrypt": "Sõnumi dekrüptimine ei õnnestu" + }, + "disambiguated_profile": "%(displayName)s (%(matrixId)s)", "download_action_decrypting": "Dekrüptin sisu", "download_action_downloading": "Laadin alla", + "download_failed": "Allalaadimine ei õnnestunud", + "download_failed_description": "Selle faili allalaadimisel tekkis viga", + "e2e_state": "Läbiva krüptimise olek", "edits": { "tooltip_label": "Muudetud %(date)s. Klõpsi et näha varasemaid versioone.", "tooltip_sub": "Muudatuste nägemiseks klõpsi", @@ -2971,6 +3311,7 @@ "historical_messages_unavailable": "Sa ei saa näha varasemaid sõnumeid", "in_room_name": " %(room)s jututoas", "io.element.widgets.layout": "%(senderName)s on uuendanud jututoa välimust", + "late_event_separator": "Algselt saadetud %(dateTime)s", "load_error": { "no_permission": "Üritasin laadida teatud hetke selle jututoa ajajoonelt, kuid sul ei ole õigusi selle sõnumi nägemiseks.", "title": "Asukoha laadimine ajajoonel ei õnnestunud", @@ -3013,7 +3354,7 @@ }, "m.file": { "error_decrypting": "Viga manuse dekrüptimisel", - "error_invalid": "Vigane fail %(extra)s" + "error_invalid": "Vigane fail" }, "m.image": { "error": "Vea tõttu ei ole võimalik pilti kuvada", @@ -3136,7 +3477,7 @@ "unpinned_link": "%(senderName)s eemaldas siin jututoas klammerduse ühelt sõnumilt. Vaata kõiki klammerdatud sõnumeid." }, "m.room.power_levels": { - "changed": "%(senderName)s muutis %(powerLevelDiffText)s õigusi.", + "changed": "%(senderName)s muutis õiguseid: %(powerLevelDiffText)s.", "user_from_to": "%(userId)s õigused muutusid: %(fromPowerLevel)s -> %(toPowerLevel)s" }, "m.room.server_acl": { @@ -3171,6 +3512,8 @@ "label": "Tegevused sõnumitega", "view_in_room": "Vaata jututoas" }, + "message_timestamp_received_at": "Saabumise aeg: %(dateTime)s", + "message_timestamp_sent_at": "Saatmise aeg: %(dateTime)s", "mjolnir": { "changed_rule_glob": "%(senderName)s muutis %(reason)s tõttu ligipääsukeelu reegli algset tingimust %(oldGlob)s uueks tingimuseks %(newGlob)s", "changed_rule_rooms": "%(senderName)s muutis %(reason)s tõttu jututubade ligipääsukeelu reegli algset tingimust %(oldGlob)s uueks tingimuseks %(newGlob)s", @@ -3197,7 +3540,9 @@ "pending_moderation_reason": "Sõnum on modereerimise ootel: %(reason)s", "reactions": { "add_reaction_prompt": "Lisa reaktsioon", - "label": "%(reactors)s kasutajat reageeris järgnevalt: %(content)s" + "custom_reaction_fallback_label": "Kohandatud reaktsioon", + "label": "%(reactors)s kasutajat reageeris järgnevalt: %(content)s", + "tooltip_caption": "kasutas reageerimiseks %(shortName)s" }, "read_receipt_title": { "one": "Seda nägi %(count)s lugeja", @@ -3247,6 +3592,7 @@ "other": "Mitu kasutajat %(severalUsers)s muutsid oma nime %(count)s korda", "one": "Mitu kasutajat %(severalUsers)s muutsid oma nime" }, + "format": "%(nameList)s %(transitionList)s", "hidden_event": { "one": "%(oneUser)s saatis ühe peidetud sõnumi", "other": "%(oneUser)s saatis %(count)s peidetud sõnumit" @@ -3312,12 +3658,12 @@ "one": "%(severalUsers)s ei teinud muudatusi" }, "pinned_events": { - "one": "%(oneUser)s muutis selle jututoa klammerdatud sõnumeid", - "other": "%(oneUser)s muutis jututoa klammerdatud sõnumeid %(count)s korda" + "one": "%(oneUser)s muutis selle jututoa esiletõstetud sõnumeid", + "other": "%(oneUser)s muutis selle jututoa esiletõstetud sõnumeid %(count)s korda" }, "pinned_events_multiple": { - "one": "%(severalUsers)s muutsid selle jututoa klammerdatud sõnumeid", - "other": "%(severalUsers)s muutsid jututoa klammerdatud sõnumeid %(count)s korda" + "one": "%(severalUsers)s muutsid selle jututoa esiletõstetud sõnumeid", + "other": "%(severalUsers)s muutsid selle jututoa esiletõstetud sõnumeid %(count)s korda" }, "redacted": { "one": "%(oneUser)s kustutas sõnumi", @@ -3381,6 +3727,10 @@ "truncated_list_n_more": { "other": "Ja %(count)s muud..." }, + "unsupported_browser": { + "description": "Kui sa jätkad, siis mõni funktsionaalsus ei pruugi toimida ja on märgatav risk, et sa võid tulevikus andmeid kaotada. Kasutamaks veebirakendust %(brand)s, palun uuenda on veebibrauserit.", + "title": "%(brand)s ei toimi selle veebibrauseriga" + }, "unsupported_server_description": "See server kasutab Matrixi vanemat versiooni. Selleks, et %(brand)s'i kasutamisel vigu ei tekiks palun uuenda serverit nii, et kasutusel oleks Matrixi %(version)s.", "unsupported_server_title": "Sinu server ei ole toetatud", "update": { @@ -3398,6 +3748,12 @@ "toast_title": "Uuenda %(brand)s rakendust", "unavailable": "Ei ole saadaval" }, + "update_room_access_modal": { + "description": "Osalemiseks mõeldud lingi loomiseks on vaja, et külalised saavad selle jututoaga liituda. See aga võib muuta jututoa vähem turvaliseks. Kui sa oled aga kõne lõpetanud, siis saad soovi korral jututoa uuesti muuta privaatseks.", + "dont_change_description": "Teise võimalusena saad helistada eraldi jututoas", + "no_change": "Ma ei soovi muuta õigusi jututoas.", + "title": "Muuda õigusi jututoas" + }, "upload_failed_generic": "Faili '%(fileName)s' üleslaadimine ei õnnestunud.", "upload_failed_size": "Faili '%(fileName)s' suurus ületab serveris seadistatud üleslaadimise piiri", "upload_failed_title": "Üleslaadimine ei õnnestunud", @@ -3407,6 +3763,7 @@ "error_files_too_large": "Need failid on üleslaadimiseks liiga suured. Failisuuruse piir on %(limit)s.", "error_some_files_too_large": "Mõned failid on üleslaadimiseks liiga suured. Failisuuruse piir on %(limit)s.", "error_title": "Üleslaadimise viga", + "not_image": "Sinu valitud fail pole korrektne pildifail.", "title": "Laadi failid üles", "title_progress": "Laadin faile üles (%(current)s / %(total)s)", "upload_all_button": "Laadi kõik üles", @@ -3433,6 +3790,7 @@ "deactivate_confirm_action": "Deaktiveeri kasutaja", "deactivate_confirm_description": "Kasutaja deaktiveerimisel logitakse ta automaatselt välja ning ei lubata enam sisse logida. Lisaks lahkub ta kõikidest jututubadest, mille liige ta parasjagu on. Seda tegevust ei saa tagasi pöörata. Kas sa oled ikka kindel, et soovid selle kasutaja kõijkalt eemaldada?", "deactivate_confirm_title": "Kas deaktiveerime kasutajakonto?", + "dehydrated_device_enabled": "Võrguühenduseta seadme funktsionaalsus on sisse lülitatud", "demote_button": "Vähenda enda õigusi", "demote_self_confirm_description_space": "Kuna sa vähendad enda õigusi, siis sul ei pruugi hiljem olla võimalik seda muutust tagasi pöörata. Kui sa juhtumisi oled viimane haldusõigustega kasutaja kogukonnakeskuses, siis hiljem on võimatu samu õigusi tagasi saada.", "demote_self_confirm_room": "Kuna sa vähendad enda õigusi, siis sul ei pruugi hiljem olla võimalik seda muutust tagasi pöörata. Kui sa juhtumisi oled viimane haldusõigustega kasutaja jututoas, siis hiljem on võimatu samu õigusi tagasi saada.", @@ -3449,6 +3807,7 @@ "error_revoke_3pid_invite_title": "Kutse tühistamine ei õnnestunud", "hide_sessions": "Peida sessioonid", "hide_verified_sessions": "Peida verifitseeritud sessioonid", + "ignore_button": "Eira", "ignore_confirm_description": "Kõik selle kasutaja sõnumid ja kutsed saava olema peidetud. Kas sa oled kindel, et soovid teda eirata?", "ignore_confirm_title": "Eira kasutajat %(user)s", "invited_by": "Kutsutud %(sender)s poolt", @@ -3476,26 +3835,29 @@ "no_recent_messages_description": "Vaata kas ajajoonel ülespool leidub varasemaid sõnumeid.", "no_recent_messages_title": "Kasutajalt %(user)s ei leitud hiljutisi sõnumeid" }, - "redact_button": "Eemalda hiljutised sõnumid", + "redact_button": "Eemalda sõnumid", "revoke_invite": "Tühista kutse", "room_encrypted": "See jututuba on läbivalt krüptitud.", "room_encrypted_detail": "Sinu sõnumid on turvatud ning ainult sinul ja saaja(te)l on unikaalsed võtmed selliste sõnumite lugemiseks.", "room_unencrypted": "See jututuba ei ole läbivalt krüptitud.", "room_unencrypted_detail": "Krüptitud jututubades sinu sõnumid on turvatud ning vaid sinul ja sõnumi saajal on unikaalsed võtmed nende kuvamiseks.", - "share_button": "Jaga viidet kasutaja kohta", + "send_message": "Saada sõnum", + "share_button": "Jaga profiili", "unban_button_room": "Eemalda suhtluskeeld jututoas", "unban_button_space": "Eemalda suhtluskeeld kogukonnas", "unban_room_confirm_title": "Eemalda suhtluskeeld %(roomName)s jututoas", "unban_space_everything": "Eemalda kasutajalt suhtluskeeld kõikjalt, kust ma saan", "unban_space_specific": "Eemalda kasutajalt suhtluskeeld valitud kohtadest, kust ma saan", "unban_space_warning": "Kasutaja ei saa ligi kohtadele, kus sul pole peakasutaja õigusi.", + "unignore_button": "Lõpeta eiramine", "verify_button": "Verifitseeri kasutaja", "verify_explainer": "Lisaturvalisus mõttes verifitseeri see kasutaja võrreldes selleks üheks korraks loodud koodi mõlemas seadmes." }, "user_menu": { + "link_new_device": "Seo uus seade", "settings": "Kõik seadistused", - "switch_theme_dark": "Kasuta tumedat teemat", - "switch_theme_light": "Kasuta heledat teemat" + "switch_theme_dark": "Kasuta tumedat kujundust", + "switch_theme_light": "Kasuta heledat kujundust" }, "voip": { "already_in_call": "Kõne on juba pooleli", @@ -3516,6 +3878,7 @@ "camera_disabled": "Sinu seadme kaamera on välja lülitatud", "camera_enabled": "Sinu seadme kaamera on jätkuvalt kasutusel", "cannot_call_yourself_description": "Sa ei saa iseendale helistada.", + "close_lobby": "Sulge ooteruum", "connecting": "Kõne on ühendamisel", "connection_lost": "Ühendus sinu serveriga on katkenud", "connection_lost_description": "Kui ühendus sinu serveriga on katkenud, siis sa ei saa helistada.", @@ -3529,15 +3892,24 @@ "disabled_no_perms_start_video_call": "Sul ei ole piisavalt õigusi videokõne alustamiseks", "disabled_no_perms_start_voice_call": "Sul ei ole piisavalt õigusi häälkõne alustamiseks", "disabled_ongoing_call": "Kõne on pooleli", + "element_call": "Element Call", "enable_camera": "Lülita kaamera sisse", "enable_microphone": "Eemalda mikrofoni summutamine", "expand": "Pöördu tagasi kõne juurde", + "get_call_link": "Jaga kõne linki", "hangup": "Katkesta kõne", "hide_sidebar_button": "Peida külgpaan", "input_devices": "Sisendseadmed", + "jitsi_call": "Jitsi-põhine kõne", "join_button_tooltip_call_full": "Vabandust, selles kõnes ei saa rohkem osalejaid olla", "join_button_tooltip_connecting": "Kõne on ühendamisel", + "legacy_call": "Vana lahendusega kõne", "maximise": "Täida ekraan", + "maximise_call": "Tee kõneaken suureks", + "metaspace_video_rooms": { + "conference_room_section": "Konverentsid" + }, + "minimise_call": "Tee kõneaken väikeseks", "misconfigured_server": "Kõne ebaõnnestus valesti seadistatud serveri tõttu", "misconfigured_server_description": "Palu oma koduserveri haldajat (%(homeserverDomain)s), et ta seadistaks kõnede kindlamaks toimimiseks TURN serveri.", "misconfigured_server_fallback": "Alternatiivina võid sa kasutada avalikku serverit , kuid see ei pruugi olla piisavalt töökindel ning sa jagad ka oma IP-aadressi selle serveriga. Täpsemalt saad seda määrata seadistustes.", @@ -3585,6 +3957,7 @@ "user_is_presenting": "%(sharerName)s esitab", "video_call": "Videokõne", "video_call_started": "Videokõne algas", + "video_call_using": "Videokõne, kus on kasutusel:", "voice_call": "Häälkõne", "you_are_presenting": "Sina esitad" }, @@ -3693,14 +4066,14 @@ "title": "Luba sellel vidinal sinu isikut verifitseerida" }, "popout": "Ava rakendus eraldi aknas", - "set_room_layout": "Kasuta minu jututoa paigutust kõigi jaoks", + "set_room_layout": "Kasuta paigutust kõigi jaoks", "shared_data_avatar": "Sinu tunnuspildi URL", "shared_data_device_id": "Sinu seadme tunnus", "shared_data_lang": "Sinu keel", "shared_data_mxid": "Sinu kasutajatunnus", "shared_data_name": "Sinu kuvatav nimi", "shared_data_room_id": "Jututoa tunnus", - "shared_data_theme": "Sinu teema", + "shared_data_theme": "Sinu kujundus", "shared_data_url": "%(brand)s'i aadress", "shared_data_warning": "Selle vidina kasutamisel võidakse jagada andmeid saitidega %(widgetDomain)s.", "shared_data_warning_im": "Selle vidina kasutamisel võidakse jagada andmeid %(widgetDomain)s saitidega ning sinu lõiminguhalduriga.", diff --git a/src/i18n/strings/fi.json b/src/i18n/strings/fi.json index f42a1af569..70ee03fcd2 100644 --- a/src/i18n/strings/fi.json +++ b/src/i18n/strings/fi.json @@ -1,5 +1,6 @@ { "a11y": { + "emoji_picker": "Emoji-valitsin", "jump_first_invite": "Siirry ensimmäiseen kutsuun.", "n_unread_messages": { "other": "%(count)s lukematonta viestiä.", @@ -9,6 +10,7 @@ "other": "%(count)s lukematonta viestiä, sisältäen maininnat.", "one": "Yksi lukematon maininta." }, + "recent_rooms": "Viimeisimmät huoneet", "room_name": "Huone %(name)s", "unread_messages": "Lukemattomat viestit.", "user_menu": "Käyttäjän valikko" @@ -21,11 +23,13 @@ "add_people": "Lisää ihmisiä", "apply": "Toteuta", "approve": "Hyväksy", + "ask_to_join": "Pyydä liittyä", "back": "Takaisin", "call": "Soita", "cancel": "Peruuta", "change": "Muuta", "clear": "Tyhjennä", + "click": "Napsauta", "click_to_copy": "Kopioi napsauttamalla", "close": "Sulje", "collapse": "Supista", @@ -39,6 +43,7 @@ "create_account": "Luo tili", "decline": "Hylkää", "delete": "Poista", + "deny": "Estä", "disable": "Poista käytöstä", "disconnect": "Katkaise yhteys", "dismiss": "Hylkää", @@ -76,10 +81,12 @@ "new_video_room": "Uusi videohuone", "next": "Seuraava", "no": "Ei", + "ok": "OK", "open": "Avaa", "pause": "Keskeytä", "pin": "Nuppineula", "play": "Toista", + "proceed": "Jatka", "quote": "Lainaa", "react": "Reagoi", "refresh": "Päivitä", @@ -100,6 +107,7 @@ "save": "Tallenna", "search": "Haku", "send_report": "Lähetä ilmoitus", + "set_avatar": "Aseta profiilikuva", "share": "Jaa", "show": "Näytä", "show_advanced": "Näytä lisäasetukset", @@ -123,6 +131,7 @@ "update": "Päivitä", "upgrade": "Päivitä", "upload": "Lähetä", + "upload_file": "Lähetä tiedosto", "verify": "Varmenna", "view": "Näytä", "view_all": "Näytä kaikki", @@ -213,6 +222,7 @@ }, "misconfigured_body": "Pyydä %(brand)s-ylläpitäjääsi tarkistamaan, onko asetuksissasivirheellisiä tai toistettuja merkintöjä.", "misconfigured_title": "%(brand)sin asetukset ovat pielessä", + "mobile_create_account_title": "Olet aikeissa luoda tilin palveluun %(hsName)s", "msisdn_field_description": "Muut voivat kutsua sinut huoneisiin yhteystietojesi avulla", "msisdn_field_label": "Puhelin", "msisdn_field_number_invalid": "Tämä puhelinnumero ei näytä oikealta, tarkista se ja yritä uudelleen", @@ -220,6 +230,7 @@ "no_hs_url_provided": "Kotipalvelimen osoite puuttuu", "oidc": { "error_title": "Emme voineet kirjata sinua sisään", + "generic_auth_error": "Jokin meni pieleen tunnistautumisen aikana. Siirry kirjautumissivulle ja yritä uudelleen.", "missing_or_invalid_stored_state": "Pyysimme selainta muistamaan kirjautumista varten mitä kotipalvelinta käytät, mutta selain on unohtanut sen. Mene kirjautumissivulle ja yritä uudelleen." }, "password_field_keep_going_prompt": "Jatka…", @@ -229,8 +240,40 @@ "phone_label": "Puhelin", "phone_optional_label": "Puhelin (valinnainen)", "qr_code_login": { + "check_code_explainer": "Tämä varmistaa, että yhteys toiseen laitteeseen on turvallinen.", + "check_code_heading": "Kirjoita toisessa laitteessa näkyvä numero", + "check_code_input_label": "2-numeroinen koodi", + "check_code_mismatch": "Numerot eivät täsmää", + "completing_setup": "Viimeistellään uuden laitteesi käyttöönottoa", + "error_etag_missing": "Tapahtui odottamaton virhe. Tämä voi johtua selaimen laajennuksesta, välityspalvelimesta tai palvelimen virheellisestä konfiguroinnista.", + "error_expired": "Kirjautuminen vanhentui. Yritä uudelleen.", + "error_expired_title": "Kirjautumista ei suoritettu ajoissa", + "error_insecure_channel_detected": "Turvallista yhteyttä uuteen laitteeseen ei voitu muodostaa. Olemassa olevat laitteesi ovat edelleen turvassa, eikä sinun tarvitse huolehtia niistä.", + "error_insecure_channel_detected_instructions": "Mitä nyt?", + "error_insecure_channel_detected_instructions_1": "Yritä kirjautua toiseen laitteeseen uudelleen QR-koodilla, jos kyseessä oli verkko-ongelma", + "error_insecure_channel_detected_instructions_2": "Jos kohtaat saman ongelman, kokeile toista wifi-verkkoa tai käytä mobiilidataa wifi-yhteyden sijaan", + "error_insecure_channel_detected_instructions_3": "Jos tämä ei auta, kirjaudu sisään manuaalisesti", + "error_insecure_channel_detected_title": "Yhteys ei ole turvallinen", + "error_other_device_already_signed_in": "Sinun ei tarvitse tehdä mitään muuta.", + "error_other_device_already_signed_in_title": "Toinen laitteesi on jo kirjautunut sisään", "error_rate_limited": "Liikaa yrityksiä lyhyessä ajassa. Odota hetki, ennen kuin yrität uudelleen.", "error_unexpected": "Tapahtui odottamaton virhe.", + "error_unsupported_protocol": "Tämä laite ei tue kirjautumista toiseen laitteeseen QR-koodilla.", + "error_unsupported_protocol_title": "Toinen laite ei ole yhteensopiva", + "error_user_cancelled": "Kirjautuminen peruutettiin toisella laitteella.", + "error_user_cancelled_title": "Kirjautumispyyntö peruutettu", + "error_user_declined": "Sinä tai palveluntarjoajasi hylkäsi kirjautumispyynnön.", + "error_user_declined_title": "Kirjautuminen hylätty", + "follow_remaining_instructions": "Noudata jäljellä olevia ohjeita", + "open_element_other_device": "Avaa %(brand)s toisella laitteellasi", + "point_the_camera": "Skannaa tässä näkyvä QR-koodi", + "scan_code_instruction": "Skannaa QR-koodi toisella laitteella", + "scan_qr_code": "Kirjaudu sisään QR-koodilla", + "security_code": "Turvakoodi", + "security_code_prompt": "Anna pyydettäessä alla oleva koodi toisella laitteellasi.", + "select_qr_code": "Valitse \"%(scanQRCode)s\"", + "unsupported_explainer": "Palveluntarjoajasi ei tue kirjautumista uuteen laitteeseen QR-koodilla.", + "unsupported_heading": "QR-koodia ei tueta", "waiting_for_device": "Odotetaan laitteen sisäänkirjautumista" }, "register_action": "Luo tili", @@ -257,6 +300,7 @@ "sign_out_other_devices": "Kirjaudu ulos kaikista laitteista" }, "reset_password_action": "Nollaa salasana", + "reset_password_button": "Unohditko salasanan?", "reset_password_email_field_description": "Voit palauttaa tilisi sähköpostiosoitteen avulla", "reset_password_email_field_required_invalid": "Syötä sähköpostiosoite (vaaditaan tällä kotipalvelimella)", "reset_password_email_not_found_title": "Sähköpostiosoitetta ei löytynyt", @@ -283,6 +327,7 @@ }, "set_email_prompt": "Haluatko asettaa sähköpostiosoitteen?", "sign_in_description": "Käytä tiliäsi jatkaaksesi.", + "sign_in_instead": "Kirjaudu sen sijaan", "sign_in_instead_prompt": "Onko sinulla jo tili? Kirjaudu tästä", "sign_in_or_register": "Kirjaudu sisään tai luo tili", "sign_in_or_register_description": "Käytä tiliäsi tai luo uusi jatkaaksesi.", @@ -312,6 +357,7 @@ "email_resend_prompt": "Etkö saanut sitä? Lähetä uudelleen", "email_resent": "Lähetetty uudelleen!", "fallback_button": "Aloita tunnistus", + "mas_cross_signing_reset_cta": "Siirry tilillesi", "msisdn": "Tekstiviesti lähetetty numeroon %(msisdn)s", "msisdn_token_incorrect": "Väärä tunniste", "msisdn_token_prompt": "Ole hyvä ja syötä sen sisältämä koodi:", @@ -394,6 +440,7 @@ "beta": "Beeta", "camera": "Kamera", "cameras": "Kamerat", + "cancel": "Peruuta", "capabilities": "Kyvykkyydet", "copied": "Kopioitu!", "credits": "Maininnat", @@ -404,6 +451,7 @@ "device": "Laite", "edited": "muokattu", "email_address": "Sähköpostiosoite", + "emoji": "Emoji", "encrypted": "Salattu", "encryption_enabled": "Salaus käytössä", "error": "Virhe", @@ -427,10 +475,13 @@ "legal": "Lakitekstit", "light": "Vaalea", "loading": "Ladataan…", + "lobby": "Aula", "location": "Sijainti", "low_priority": "Matala prioriteetti", + "matrix": "Matrix", "message": "Viesti", "message_layout": "Viestien asettelu", + "message_timestamp_invalid": "Virheellinen aikaleima", "microphone": "Mikrofoni", "model": "Malli", "modern": "Moderni", @@ -472,6 +523,8 @@ "room": "Huone", "room_name": "Huoneen nimi", "rooms": "Huoneet", + "save": "Tallenna", + "saved": "Tallennettu", "saving": "Tallennetaan…", "secure_backup": "Turvallinen varmuuskopio", "security": "Tietoturva", @@ -500,12 +553,14 @@ "unnamed_room": "Nimeämätön huone", "unnamed_space": "Nimetön avaruus", "unverified": "Vahvistamaton", + "updating": "Päivitetään...", "user": "Käyttäjä", "user_avatar": "Profiilikuva", "username": "Käyttäjätunnus", "verification_cancelled": "Varmennus peruutettu", "verified": "Vahvistettu", "version": "Versio", + "video": "Video", "video_room": "Videohuone", "view_message": "Näytä viesti", "warning": "Varoitus" @@ -534,8 +589,10 @@ "format_italic": "Kursivointi", "format_italics": "Kursivoitu", "format_link": "Linkki", + "format_ordered_list": "Numeroitu luettelo", "format_strikethrough": "Yliviivattu", "format_underline": "Alleviivaus", + "format_unordered_list": "Järjestämätön luettelo", "formatting_toolbar_label": "Muotoilu", "link_modal": { "link_field_label": "Linkki", @@ -621,11 +678,13 @@ "private_space_description": "Yksityinen avaruus sinulle ja tiimikavereille", "public_description": "Avoin avaruus kaikille, paras yhteisöille", "public_heading": "Julkinen avaruutesi", + "search_public_button": "Etsi julkisia avaruuksia", "setup_rooms_community_description": "Tehdään huone jokaiselle.", "setup_rooms_community_heading": "Mistä asioista haluat puhua avaruudessa %(spaceName)s?", "setup_rooms_description": "Voit lisätä niitä myöhemmin, mukaan lukien olemassa olevia.", "setup_rooms_private_description": "Luomme huoneet jokaiselle niistä.", "setup_rooms_private_heading": "Minkä projektien parissa tiimisi työskentelee?", + "share_description": "Vain sinä tällä hetkellä, vielä parempi muiden kanssa.", "share_heading": "Jaa %(name)s", "skip_action": "Ohita tältä erää", "subspace_adding": "Lisätään…", @@ -667,8 +726,12 @@ "no_receipt_found": "Kuittausta ei löytynyt", "number_of_users": "Käyttäjämäärä", "original_event_source": "Alkuperäinen tapahtumalähde", + "room_encrypted": "Huone on salattu ✅", "room_id": "Huoneen ID-tunniste: %(roomId)s", + "room_not_encrypted": "Huone ei ole salattu 🚨", "room_notifications_sender": "Lähettäjä: ", + "room_notifications_total": "Yhteensä: ", + "room_notifications_type": "Tyyppi: ", "save_setting_values": "Tallenna asetusarvot", "server_info": "Palvelimen tiedot", "server_versions": "Palvelinversiot", @@ -815,6 +878,7 @@ "qr_reciprocate_same_shield_device": "Melkein valmista! Näyttääkö toinen laitteesi saman kilven?", "qr_reciprocate_same_shield_user": "Melkein valmista! Näyttääkö %(displayName)s saman kilven?", "request_toast_accept": "Vahvista istunto", + "request_toast_accept_user": "Vahvista käyttäjä", "request_toast_decline_counter": "Sivuuta (%(counter)s)", "request_toast_detail": "%(deviceId)s osoitteesta %(ip)s", "sas_caption_self": "Vahvista tämä laite toteamalla, että seuraava numero näkyy sen näytöllä.", @@ -925,6 +989,13 @@ "dm_send": "Odotetaan vastausta", "user": "%(senderName)s aloitti puhelun", "you": "Aloitit puhelun" + }, + "prefix": { + "audio": "Ääni", + "file": "Tiedosto", + "image": "Kuva", + "poll": "Kysely", + "video": "Video" } }, "export_chat": { @@ -958,9 +1029,12 @@ }, "fetching_events": "Noudetaan tapahtumia…", "file_attached": "Tiedosto liitetty", + "format": "Muoto", "from_the_beginning": "Alusta lähtien", "generating_zip": "Luodaan ZIPiä", + "html": "HTML", "include_attachments": "Sisällytä liitteet", + "json": "JSON", "media_omitted": "Media jätetty pois", "media_omitted_file_size": "Media jätetty pois – tiedoston kokoraja ylitetty", "messages": "Viestit", @@ -1041,7 +1115,14 @@ "other": "Avaruudessa %(spaceName)s ja %(count)s muussa avaruudessa." }, "incompatible_browser": { - "title": "Selainta ei tueta" + "continue": "Jatka silti", + "detail_no_continue": "Yritä päivittää tämä selain, jos et käytä uusinta versiota, ja yritä uudelleen.", + "learn_more": "Lue lisää", + "linux": "Linux", + "macos": "Mac", + "supported_browsers": "Saat parhaan käyttökokemuksen käyttämällä Chromea, Firefoxia, Edgeä tai Safaria.", + "title": "Selainta ei tueta", + "windows": "Windows (%(bits)s-bit)" }, "info_tooltip_title": "Tiedot", "integration_manager": { @@ -1191,7 +1272,9 @@ "currently_experimental": "Tällä hetkellä kokeellinen.", "custom_themes": "Tue mukaututettujen teemojen lisäämistä", "dynamic_room_predecessors_description": "Ota käyttöön MSC3946 (viiveellä saapuvien huonearkistojen tukemiseksi)", + "element_call_video_rooms": "Element Call -videohuoneet", "experimental_section": "Ennakot", + "extended_profiles_msc_support": "Edellyttää, että palvelimesi tukee MSC4133:a", "group_calls": "Uusi ryhmäpuhelukokemus", "group_developer": "Kehittäjä", "group_encryption": "Salaus", @@ -1203,6 +1286,7 @@ "group_spaces": "Avaruudet", "group_themes": "Teemat", "group_threads": "Ketjut", + "group_ui": "Käyttöliittymä", "group_voip": "Ääni ja video", "group_widgets": "Sovelmat", "html_topic": "Näytä huoneiden aiheiden HTML-esitys", @@ -1215,6 +1299,9 @@ "location_share_live_description": "Tilapäinen toteutus. Sijainnit säilyvät huoneen historiassa.", "mjolnir": "Uusia tapoja jättää ihmiset huomiotta", "msc3531_hide_messages_pending_moderation": "Anna moderaattorien piilottaa moderointia odottavia viestejä.", + "notification_settings": "Uudet ilmoitusasetukset", + "notification_settings_beta_title": "Ilmoitusasetukset", + "release_announcement": "Julkaisutiedote", "report_to_moderators": "Ilmoita moderaattoreille", "report_to_moderators_description": "Moderointia tukevissa huoneissa väärinkäytökset voi ilmoittaa Ilmoita-painikkeella huoneen moderaattoreille.", "sliding_sync": "Liukuvan synkronoinnin tila", @@ -1328,6 +1415,7 @@ "class_global": "Yleiset", "class_other": "Muut", "default": "Oletus", + "email_pusher_app_display_name": "Sähköposti-ilmoitukset", "enable_prompt_toast_description": "Ota työpöytäilmoitukset käyttöön", "enable_prompt_toast_title": "Ilmoitukset", "enable_prompt_toast_title_from_message_send": "Älä jätä vastauksia huomiotta", @@ -1473,10 +1561,24 @@ }, "right_panel": { "add_integrations": "Lisää sovelmia, siltoja ja botteja", + "add_topic": "Lisää aihe", + "extensions_button": "Laajennukset", + "extensions_empty_description": "Valitse ”%(addIntegrations)s” selataksesi ja lisätäksesi laajennuksia tähän huoneeseen", + "extensions_empty_title": "Paranna tuottavuutta työkaluilla, widgeteillä ja boteilla", "files_button": "Tiedostot", "pinned_messages": { + "header": { + "one": "1 kiinnitetty viesti", + "other": "%(count)s kiinnitettyä viestiä" + }, "limits": { "other": "Voit kiinnittää enintään %(count)s sovelmaa" + }, + "menu": "Avaa valikko", + "release_announcement": { + "close": "OK", + "description": "Kiinnitetyt viestit löytyvät täältä. Mene minkä tahansa viestin päälle ja valitse \"Kiinnitä\" lisätäksesi viestin tänne.", + "title": "Kiinnitetyt viestit" } }, "pinned_messages_button": "Kiinnitetty", @@ -1533,6 +1635,7 @@ "forget": "Unohda huone", "low_priority": "Matala prioriteetti", "mark_read": "Merkitse luetuksi", + "mark_unread": "Merkitse lukemattomaksi", "notifications_mute": "Mykistä huone", "title": "Huoneen asetukset", "unfavourite": "Suositut" @@ -1567,6 +1670,7 @@ "header": { "room_is_public": "Tämä huone on julkinen" }, + "header_avatar_open_settings_label": "Avaa huoneen asetukset", "inaccessible": "Tämä huone tai avaruus ei ole käytettävissä juuri tällä hetkellä.", "inaccessible_name": "%(roomName)s ei ole saatavilla tällä hetkellä.", "intro": { @@ -1611,6 +1715,8 @@ "kick_reason": "Syy: %(reason)s", "kicked_by": "%(memberName)s poisti sinut", "kicked_from_room_by": "%(memberName)s poisti sinut huoneesta %(roomName)s", + "knock_cancel_action": "Peruuta pyyntö", + "knock_message_field_placeholder": "Viesti (valinnainen)", "leave_error_title": "Virhe poistuessa huoneesta", "leave_server_notices_description": "Tämä huone on kotipalvelimen tärkeille viesteille, joten ei voi poistua siitä.", "leave_server_notices_title": "Palvelinilmoitushuonetta ei voitu jättää", @@ -1623,9 +1729,20 @@ "not_found_title": "Tätä huonetta tai avaruutta ei ole olemassa.", "not_found_title_name": "Huonetta %(roomName)s ei ole olemassa.", "peek_join_prompt": "Esikatselet huonetta %(roomName)s. Haluatko liittyä siihen?", + "pinned_message_badge": "Kiinnitetty viesti", + "pinned_message_banner": { + "button_view_all": "Näytä kaikki" + }, "read_topic": "Lue aihe napsauttamalla", "rejecting": "Hylätään kutsua…", "rejoin_button": "Liity uudelleen", + "search": { + "placeholder": "Etsi viestejä...", + "summary": { + "one": "1 tulos haulle “”", + "other": "%(count)s tulosta haulle “”" + } + }, "status_bar": { "delete_all": "Poista kaikki", "exceeded_resource_limit": "Viestiäsi ei lähetetty, koska tämä kotipalvelin on ylittänyt resurssirajan. Ota yhteyttä palvelun ylläpitäjään jatkaaksesi palvelun käyttämistä.", @@ -1751,6 +1868,8 @@ "error_deleting_alias_description": "Osoitetta poistaessa tapahtui virhe. Osoitetta ei ehkä ole enää olemassa tai kyseessä oli tilapäinen virhe.", "error_deleting_alias_description_forbidden": "Sinulla ei ole oikeutta poistaa osoitetta.", "error_deleting_alias_title": "Virhe osoitetta poistettaessa", + "error_publishing": "Huonetta ei voi julkaista", + "error_publishing_detail": "Tämän huoneen julkaisemisessa tapahtui virhe", "error_save_space_settings": "Avaruuden asetusten tallentaminen epäonnistui.", "error_updating_alias_description": "Huoneen vaihtoehtoisten osoitteiden päivittämisessä tapahtui virhe. Palvelin ei ehkä salli sitä tai kyseessä oli tilapäinen virhe.", "error_updating_canonical_alias_description": "Huoneen pääosoitteen päivityksessä tapahtui virhe. Se ei välttämättä ole sallittua tällä palvelimella tai kyseessä on väliaikainen virhe.", @@ -1783,8 +1902,14 @@ "notification_sound": "Ilmoitusääni", "settings_link": "Vastaanota ilmoitukset asetuksissa määrittämälläsi tavalla", "sounds_section": "Äänet", + "upload_sound_label": "Lähetä mukautettu ääni", "uploaded_sound": "Asetettu ääni" }, + "people": { + "knock_empty": "Ei pyyntöjä", + "see_less": "Näytä vähemmän", + "see_more": "Näytä enemmän" + }, "permissions": { "add_privileged_user_filter_placeholder": "Etsi käyttäjiä tästä huoneesta…", "ban": "Anna porttikieltoja", @@ -1954,6 +2079,7 @@ "access_token_detail": "Käyttöpolettisi (ns. token) antaa täyden pääsyn tilillesi. Älä jaa sitä kenenkään kanssa.", "brand_version": "%(brand)s-versio:", "clear_cache_reload": "Tyhjennä välimuisti ja lataa uudelleen", + "dialog_title": "Asetukset: Ohje ja tietoja", "help_link": "Saadaksesi apua %(brand)sin käyttämisessä, napsauta tästä.", "homeserver": "Kotipalvelin on %(homeserverUrl)s", "identity_server": "Identiteettipalvelin on %(identityServerUrl)s", @@ -1962,17 +2088,27 @@ } }, "settings": { + "account": { + "dialog_title": "Asetukset: Tili", + "title": "Tili" + }, "all_rooms_home": "Näytä kaikki huoneet etusivulla", "all_rooms_home_description": "Kaikki huoneet, joissa olet, näkyvät etusivulla.", "always_show_message_timestamps": "Näytä aina viestien aikaleimat", "appearance": { + "compact_layout": "Näytä tiiviit tekstit ja viestit", "custom_font": "Käytä järjestelmän fonttia", "custom_font_description": "Aseta käyttöjärjestelmääsi asennetun fontin nimi, niin %(brand)s pyrkii käyttämään sitä.", "custom_font_name": "Järjestelmän fontin nimi", "custom_font_size": "Käytä mukautettua kokoa", + "custom_theme_add": "Lisää mukautettu teema", + "custom_theme_downloading": "Ladataan mukautettua teemaa...", "custom_theme_error_downloading": "Virhe ladattaessa teematietoa.", "custom_theme_invalid": "Epäkelpo teeman skeema.", + "dialog_title": "Asetukset: Ulkoasu", "font_size": "Fontin koko", + "font_size_default": "%(fontSize)s (oletus)", + "high_contrast": "Suuri kontrasti", "image_size_default": "Oletus", "image_size_large": "Suuri", "layout_bubbles": "Viestikuplat", @@ -1999,6 +2135,12 @@ "add_msisdn_confirm_sso_button": "Vahvista tämän puhelinnumeron lisääminen todistamalla henkilöllisyytesi kertakirjautumista käyttäen.", "add_msisdn_dialog_title": "Lisää puhelinnumero", "add_msisdn_instructions": "Tekstiviesti on lähetetty numeroon +%(msisdn)s. Syötä siinä oleva varmistuskoodi.", + "allow_spellcheck": "Salli oikeinkirjoituksen tarkistus", + "application_language": "Sovelluksen kieli", + "application_language_reload_hint": "Sovellus käynnistyy uudelleen, kun valitset toisen kielen", + "avatar_remove_progress": "Poistetaan kuva...", + "avatar_save_progress": "Lähetetään kuva…", + "avatar_upload_error_text": "Tiedostomuoto ei ole tuettu tai kuva on suurempi kuin %(size)s.", "confirm_adding_email_body": "Napsauta alapuolella olevaa painiketta lisätäksesi tämän sähköpostiosoitteen.", "confirm_adding_email_title": "Vahvista sähköpostin lisääminen", "deactivate_confirm_body": "Haluatko varmasti poistaa tilisi pysyvästi?", @@ -2017,6 +2159,9 @@ "discovery_email_verification_instructions": "Varmista sähköpostiisi saapunut linkki", "discovery_msisdn_empty": "Etsinnän asetukset näkyvät sen jälkeen, kun olet lisännyt puhelinnumeron.", "discovery_needs_terms": "Hyväksy identiteettipalvelimen (%(serverName)s) käyttöehdot, jotta sinut voi löytää sähköpostiosoitteen tai puhelinnumeron perusteella.", + "discovery_needs_terms_title": "Anna ihmisten löytää sinut", + "display_name": "Näyttönimi", + "display_name_error": "Näyttönimeä ei voi asettaa", "email_address_in_use": "Tämä sähköpostiosoite on jo käytössä", "email_address_label": "Sähköpostiosoite", "email_not_verified": "Sähköpostiosoitettasi ei ole vielä varmistettu", @@ -2048,9 +2193,13 @@ "oidc_manage_button": "Hallitse tiliä", "password_change_section": "Aseta uusi tilin salasana…", "password_change_success": "Salasanasi vaihtaminen onnistui.", + "personal_info": "Henkilökohtaiset tiedot", + "profile_subtitle": "Näytät tältä muille sovelluksessa.", "remove_email_prompt": "Poista %(email)s?", "remove_msisdn_prompt": "Poista %(phone)s?", - "spell_check_locale_placeholder": "Valitse maa-asetusto" + "spell_check_locale_placeholder": "Valitse maa-asetusto", + "unable_to_load_emails": "Sähköpostiosoitteita ei voi ladata", + "username": "Käyttäjätunnus" }, "image_thumbnails": "Näytä kuvien esikatselut/pienoiskuvat", "inline_url_previews_default": "Ota linkkien esikatselu käyttöön oletusarvoisesti", @@ -2060,6 +2209,7 @@ "jump_to_bottom_on_send": "Siirry aikajanan pohjalle, kun lähetät viestin", "key_backup": { "backup_in_progress": "Avaimiasi varmuuskopioidaan (ensimmäinen varmuuskopio voi viedä muutaman minuutin).", + "backup_starting": "Aloitetaan varmuuskopiointia…", "backup_success": "Onnistui!", "cannot_create_backup": "Avaimen varmuuskopiota ei voi luoda", "create_title": "Luo avaimen varmuuskopio", @@ -2096,12 +2246,18 @@ "import_description_2": "Viety tiedosto suojataan salasanalla. Syötä salasana tähän purkaaksesi tiedoston salauksen.", "import_title": "Tuo huoneen avaimet", "phrase_cannot_be_empty": "Salasana ei saa olla tyhjä", - "phrase_must_match": "Salasanojen on täsmättävä" + "phrase_must_match": "Salasanojen on täsmättävä", + "phrase_strong_enough": "Hienoa! Tämä tunnuslause näyttää riittävän vahvalta" }, "keyboard": { + "dialog_title": "Asetukset: Näppäimistö", "title": "Näppäimistö" }, + "labs": { + "dialog_title": "Asetukset: Laboratorio" + }, "notifications": { + "dialog_title": "Asetukset: Ilmoitukset", "enable_audible_notifications_session": "Ota käyttöön ääni-ilmoitukset tälle istunnolle", "enable_desktop_notifications_session": "Ota käyttöön työpöytäilmoitukset tälle istunnolle", "enable_email_notifications": "Sähköposti-ilmoitukset osoitteeseen %(email)s", @@ -2114,6 +2270,8 @@ "error_saving": "Virhe tallentaessa ilmoitusasetuksia", "error_saving_detail": "Ilmoitusasetuksia tallentaessa tapahtui virhe.", "error_title": "Ilmoitusten käyttöönotto epäonnistui", + "mentions_keywords": "Maininnat ja avainsanat", + "mentions_keywords_only": "Vain maininnat ja avainsanat", "messages_containing_keywords": "Viestit, jotka sisältävät avainsanoja", "noisy": "Äänekäs", "push_targets": "Ilmoituksen kohteet", @@ -2128,7 +2286,8 @@ "rule_roomnotif": "Viestit, jotka sisältävät sanan ”@room”", "rule_suppress_notices": "Bottien lähettämät viestit", "rule_tombstone": "Kun huoneet päivitetään", - "show_message_desktop_notification": "Näytä viestit ilmoituskeskuksessa" + "show_message_desktop_notification": "Näytä viestit ilmoituskeskuksessa", + "voip": "Ääni- ja videopuhelut" }, "preferences": { "always_show_menu_bar": "Näytä aina ikkunan valikkorivi", @@ -2136,18 +2295,21 @@ "code_blocks_heading": "Koodilohkot", "compact_modern": "Käytä entistä kompaktimpaa, \"Modernia\", asettelua", "composer_heading": "Viestin kirjoitus", + "default_timezone": "Selaimen oletus (%(timezone)s)", "enable_hardware_acceleration": "Ota laitteistokiihdytys käyttöön", "enable_tray_icon": "Näytä ilmaisinalueen kuvake ja pienennä ikkuna siihen suljettaessa", "keyboard_heading": "Pikanäppäimet", "keyboard_view_shortcuts_button": "Katso kaikki pikanäppäimet napsauttamalla tästä.", "media_heading": "Kuvat, GIF:t ja videot", "presence_description": "Jaa toimintasi ja tilasi muiden kanssa.", + "publish_timezone": "Julkaise aikavyöhyke julkisessa profiilissa", "rm_lifetime": "Viestin luetuksi merkkaamisen kesto (ms)", "rm_lifetime_offscreen": "Viestin luetuksi merkkaamisen kesto, kun Element ei ole näkyvissä (ms)", "room_directory_heading": "Huoneluettelo", "room_list_heading": "Huoneluettelo", "show_polls_button": "Näytä kyselypainike", - "time_heading": "Ajan näyttäminen" + "time_heading": "Ajan näyttäminen", + "user_timezone": "Aseta aikavyöhyke" }, "prompt_invite": "Kysy varmistus ennen kutsujen lähettämistä mahdollisesti epäkelpoihin Matrix ID:hin", "replace_plain_emoji": "Korvaa automaattisesti teksimuotoiset emojit", @@ -2176,6 +2338,7 @@ "cryptography_section": "Salaus", "delete_backup": "Poista varmuuskopio", "delete_backup_confirm_description": "Oletko varma? Et voi lukea salattuja viestejäsi, mikäli avaimesi eivät ole kunnolla varmuuskopioituna.", + "dialog_title": "Asetukset: Tietoturva ja yksityisyys", "e2ee_default_disabled_warning": "Palvelimesi ylläpitäjä on poistanut päästä päähän -salauksen oletuksena käytöstä yksityisissä huoneissa ja yksityisviesteissä.", "enable_message_search": "Ota viestihaku salausta käyttävissä huoneissa käyttöön", "encryption_section": "Salaus", @@ -2245,6 +2408,7 @@ "device_unverified_description": "Vahvista tämä istunto tai kirjaudu ulos siitä tietoturvan ja luotettavuuden parantamiseksi.", "device_verified_description": "Tämä istunto on valmis turvallista viestintää varten.", "device_verified_description_current": "Nykyinen istuntosi on valmis turvalliseen viestintään.", + "dialog_title": "Asetukset: Istunnot", "filter_all": "Kaikki", "filter_inactive": "Passiivinen", "filter_inactive_description": "Passiivinen %(inactiveAgeDays)s päivää tai pidempään", @@ -2282,6 +2446,8 @@ "show_details": "Näytä yksityiskohdat", "sign_in_with_qr": "Kirjaudu sisään QR-koodilla", "sign_in_with_qr_button": "Näytä QR-koodi", + "sign_in_with_qr_description": "Kirjaudu QR-koodin avulla toiseen laitteeseen ja määritä suojattu viestinvälitys.", + "sign_in_with_qr_unsupported": "Palveluntarjoajasi ei tue tätä", "sign_out": "Kirjaudu ulos tästä istunnosta", "sign_out_all_other_sessions": "Kirjaudu ulos kaikista muista istunnoista (%(otherSessionsCount)s)", "sign_out_confirm_description": { @@ -2316,6 +2482,7 @@ "show_stickers_button": "Näytä tarrapainike", "show_typing_notifications": "Näytä kirjoitusilmoitukset", "sidebar": { + "dialog_title": "Asetukset: Sivupalkki", "metaspaces_favourites_description": "Ryhmitä kaikki suosimasi huoneet ja henkilöt yhteen paikkaan.", "metaspaces_home_all_rooms": "Näytä kaikki huoneet", "metaspaces_home_all_rooms_description": "Näytä kaikki huoneesi etusivulla, vaikka ne olisivat jossain muussa avaruudessa.", @@ -2340,6 +2507,7 @@ "audio_output_empty": "Äänen ulostuloja ei havaittu", "auto_gain_control": "Automaattinen vahvistuksen säätö", "connection_section": "Yhteys", + "dialog_title": "Asetukset: Ääni ja video", "echo_cancellation": "Kaiunpoisto", "mirror_local_feed": "Peilaa paikallinen videosyöte", "missing_permissions_prompt": "Mediaoikeuksia puuttuu. Napsauta painikkeesta pyytääksesi oikeuksia.", @@ -2358,6 +2526,7 @@ "share": { "permalink_message": "Linkitä valittuun viestiin", "permalink_most_recent": "Linkitä viimeisimpään viestiin", + "title_link": "Jaa linkki", "title_message": "Jaa huoneviesti", "title_room": "Jaa huone", "title_user": "Jaa käyttäjä" @@ -2401,6 +2570,8 @@ "lenny": "Lisää ( ͡° ͜ʖ ͡°) viestin alkuun", "me": "Näyttää toiminnan", "msg": "Lähettää viestin annetulle käyttäjälle", + "myavatar": "Vaihtaa profiilikuvasi kaikissa huoneissa", + "myroomavatar": "Vaihtaa profiilikuvasi vain tässä nykyisessä huoneessa", "myroomnick": "Vaihtaa näyttönimesi vain nykyisessä huoneessa", "nick": "Vaihtaa näyttönimesi", "no_active_call": "Huoneessa ei ole aktiivista puhelua", @@ -2546,6 +2717,7 @@ "message_search_section_title": "Muut haut", "other_rooms_in_space": "Muut huoneet avaruudessa %(spaceName)s", "public_rooms_label": "Julkiset huoneet", + "public_spaces_label": "Julkiset avaruudet", "recent_searches_section_title": "Viimeaikaiset haut", "recently_viewed_section_title": "Äskettäin katsottu", "result_may_be_hidden_privacy_warning": "Jotkin tulokset saatetaan piilottaa tietosuojan takia", @@ -2576,7 +2748,8 @@ "tos": "Käyttöehdot" }, "theme": { - "light_high_contrast": "Vaalea, suuri kontrasti" + "light_high_contrast": "Vaalea, suuri kontrasti", + "match_system": "Sama kuin järjestelmän" }, "thread_view_back_action_label": "Takaisin ketjuun", "threads": { @@ -2586,6 +2759,7 @@ "one": "%(count)s vastaus", "other": "%(count)s vastausta" }, + "mark_all_read": "Merkitse kaikki luetuiksi", "my_threads": "Omat ketjut", "my_threads_description": "Näyttää kaikki ketjut, joissa olet ollut osallinen", "open_thread": "Avaa ketju", @@ -2633,6 +2807,7 @@ "creation_summary_room": "%(creator)s loi ja määritti huoneen.", "download_action_decrypting": "Puretaan salausta", "download_action_downloading": "Ladataan", + "download_failed": "Lataus epäonnistui", "edits": { "tooltip_label": "Muokattu %(date)s. Napsauta nähdäksesi muokkaukset.", "tooltip_sub": "Napsauta nähdäksesi muokkaukset", @@ -2834,6 +3009,8 @@ "label": "Viestitoiminnot", "view_in_room": "Näytä huoneessa" }, + "message_timestamp_received_at": "Vastaanotettu: %(dateTime)s", + "message_timestamp_sent_at": "Lähetetty: %(dateTime)s", "mjolnir": { "changed_rule_glob": "%(senderName)s muutti estosääntöä muodosta %(oldGlob)s muotoon %(newGlob)s. Syy: %(reason)s", "changed_rule_rooms": "%(senderName)s muutti sääntöä, joka esti huoneita säännöllä %(oldGlob)s muotoon %(newGlob)s. Syy: %(reason)s", @@ -3027,6 +3204,10 @@ "truncated_list_n_more": { "other": "Ja %(count)s muuta..." }, + "unsupported_browser": { + "title": "%(brand)s ei tue tätä selainta" + }, + "unsupported_server_title": "Palvelimesi ei ole tuettu", "update": { "changelog": "Muutosloki", "check_action": "Tarkista päivitykset", @@ -3126,6 +3307,7 @@ "verify_explainer": "Lisäturvaksi, varmenna tämä käyttäjä tarkistamalla koodin kummankin laitteella." }, "user_menu": { + "link_new_device": "Yhdistä uusi laite", "settings": "Kaikki asetukset", "switch_theme_dark": "Vaihda tummaan teemaan", "switch_theme_light": "Vaihda vaaleaan teemaan" @@ -3149,6 +3331,7 @@ "camera_disabled": "Kamerasi on pois päältä", "camera_enabled": "Kamerasi on edelleen päällä", "cannot_call_yourself_description": "Et voi soittaa itsellesi.", + "close_lobby": "Sulje aula", "connecting": "Yhdistetään", "connection_lost": "Yhteys palvelimeen on katkennut", "connection_lost_description": "Et voi soittaa puheluja ilman yhteyttä palvelimeen.", @@ -3302,6 +3485,7 @@ }, "popout": "Avaa sovelma omassa ikkunassaan", "set_room_layout": "Aseta minun huoneen asettelu kaikille", + "shared_data_lang": "Kielesi", "shared_data_mxid": "Käyttäjätunnuksesi", "shared_data_name": "Näyttönimesi", "shared_data_room_id": "Huoneen tunnus", @@ -3323,6 +3507,7 @@ "l33t": "Arvattavat vaihdot, kuten ”@” ”a”:n sijaan ei auta paljoakaan", "longerKeyboardPattern": "Käytä pidempiä näppäinyhdistelmiä, joissa on enemmän suunnanmuutoksia", "noNeed": "Ei tarvetta symboleille, numeroille tai isoille kirjaimille", + "pwned": "Jos käytät tätä salasanaa muualla, vaihda se.", "recentYears": "Vältä viime vuosia", "repeated": "Vältä toistettuja sanoja ja merkkejä", "reverseWords": "Takaperin kirjoitetut sanat eivät ole paljoakaan vaikeampia arvata", @@ -3336,6 +3521,7 @@ "extendedRepeat": "Toistot, kuten ”abcabcabe” ovat vain hieman hankalampia arvata kuin ”abc”", "keyPattern": "Lyhyet näppäinsarjat ovat helppoja arvata", "namesByThemselves": "Nimet ja sukunimet yksinään ovat helppoja arvata", + "pwned": "Salasanasi paljastui Internetissä tapahtuneen tietovuodon seurauksena.", "recentYears": "Viime vuodet ovat helppoja arvata", "sequences": "Sarjat, kuten ”abc” tai ”6543” ovat helppoja arvata", "similarToCommon": "Tämä on samankaltainen kuin yleisesti käytetty salasana", diff --git a/src/modules.d.ts b/src/modules.d.ts new file mode 100644 index 0000000000..0f804f17cd --- /dev/null +++ b/src/modules.d.ts @@ -0,0 +1,13 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { ModuleApi, RuntimeModule } from "@matrix-org/react-sdk-module-api"; + +declare module "./modules.js" { + export type RuntimeModuleConstructor = { new (api: ModuleApi): RuntimeModule }; + export const INSTALLED_MODULES: RuntimeModuleConstructor[]; +} diff --git a/src/settings/watchers/ThemeWatcher.ts b/src/settings/watchers/ThemeWatcher.ts index 4d0c4a7171..77c8a391ae 100644 --- a/src/settings/watchers/ThemeWatcher.ts +++ b/src/settings/watchers/ThemeWatcher.ts @@ -8,16 +8,25 @@ Please see LICENSE files in the repository root for full details. */ import { logger } from "matrix-js-sdk/src/logger"; +import { TypedEventEmitter } from "matrix-js-sdk/src/matrix"; import SettingsStore from "../SettingsStore"; import dis from "../../dispatcher/dispatcher"; import { Action } from "../../dispatcher/actions"; import ThemeController from "../controllers/ThemeController"; -import { findHighContrastTheme, setTheme } from "../../theme"; +import { findHighContrastTheme } from "../../theme"; import { ActionPayload } from "../../dispatcher/payloads"; import { SettingLevel } from "../SettingLevel"; -export default class ThemeWatcher { +export enum ThemeWatcherEvent { + Change = "change", +} + +interface ThemeWatcherEventHandlerMap { + [ThemeWatcherEvent.Change]: (theme: string) => void; +} + +export default class ThemeWatcher extends TypedEventEmitter { private themeWatchRef?: string; private systemThemeWatchRef?: string; private dispatcherRef?: string; @@ -29,6 +38,7 @@ export default class ThemeWatcher { private currentTheme: string; public constructor() { + super(); // we have both here as each may either match or not match, so by having both // we can get the tristate of dark/light/unsupported this.preferDark = (global).matchMedia("(prefers-color-scheme: dark)"); @@ -72,9 +82,7 @@ export default class ThemeWatcher { public recheck(forceTheme?: string): void { const oldTheme = this.currentTheme; this.currentTheme = forceTheme === undefined ? this.getEffectiveTheme() : forceTheme; - if (oldTheme !== this.currentTheme) { - setTheme(this.currentTheme); - } + if (oldTheme !== this.currentTheme) this.emit(ThemeWatcherEvent.Change, this.currentTheme); } public getEffectiveTheme(): string { diff --git a/src/stores/CallStore.ts b/src/stores/CallStore.ts index d667e0b811..0d950eecce 100644 --- a/src/stores/CallStore.ts +++ b/src/stores/CallStore.ts @@ -10,7 +10,7 @@ import { logger } from "matrix-js-sdk/src/logger"; import { GroupCallEventHandlerEvent } from "matrix-js-sdk/src/webrtc/groupCallEventHandler"; import { MatrixRTCSession, MatrixRTCSessionManagerEvents } from "matrix-js-sdk/src/matrixrtc"; -import type { GroupCall, Room } from "matrix-js-sdk/src/matrix"; +import type { EmptyObject, GroupCall, Room } from "matrix-js-sdk/src/matrix"; import defaultDispatcher from "../dispatcher/dispatcher"; import { UPDATE_EVENT } from "./AsyncStore"; import { AsyncStoreWithClient } from "./AsyncStoreWithClient"; @@ -26,7 +26,7 @@ export enum CallStoreEvent { ConnectedCalls = "connected_calls", } -export class CallStore extends AsyncStoreWithClient<{}> { +export class CallStore extends AsyncStoreWithClient { private static _instance: CallStore; public static get instance(): CallStore { if (!this._instance) { diff --git a/src/stores/WidgetStore.ts b/src/stores/WidgetStore.ts index 8c69770930..a715a97c1e 100644 --- a/src/stores/WidgetStore.ts +++ b/src/stores/WidgetStore.ts @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { Room, RoomStateEvent, MatrixEvent, ClientEvent } from "matrix-js-sdk/src/matrix"; +import { Room, RoomStateEvent, MatrixEvent, ClientEvent, EmptyObject } from "matrix-js-sdk/src/matrix"; import { IWidget } from "matrix-widget-api"; import { logger } from "matrix-js-sdk/src/logger"; @@ -19,8 +19,6 @@ import WidgetUtils from "../utils/WidgetUtils"; import { UPDATE_EVENT } from "./AsyncStore"; import { IApp } from "../utils/WidgetUtils-types"; -interface IState {} - export type { IApp }; export function isAppWidget(widget: IWidget | IApp): widget is IApp { @@ -36,7 +34,7 @@ interface IRoomWidgets { // TODO consolidate WidgetEchoStore into this // TODO consolidate ActiveWidgetStore into this -export default class WidgetStore extends AsyncStoreWithClient { +export default class WidgetStore extends AsyncStoreWithClient { private static readonly internalInstance = (() => { const instance = new WidgetStore(); instance.start(); diff --git a/src/stores/notifications/RoomNotificationStateStore.ts b/src/stores/notifications/RoomNotificationStateStore.ts index c58125b0ba..4fa51128be 100644 --- a/src/stores/notifications/RoomNotificationStateStore.ts +++ b/src/stores/notifications/RoomNotificationStateStore.ts @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { Room, ClientEvent, SyncState } from "matrix-js-sdk/src/matrix"; +import { Room, ClientEvent, SyncState, EmptyObject } from "matrix-js-sdk/src/matrix"; import { ActionPayload } from "../../dispatcher/payloads"; import { AsyncStoreWithClient } from "../AsyncStoreWithClient"; @@ -19,11 +19,9 @@ import { VisibilityProvider } from "../room-list/filters/VisibilityProvider"; import { PosthogAnalytics } from "../../PosthogAnalytics"; import SettingsStore from "../../settings/SettingsStore"; -interface IState {} - export const UPDATE_STATUS_INDICATOR = Symbol("update-status-indicator"); -export class RoomNotificationStateStore extends AsyncStoreWithClient { +export class RoomNotificationStateStore extends AsyncStoreWithClient { private static readonly internalInstance = (() => { const instance = new RoomNotificationStateStore(); instance.start(); diff --git a/src/stores/right-panel/RightPanelStore.ts b/src/stores/right-panel/RightPanelStore.ts index ea9b722071..b89851691a 100644 --- a/src/stores/right-panel/RightPanelStore.ts +++ b/src/stores/right-panel/RightPanelStore.ts @@ -30,6 +30,23 @@ import { ActiveRoomChangedPayload } from "../../dispatcher/payloads/ActiveRoomCh import { SdkContextClass } from "../../contexts/SDKContext"; import { MatrixClientPeg } from "../../MatrixClientPeg"; +/** + * @see RightPanelStore#generateHistoryForPhase + */ +function getPhasesForPhase(phase: IRightPanelCard["phase"]): RightPanelPhases[] { + switch (phase) { + case RightPanelPhases.ThreadPanel: + case RightPanelPhases.MemberList: + case RightPanelPhases.PinnedMessages: + return [RightPanelPhases.RoomSummary]; + case RightPanelPhases.MemberInfo: + case RightPanelPhases.ThreePidMemberInfo: + return [RightPanelPhases.RoomSummary, RightPanelPhases.MemberList]; + default: + return []; + } +} + /** * A class for tracking the state of the right panel between layouts and * sessions. This state includes a history for each room. Each history element @@ -134,16 +151,20 @@ export default class RightPanelStore extends ReadyWatchingStore { return { state: {}, phase: null }; } - // Setters + /** + * This function behaves as following: + * - If the same phase is sent along with a non-empty state, only the state is updated and history is retained. + * - If the provided phase is different to the current phase: + * - Existing history is thrown away. + * - New card is added along with a different history, see {@link generateHistoryForPhase} + * + * If the right panel was set, this function also shows the right panel. + */ public setCard(card: IRightPanelCard, allowClose = true, roomId?: string): void { const rId = roomId ?? this.viewedRoomId ?? ""; - // This function behaves as following: - // Update state: if the same phase is send but with a state - // Set right panel and erase history: if a "different to the current" phase is send (with or without a state) - // If the right panel is set, this function also shows the right panel. const redirect = this.getVerificationRedirect(card); const targetPhase = redirect?.phase ?? card.phase; - const cardState = redirect?.state ?? (Object.keys(card.state ?? {}).length === 0 ? null : card.state); + const cardState = redirect?.state ?? (Object.keys(card.state ?? {}).length === 0 ? undefined : card.state); // Checks for wrong SetRightPanelPhase requests if (!this.isPhaseValid(targetPhase, Boolean(rId))) return; @@ -155,7 +176,7 @@ export default class RightPanelStore extends ReadyWatchingStore { this.emitAndUpdateSettings(); } else if (targetPhase !== this.currentCardForRoom(rId)?.phase || !this.byRoom[rId]) { // Set right panel and initialize/erase history - const history = [{ phase: targetPhase, state: cardState ?? {} }]; + const history = this.generateHistoryForPhase(targetPhase!, cardState ?? {}); this.byRoom[rId] = { history, isOpen: true }; this.emitAndUpdateSettings(); } else { @@ -239,7 +260,7 @@ export default class RightPanelStore extends ReadyWatchingStore { * @param cardState The state within the phase. */ public showOrHidePhase(phase: RightPanelPhases, cardState?: Partial): void { - if (this.currentCard.phase == phase && !cardState && this.isOpen) { + if (this.currentCard.phase === phase && !cardState && this.isOpen) { this.togglePanel(null); } else { this.setCard({ phase, state: cardState }); @@ -247,6 +268,31 @@ export default class RightPanelStore extends ReadyWatchingStore { } } + /** + * For a given phase, generates card history such that it looks + * similar to how an user typically would reach said phase in the app. + * eg: User would usually reach the memberlist via room-info panel, so + * that history is added. + */ + private generateHistoryForPhase( + phase: IRightPanelCard["phase"], + cardState?: Partial, + ): IRightPanelCard[] { + const card = { phase, state: cardState }; + if (!this.isCardStateValid(card)) { + /** + * If the card we're adding is not valid, then we just return + * an empty history. + * This is to avoid a scenario where, for eg, you set a member info + * card with invalid card state (no member) but the member list is + * shown since the created history is valid except for the last card. + */ + return []; + } + const cards = getPhasesForPhase(phase).map((p) => ({ phase: p, state: {} })); + return [...cards, card]; + } + private loadCacheFromSettings(): void { if (this.viewedRoomId) { const room = this.mxClient?.getRoom(this.viewedRoomId); diff --git a/src/stores/right-panel/RightPanelStoreIPanelState.ts b/src/stores/right-panel/RightPanelStoreIPanelState.ts index 1ee5f2a95d..d70f6606e9 100644 --- a/src/stores/right-panel/RightPanelStoreIPanelState.ts +++ b/src/stores/right-panel/RightPanelStoreIPanelState.ts @@ -38,8 +38,6 @@ export interface IRightPanelCardStateStored { initialEventId?: string; isInitialEventHighlighted?: boolean; initialEventScrollIntoView?: boolean; - // room summary card - focusRoomSearch?: boolean; } export interface IRightPanelCard { @@ -84,7 +82,6 @@ export function convertCardToStore(panelState: IRightPanelCard): IRightPanelCard memberInfoEventId: !!state?.memberInfoEvent?.getId() ? state.memberInfoEvent.getId() : undefined, initialEventId: !!state?.initialEvent?.getId() ? state.initialEvent.getId() : undefined, memberId: !!state?.member?.userId ? state.member.userId : undefined, - focusRoomSearch: state.focusRoomSearch, }; return { state: stateStored, phase: panelState.phase }; @@ -104,7 +101,6 @@ function convertStoreToCard(panelStateStore: IRightPanelCardStored, room: Room): : undefined, initialEvent: !!stateStored?.initialEventId ? room.findEventById(stateStored.initialEventId) : undefined, member: (!!stateStored?.memberId && room.getMember(stateStored.memberId)) || undefined, - focusRoomSearch: stateStored?.focusRoomSearch, }; return { state: state, phase: panelStateStore.phase }; diff --git a/src/stores/room-list/MessagePreviewStore.ts b/src/stores/room-list/MessagePreviewStore.ts index 3f1614df70..fe2d56871d 100644 --- a/src/stores/room-list/MessagePreviewStore.ts +++ b/src/stores/room-list/MessagePreviewStore.ts @@ -6,7 +6,15 @@ 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 { Room, RelationType, MatrixEvent, Thread, M_POLL_START, RoomEvent } from "matrix-js-sdk/src/matrix"; +import { + Room, + RelationType, + MatrixEvent, + Thread, + M_POLL_START, + RoomEvent, + EmptyObject, +} from "matrix-js-sdk/src/matrix"; import { isNullOrUndefined } from "matrix-js-sdk/src/utils"; import { ActionPayload } from "../../dispatcher/payloads"; @@ -76,10 +84,6 @@ const MAX_EVENTS_BACKWARDS = 50; type TAG_ANY = "im.vector.any"; // eslint-disable-line @typescript-eslint/naming-convention const TAG_ANY: TAG_ANY = "im.vector.any"; -interface IState { - // Empty because we don't actually use the state -} - export interface MessagePreview { event: MatrixEvent; isThreadReply: boolean; @@ -117,7 +121,7 @@ const mkMessagePreview = (text: string, event: MatrixEvent): MessagePreview => { }; }; -export class MessagePreviewStore extends AsyncStoreWithClient { +export class MessagePreviewStore extends AsyncStoreWithClient { private static readonly internalInstance = (() => { const instance = new MessagePreviewStore(); instance.start(); diff --git a/src/stores/room-list/RoomListLayoutStore.ts b/src/stores/room-list/RoomListLayoutStore.ts index 90461a581c..f739554b82 100644 --- a/src/stores/room-list/RoomListLayoutStore.ts +++ b/src/stores/room-list/RoomListLayoutStore.ts @@ -7,6 +7,7 @@ Please see LICENSE files in the repository root for full details. */ import { logger } from "matrix-js-sdk/src/logger"; +import { EmptyObject } from "matrix-js-sdk/src/matrix"; import { TagID } from "./models"; import { ListLayout } from "./ListLayout"; @@ -14,9 +15,7 @@ import { AsyncStoreWithClient } from "../AsyncStoreWithClient"; import defaultDispatcher from "../../dispatcher/dispatcher"; import { ActionPayload } from "../../dispatcher/payloads"; -interface IState {} - -export default class RoomListLayoutStore extends AsyncStoreWithClient { +export default class RoomListLayoutStore extends AsyncStoreWithClient { private static internalInstance: RoomListLayoutStore; private readonly layoutMap = new Map(); diff --git a/src/stores/room-list/RoomListStore.ts b/src/stores/room-list/RoomListStore.ts index 0b179f7db5..bb48ec5e18 100644 --- a/src/stores/room-list/RoomListStore.ts +++ b/src/stores/room-list/RoomListStore.ts @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { MatrixClient, Room, RoomState, EventType } from "matrix-js-sdk/src/matrix"; +import { MatrixClient, Room, RoomState, EventType, EmptyObject } from "matrix-js-sdk/src/matrix"; import { KnownMembership } from "matrix-js-sdk/src/types"; import { logger } from "matrix-js-sdk/src/logger"; @@ -32,14 +32,10 @@ import { UPDATE_EVENT } from "../AsyncStore"; import { SdkContextClass } from "../../contexts/SDKContext"; import { getChangedOverrideRoomMutePushRules } from "./utils/roomMute"; -interface IState { - // state is tracked in underlying classes -} - export const LISTS_UPDATE_EVENT = RoomListStoreEvent.ListsUpdate; export const LISTS_LOADING_EVENT = RoomListStoreEvent.ListsLoading; // unused; used by SlidingRoomListStore -export class RoomListStoreClass extends AsyncStoreWithClient implements Interface { +export class RoomListStoreClass extends AsyncStoreWithClient implements Interface { /** * Set to true if you're running tests on the store. Should not be touched in * any other environment. diff --git a/src/stores/room-list/SlidingRoomListStore.ts b/src/stores/room-list/SlidingRoomListStore.ts index ba585f3218..626ac5dc6a 100644 --- a/src/stores/room-list/SlidingRoomListStore.ts +++ b/src/stores/room-list/SlidingRoomListStore.ts @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { Room } from "matrix-js-sdk/src/matrix"; +import { EmptyObject, Room } from "matrix-js-sdk/src/matrix"; import { logger } from "matrix-js-sdk/src/logger"; import { MSC3575Filter, SlidingSyncEvent } from "matrix-js-sdk/src/sliding-sync"; import { Optional } from "matrix-events-sdk"; @@ -23,10 +23,6 @@ import { LISTS_LOADING_EVENT } from "./RoomListStore"; import { UPDATE_EVENT } from "../AsyncStore"; import { SdkContextClass } from "../../contexts/SDKContext"; -interface IState { - // state is tracked in underlying classes -} - export const SlidingSyncSortToFilter: Record = { [SortAlgorithm.Alphabetic]: ["by_name", "by_recency"], [SortAlgorithm.Recent]: ["by_notification_level", "by_recency"], @@ -66,7 +62,7 @@ const filterConditions: Record = { export const LISTS_UPDATE_EVENT = RoomListStoreEvent.ListsUpdate; -export class SlidingRoomListStoreClass extends AsyncStoreWithClient implements Interface { +export class SlidingRoomListStoreClass extends AsyncStoreWithClient implements Interface { private tagIdToSortAlgo: Record = {}; private tagMap: ITagMap = {}; private counts: Record = {}; diff --git a/src/stores/spaces/SpaceStore.ts b/src/stores/spaces/SpaceStore.ts index 50aa7748a5..9ee877ec3f 100644 --- a/src/stores/spaces/SpaceStore.ts +++ b/src/stores/spaces/SpaceStore.ts @@ -17,6 +17,7 @@ import { MatrixEvent, ClientEvent, ISendEventResponse, + EmptyObject, } from "matrix-js-sdk/src/matrix"; import { KnownMembership } from "matrix-js-sdk/src/types"; import { logger } from "matrix-js-sdk/src/logger"; @@ -63,8 +64,6 @@ import { SwitchSpacePayload } from "../../dispatcher/payloads/SwitchSpacePayload import { AfterLeaveRoomPayload } from "../../dispatcher/payloads/AfterLeaveRoomPayload"; import { SdkContextClass } from "../../contexts/SDKContext"; -interface IState {} - const ACTIVE_SPACE_LS_KEY = "mx_active_space"; const metaSpaceOrder: MetaSpace[] = [ @@ -123,7 +122,7 @@ type SpaceStoreActions = | SwitchSpacePayload | AfterLeaveRoomPayload; -export class SpaceStoreClass extends AsyncStoreWithClient { +export class SpaceStoreClass extends AsyncStoreWithClient { // The spaces representing the roots of the various tree-like hierarchies private rootSpaces: Room[] = []; // Map from room/space ID to set of spaces which list it as a child diff --git a/src/stores/widgets/StopGapWidget.ts b/src/stores/widgets/StopGapWidget.ts index c17aa81aab..6f3d554c11 100644 --- a/src/stores/widgets/StopGapWidget.ts +++ b/src/stores/widgets/StopGapWidget.ts @@ -43,7 +43,6 @@ import { MatrixClientPeg } from "../../MatrixClientPeg"; import { OwnProfileStore } from "../OwnProfileStore"; import WidgetUtils from "../../utils/WidgetUtils"; import { IntegrationManagers } from "../../integrations/IntegrationManagers"; -import SettingsStore from "../../settings/SettingsStore"; import { WidgetType } from "../../widgets/WidgetType"; import ActiveWidgetStore from "../ActiveWidgetStore"; import { objectShallowClone } from "../../utils/objects"; @@ -52,7 +51,7 @@ import { Action } from "../../dispatcher/actions"; import { ElementWidgetActions, IHangupCallApiRequest, IViewRoomApiRequest } from "./ElementWidgetActions"; import { ModalWidgetStore } from "../ModalWidgetStore"; import { IApp, isAppWidget } from "../WidgetStore"; -import ThemeWatcher from "../../settings/watchers/ThemeWatcher"; +import ThemeWatcher, { ThemeWatcherEvent } from "../../settings/watchers/ThemeWatcher"; import { getCustomTheme } from "../../theme"; import { ElementWidgetCapabilities } from "./ElementWidgetCapabilities"; import { ELEMENT_CLIENT_ID } from "../../identifiers"; @@ -163,6 +162,7 @@ export class StopGapWidget extends EventEmitter { private viewedRoomId: string | null = null; private kind: WidgetKind; private readonly virtual: boolean; + private readonly themeWatcher = new ThemeWatcher(); private readUpToMap: { [roomId: string]: string } = {}; // room ID to event ID // This promise will be called and needs to resolve before the widget will actually become sticky. private stickyPromise?: () => Promise; @@ -213,7 +213,7 @@ export class StopGapWidget extends EventEmitter { userDisplayName: OwnProfileStore.instance.displayName ?? undefined, userHttpAvatarUrl: OwnProfileStore.instance.getHttpAvatarUrl() ?? undefined, clientId: ELEMENT_CLIENT_ID, - clientTheme: SettingsStore.getValue("theme"), + clientTheme: this.themeWatcher.getEffectiveTheme(), clientLanguage: getUserLanguage(), deviceId: this.client.getDeviceId() ?? undefined, baseUrl: this.client.baseUrl, @@ -245,6 +245,10 @@ export class StopGapWidget extends EventEmitter { return !!this.messaging; } + private onThemeChange = (theme: string): void => { + this.messaging?.updateTheme({ name: theme }); + }; + private onOpenModal = async (ev: CustomEvent): Promise => { ev.preventDefault(); if (ModalWidgetStore.instance.canOpenModalWidget()) { @@ -288,9 +292,14 @@ export class StopGapWidget extends EventEmitter { this.messaging = new ClientWidgetApi(this.mockWidget, iframe, driver); this.messaging.on("preparing", () => this.emit("preparing")); this.messaging.on("error:preparing", (err: unknown) => this.emit("error:preparing", err)); - this.messaging.on("ready", () => { + this.messaging.once("ready", () => { WidgetMessagingStore.instance.storeMessaging(this.mockWidget, this.roomId, this.messaging!); this.emit("ready"); + + this.themeWatcher.start(); + this.themeWatcher.on(ThemeWatcherEvent.Change, this.onThemeChange); + // Theme may have changed while messaging was starting + this.onThemeChange(this.themeWatcher.getEffectiveTheme()); }); this.messaging.on("capabilitiesNotified", () => this.emit("capabilitiesNotified")); this.messaging.on(`action:${WidgetApiFromWidgetAction.OpenModalWidget}`, this.onOpenModal); diff --git a/src/stores/widgets/StopGapWidgetDriver.ts b/src/stores/widgets/StopGapWidgetDriver.ts index 7f5affab0d..4eb5aaf654 100644 --- a/src/stores/widgets/StopGapWidgetDriver.ts +++ b/src/stores/widgets/StopGapWidgetDriver.ts @@ -65,7 +65,7 @@ import { navigateToPermalink } from "../../utils/permalinks/navigator"; import { SdkContextClass } from "../../contexts/SDKContext"; import { ModuleRunner } from "../../modules/ModuleRunner"; import SettingsStore from "../../settings/SettingsStore"; -import { Media } from "../../customisations/Media"; +import { mediaFromMxc } from "../../customisations/Media"; // TODO: Purge this from the universe @@ -684,7 +684,7 @@ export class StopGapWidgetDriver extends WidgetDriver { */ public async downloadFile(contentUri: string): Promise<{ file: XMLHttpRequestBodyInit }> { const client = MatrixClientPeg.safeGet(); - const media = new Media({ mxc: contentUri }, client); + const media = mediaFromMxc(contentUri, client); const response = await media.downloadSource(); const blob = await response.blob(); return { file: blob }; diff --git a/src/stores/widgets/WidgetMessagingStore.ts b/src/stores/widgets/WidgetMessagingStore.ts index f73fd15c51..d42c499226 100644 --- a/src/stores/widgets/WidgetMessagingStore.ts +++ b/src/stores/widgets/WidgetMessagingStore.ts @@ -7,6 +7,7 @@ */ import { ClientWidgetApi, Widget } from "matrix-widget-api"; +import { EmptyObject } from "matrix-js-sdk/src/matrix"; import { AsyncStoreWithClient } from "../AsyncStoreWithClient"; import defaultDispatcher from "../../dispatcher/dispatcher"; @@ -24,7 +25,7 @@ export enum WidgetMessagingStoreEvent { * going to be merged with a more complete WidgetStore, but for now it's * easiest to split this into a single place. */ -export class WidgetMessagingStore extends AsyncStoreWithClient<{}> { +export class WidgetMessagingStore extends AsyncStoreWithClient { private static readonly internalInstance = (() => { const instance = new WidgetMessagingStore(); instance.start(); diff --git a/src/toasts/SetupEncryptionToast.ts b/src/toasts/SetupEncryptionToast.ts index 3b8e85eb44..8d321a16eb 100644 --- a/src/toasts/SetupEncryptionToast.ts +++ b/src/toasts/SetupEncryptionToast.ts @@ -16,6 +16,10 @@ import GenericToast from "../components/views/toasts/GenericToast"; import { ModuleRunner } from "../modules/ModuleRunner"; import { SetupEncryptionStore } from "../stores/SetupEncryptionStore"; import Spinner from "../components/views/elements/Spinner"; +import { OpenToTabPayload } from "../dispatcher/payloads/OpenToTabPayload"; +import { Action } from "../dispatcher/actions"; +import { UserTab } from "../components/views/dialogs/UserTab"; +import defaultDispatcher from "../dispatcher/dispatcher"; const TOAST_KEY = "setupencryption"; @@ -104,10 +108,6 @@ export enum Kind { KEY_STORAGE_OUT_OF_SYNC = "key_storage_out_of_sync", } -const onReject = (): void => { - DeviceListener.sharedInstance().dismissEncryptionSetup(); -}; - /** * Show a toast prompting the user for some action related to setting up their encryption. * @@ -123,7 +123,7 @@ export const showToast = (kind: Kind): void => { return; } - const onAccept = async (): Promise => { + const onPrimaryClick = async (): Promise => { if (kind === Kind.VERIFY_THIS_SESSION) { Modal.createDialog(SetupEncryptionDialog, {}, undefined, /* priority = */ false, /* static = */ true); } else { @@ -142,6 +142,19 @@ export const showToast = (kind: Kind): void => { } }; + const onSecondaryClick = (): void => { + if (kind === Kind.KEY_STORAGE_OUT_OF_SYNC) { + const payload: OpenToTabPayload = { + action: Action.ViewUserSettings, + initialTabId: UserTab.Encryption, + props: { showResetIdentity: true }, + }; + defaultDispatcher.dispatch(payload); + } else { + DeviceListener.sharedInstance().dismissEncryptionSetup(); + } + }; + ToastStore.sharedInstance().addOrReplaceToast({ key: TOAST_KEY, title: getTitle(kind), @@ -149,9 +162,9 @@ export const showToast = (kind: Kind): void => { props: { description: getDescription(kind), primaryLabel: getSetupCaption(kind), - onPrimaryClick: onAccept, + onPrimaryClick, secondaryLabel: getSecondaryButtonLabel(kind), - onSecondaryClick: onReject, + onSecondaryClick, overrideWidth: kind === Kind.KEY_STORAGE_OUT_OF_SYNC ? "366px" : undefined, }, component: GenericToast, diff --git a/src/utils/IdentityServerUtils.ts b/src/utils/IdentityServerUtils.ts index fd3de6b2f4..aa1153acfd 100644 --- a/src/utils/IdentityServerUtils.ts +++ b/src/utils/IdentityServerUtils.ts @@ -6,11 +6,10 @@ 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 { SERVICE_TYPES, HTTPError, MatrixClient } from "matrix-js-sdk/src/matrix"; +import { SERVICE_TYPES, HTTPError, MatrixClient, Terms } from "matrix-js-sdk/src/matrix"; import { logger } from "matrix-js-sdk/src/logger"; import SdkConfig from "../SdkConfig"; -import { Policies } from "../Terms"; export function getDefaultIdentityServerUrl(): string | undefined { return SdkConfig.get("validated_server_config")?.isUrl; @@ -25,7 +24,7 @@ export function setToDefaultIdentityServer(matrixClient: MatrixClient): void { } export async function doesIdentityServerHaveTerms(matrixClient: MatrixClient, fullUrl: string): Promise { - let terms: { policies?: Policies } | null; + let terms: Partial | null; try { terms = await matrixClient.getTerms(SERVICE_TYPES.IS, fullUrl); } catch (e) { diff --git a/src/utils/MultiInviter.ts b/src/utils/MultiInviter.ts index 1350eb94a4..c42284db45 100644 --- a/src/utils/MultiInviter.ts +++ b/src/utils/MultiInviter.ts @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { MatrixError, MatrixClient, EventType } from "matrix-js-sdk/src/matrix"; +import { MatrixError, MatrixClient, EventType, EmptyObject } from "matrix-js-sdk/src/matrix"; import { KnownMembership } from "matrix-js-sdk/src/types"; import { defer, IDeferred } from "matrix-js-sdk/src/utils"; import { logger } from "matrix-js-sdk/src/logger"; @@ -117,7 +117,7 @@ export default class MultiInviter { return this.errors[addr]?.errorText ?? null; } - private async inviteToRoom(roomId: string, addr: string, ignoreProfile = false): Promise<{}> { + private async inviteToRoom(roomId: string, addr: string, ignoreProfile = false): Promise { const addrType = getAddressType(addr); if (addrType === AddressType.Email) { diff --git a/src/utils/PinningUtils.ts b/src/utils/PinningUtils.ts index a1304598f7..40ab66160d 100644 --- a/src/utils/PinningUtils.ts +++ b/src/utils/PinningUtils.ts @@ -6,7 +6,15 @@ 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 { MatrixEvent, EventType, M_POLL_START, MatrixClient, EventTimeline, Room } from "matrix-js-sdk/src/matrix"; +import { + MatrixEvent, + EventType, + M_POLL_START, + MatrixClient, + EventTimeline, + Room, + EmptyObject, +} from "matrix-js-sdk/src/matrix"; import { isContentActionable } from "./EventUtils"; import { ReadPinsEventId } from "../components/views/right_panel/types"; @@ -123,7 +131,7 @@ export default class PinningUtils { ?.getStateEvents(EventType.RoomPinnedEvents, "") ?.getContent().pinned || []; - let roomAccountDataPromise: Promise<{} | void> = Promise.resolve(); + let roomAccountDataPromise: Promise = Promise.resolve(); // If the event is already pinned, unpin it if (pinnedIds.includes(eventId)) { pinnedIds.splice(pinnedIds.indexOf(eventId), 1); diff --git a/src/utils/device/dehydration.ts b/src/utils/device/dehydration.ts index d87d43e13a..8ad4901b8c 100644 --- a/src/utils/device/dehydration.ts +++ b/src/utils/device/dehydration.ts @@ -7,8 +7,9 @@ Please see LICENSE files in the repository root for full details. */ import { logger } from "matrix-js-sdk/src/logger"; -import { CryptoApi } from "matrix-js-sdk/src/crypto-api"; +import { CryptoApi, StartDehydrationOpts } from "matrix-js-sdk/src/crypto-api"; +import type { MatrixClient } from "matrix-js-sdk/src/matrix"; import { MatrixClientPeg } from "../../MatrixClientPeg"; /** @@ -21,14 +22,14 @@ import { MatrixClientPeg } from "../../MatrixClientPeg"; * * Dehydration can currently only be enabled by setting a flag in the .well-known file. */ -async function deviceDehydrationEnabled(crypto: CryptoApi | undefined): Promise { +async function deviceDehydrationEnabled(client: MatrixClient, crypto: CryptoApi | undefined): Promise { if (!crypto) { return false; } if (!(await crypto.isDehydrationSupported())) { return false; } - const wellknown = await MatrixClientPeg.safeGet().waitForClientWellKnown(); + const wellknown = await client.waitForClientWellKnown(); return !!wellknown?.["org.matrix.msc3814"]; } @@ -40,10 +41,11 @@ async function deviceDehydrationEnabled(crypto: CryptoApi | undefined): Promise< * @param createNewKey: force a new dehydration key to be created, even if one * already exists. This is used when we reset secret storage. */ -export async function initialiseDehydration(createNewKey: boolean = false): Promise { - const crypto = MatrixClientPeg.safeGet().getCrypto(); - if (await deviceDehydrationEnabled(crypto)) { +export async function initialiseDehydration(opts: StartDehydrationOpts = {}, client?: MatrixClient): Promise { + client = client || MatrixClientPeg.safeGet(); + const crypto = client.getCrypto(); + if (await deviceDehydrationEnabled(client, crypto)) { logger.log("Device dehydration enabled"); - await crypto!.startDehydration(createNewKey); + await crypto!.startDehydration(opts); } } diff --git a/src/utils/notifications.ts b/src/utils/notifications.ts index 9119ef9bcb..d0dfa8ffa6 100644 --- a/src/utils/notifications.ts +++ b/src/utils/notifications.ts @@ -14,6 +14,7 @@ import { LocalNotificationSettings, ReceiptType, IMarkedUnreadEvent, + EmptyObject, } from "matrix-js-sdk/src/matrix"; import { IndicatorIcon } from "@vector-im/compound-web"; @@ -80,7 +81,7 @@ export function localNotificationsAreSilenced(cli: MatrixClient): boolean { * @param client * @returns a promise that resolves when the room has been marked as read */ -export async function clearRoomNotification(room: Room, client: MatrixClient): Promise<{} | undefined> { +export async function clearRoomNotification(room: Room, client: MatrixClient): Promise { const lastEvent = room.getLastLiveEvent(); await setMarkedUnreadState(room, client, false); @@ -115,15 +116,17 @@ export async function clearRoomNotification(room: Room, client: MatrixClient): P * @param client The matrix client * @returns a promise that resolves when all rooms have been marked as read */ -export function clearAllNotifications(client: MatrixClient): Promise> { - const receiptPromises = client.getRooms().reduce((promises: Array>, room: Room) => { - if (doesRoomHaveUnreadMessages(room, true)) { - const promise = clearRoomNotification(room, client); - promises.push(promise); - } +export function clearAllNotifications(client: MatrixClient): Promise> { + const receiptPromises = client + .getRooms() + .reduce((promises: Array>, room: Room) => { + if (doesRoomHaveUnreadMessages(room, true)) { + const promise = clearRoomNotification(room, client); + promises.push(promise); + } - return promises; - }, []); + return promises; + }, []); return Promise.all(receiptPromises); } diff --git a/src/utils/objects.ts b/src/utils/objects.ts index a919699eac..60bf702404 100644 --- a/src/utils/objects.ts +++ b/src/utils/objects.ts @@ -8,7 +8,7 @@ Please see LICENSE files in the repository root for full details. import { arrayDiff, arrayUnion, arrayIntersection } from "./arrays"; -type ObjectExcluding = { [k in Exclude]: O[k] }; +type ObjectExcluding = { [k in Exclude]: O[k] }; /** * Gets a new object which represents the provided object, excluding some properties. @@ -16,7 +16,7 @@ type ObjectExcluding = { [k in Exclude>(a: O, props: P): ObjectExcluding { +export function objectExcluding>(a: O, props: P): ObjectExcluding { // We use a Map to avoid hammering the `delete` keyword, which is slow and painful. const tempMap = new Map(Object.entries(a) as [keyof O, any][]); for (const prop of props) { @@ -37,7 +37,7 @@ export function objectExcluding>(a: O, pr * @param props The property names to keep. * @returns The new object with only the provided properties. */ -export function objectWithOnly>(a: O, props: P): { [k in P[number]]: O[k] } { +export function objectWithOnly>(a: O, props: P): { [k in P[number]]: O[k] } { const existingProps = Object.keys(a) as (keyof O)[]; const diff = arrayDiff(existingProps, props); if (diff.removed.length === 0) { @@ -58,7 +58,7 @@ export function objectWithOnly>(a: O, pro * First argument is the property key with the second being the current value. * @returns A cloned object. */ -export function objectShallowClone(a: O, propertyCloner?: (k: keyof O, v: O[keyof O]) => any): O { +export function objectShallowClone(a: O, propertyCloner?: (k: keyof O, v: O[keyof O]) => any): O { const newObj = {} as O; for (const [k, v] of Object.entries(a) as [keyof O, O[keyof O]][]) { newObj[k] = v; @@ -77,7 +77,7 @@ export function objectShallowClone(a: O, propertyCloner?: (k: keyo * @param b The second object. Must be defined. * @returns True if there's a difference between the objects, false otherwise */ -export function objectHasDiff(a: O, b: O): boolean { +export function objectHasDiff(a: O, b: O): boolean { if (a === b) return false; const aKeys = Object.keys(a); const bKeys = Object.keys(b); @@ -99,7 +99,7 @@ type Diff = { changed: K[]; added: K[]; removed: K[] }; * @param b The second object. Must be defined. * @returns The difference between the keys of each object. */ -export function objectDiff(a: O, b: O): Diff { +export function objectDiff(a: O, b: O): Diff { const aKeys = Object.keys(a) as (keyof O)[]; const bKeys = Object.keys(b) as (keyof O)[]; const keyDiff = arrayDiff(aKeys, bKeys); @@ -118,7 +118,7 @@ export function objectDiff(a: O, b: O): Diff { * @returns The keys which have been added, removed, or changed between the * two objects. */ -export function objectKeyChanges(a: O, b: O): (keyof O)[] { +export function objectKeyChanges(a: O, b: O): (keyof O)[] { const diff = objectDiff(a, b); return arrayUnion(diff.removed, diff.added, diff.changed); } @@ -130,7 +130,7 @@ export function objectKeyChanges(a: O, b: O): (keyof O)[] { * @param obj The object to clone. * @returns The cloned object */ -export function objectClone(obj: O): O { +export function objectClone(obj: O): O { return JSON.parse(JSON.stringify(obj)); } diff --git a/src/vector/app.tsx b/src/vector/app.tsx index 2ae9e6fa03..12d8173d5f 100644 --- a/src/vector/app.tsx +++ b/src/vector/app.tsx @@ -17,6 +17,7 @@ import { logger } from "matrix-js-sdk/src/logger"; import { createClient, AutoDiscovery, ClientConfig } from "matrix-js-sdk/src/matrix"; import { WrapperLifecycle, WrapperOpts } from "@matrix-org/react-sdk-module-api/lib/lifecycles/WrapperLifecycle"; +import type { QueryDict } from "matrix-js-sdk/src/utils"; import PlatformPeg from "../PlatformPeg"; import AutoDiscoveryUtils from "../utils/AutoDiscoveryUtils"; import * as Lifecycle from "../Lifecycle"; @@ -54,7 +55,7 @@ function onTokenLoginCompleted(): void { window.history.replaceState(null, "", url.href); } -export async function loadApp(fragParams: {}, matrixChatRef: React.Ref): Promise { +export async function loadApp(fragParams: QueryDict, matrixChatRef: React.Ref): Promise { initRouting(); const platform = PlatformPeg.get(); diff --git a/src/vector/init.tsx b/src/vector/init.tsx index 34f5b9fc08..9a2f1c1196 100644 --- a/src/vector/init.tsx +++ b/src/vector/init.tsx @@ -12,6 +12,7 @@ import { createRoot } from "react-dom/client"; import React, { StrictMode } from "react"; import { logger } from "matrix-js-sdk/src/logger"; +import type { QueryDict } from "matrix-js-sdk/src/utils"; import * as languageHandler from "../languageHandler"; import SettingsStore from "../settings/SettingsStore"; import PlatformPeg from "../PlatformPeg"; @@ -83,7 +84,7 @@ export async function loadTheme(): Promise { return setTheme(); } -export async function loadApp(fragParams: {}): Promise { +export async function loadApp(fragParams: QueryDict): Promise { // load app.js async so that its code is not executed immediately and we can catch any exceptions const module = await import( /* webpackChunkName: "element-web-app" */ @@ -125,12 +126,8 @@ export async function showIncompatibleBrowser(onAccept: () => void): Promise { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - this path is created at runtime and therefore won't exist at typecheck time - const { INSTALLED_MODULES } = await import("../modules"); + const { INSTALLED_MODULES } = await import("../modules.js"); for (const InstalledModule of INSTALLED_MODULES) { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - we know the constructor exists even if TypeScript can't be convinced of that ModuleRunner.instance.registerModule((api) => new InstalledModule(api)); } } diff --git a/src/vector/platform/IPCManager.ts b/src/vector/platform/IPCManager.ts index 7d329b8b2c..5b8af55df8 100644 --- a/src/vector/platform/IPCManager.ts +++ b/src/vector/platform/IPCManager.ts @@ -40,7 +40,7 @@ export class IPCManager { return deferred.promise; } - private onIpcReply = (_ev: {}, payload: IPCPayload): void => { + private onIpcReply = (_ev: Event, payload: IPCPayload): void => { if (payload.id === undefined) { logger.warn("Ignoring IPC reply with no ID"); return; diff --git a/test/components/views/dialogs/ModalWidgetDialog-test.tsx b/test/components/views/dialogs/ModalWidgetDialog-test.tsx new file mode 100644 index 0000000000..134aa46ad6 --- /dev/null +++ b/test/components/views/dialogs/ModalWidgetDialog-test.tsx @@ -0,0 +1,56 @@ +/* +Copyright 2024 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only +Please see LICENSE files in the repository root for full details. +*/ + +import { fireEvent, render } from "jest-matrix-react"; +import { ClientWidgetApi, MatrixWidgetType } from "matrix-widget-api"; +import React from "react"; +import { TooltipProvider } from "@vector-im/compound-web"; +import { mocked } from "jest-mock"; +import { findLast, last } from "lodash"; + +import ModalWidgetDialog from "../../../../src/components/views/dialogs/ModalWidgetDialog"; +import { stubClient } from "../../../test-utils"; +import defaultDispatcher from "../../../../src/dispatcher/dispatcher"; +import { Action } from "../../../../src/dispatcher/actions"; +import SettingsStore from "../../../../src/settings/SettingsStore"; + +jest.mock("matrix-widget-api", () => ({ + ...jest.requireActual("matrix-widget-api"), + ClientWidgetApi: (jest.createMockFromModule("matrix-widget-api") as any).ClientWidgetApi, +})); + +describe("ModalWidgetDialog", () => { + it("informs the widget of theme changes", () => { + stubClient(); + let theme = "light"; + const settingsSpy = jest + .spyOn(SettingsStore, "getValue") + .mockImplementation((name) => (name === "theme" ? theme : null)); + try { + render( + + {}} + /> + , + ); + // Indicate that the widget is loaded and ready + fireEvent.load(document.getElementsByTagName("iframe").item(0)!); + const messaging = mocked(last(mocked(ClientWidgetApi).mock.instances)!); + findLast(messaging.once.mock.calls, ([eventName]) => eventName === "ready")![1](); + + // Now change the theme + theme = "dark"; + defaultDispatcher.dispatch({ action: Action.RecheckTheme }, true); + expect(messaging.updateTheme).toHaveBeenLastCalledWith({ name: "dark" }); + } finally { + settingsSpy.mockRestore(); + } + }); +}); diff --git a/test/test-utils/test-utils.ts b/test/test-utils/test-utils.ts index 18da07db92..f752daf530 100644 --- a/test/test-utils/test-utils.ts +++ b/test/test-utils/test-utils.ts @@ -217,6 +217,7 @@ export function createTestClient(): MatrixClient { registerWithIdentityServer: jest.fn().mockResolvedValue({}), getIdentityAccount: jest.fn().mockResolvedValue({}), getTerms: jest.fn().mockResolvedValue({ policies: [] }), + agreeToTerms: jest.fn(), doesServerSupportUnstableFeature: jest.fn().mockResolvedValue(undefined), isVersionSupported: jest.fn().mockResolvedValue(undefined), getPushRules: jest.fn().mockResolvedValue(undefined), diff --git a/test/unit-tests/MatrixClientPeg-test.ts b/test/unit-tests/MatrixClientPeg-test.ts index c46edad55c..51e9406f36 100644 --- a/test/unit-tests/MatrixClientPeg-test.ts +++ b/test/unit-tests/MatrixClientPeg-test.ts @@ -86,6 +86,27 @@ describe("MatrixClientPeg", () => { expect(mockInitRustCrypto).toHaveBeenCalledWith({ storageKey: cryptoStoreKey }); }); + it("should try to start dehydration if dehydration is enabled", async () => { + const mockInitRustCrypto = jest.spyOn(testPeg.safeGet(), "initRustCrypto").mockResolvedValue(undefined); + const mockStartDehydration = jest.fn(); + jest.spyOn(testPeg.safeGet(), "getCrypto").mockReturnValue({ + isDehydrationSupported: jest.fn().mockResolvedValue(true), + startDehydration: mockStartDehydration, + setDeviceIsolationMode: jest.fn(), + } as any); + jest.spyOn(testPeg.safeGet(), "waitForClientWellKnown").mockResolvedValue({ + "m.homeserver": { + base_url: "http://example.com", + }, + "org.matrix.msc3814": true, + } as any); + + const cryptoStoreKey = new Uint8Array([1, 2, 3, 4]); + await testPeg.start({ rustCryptoStoreKey: cryptoStoreKey }); + expect(mockInitRustCrypto).toHaveBeenCalledWith({ storageKey: cryptoStoreKey }); + expect(mockStartDehydration).toHaveBeenCalledWith({ onlyIfKeyCached: true, rehydrate: false }); + }); + it("Should migrate existing login", async () => { const mockInitRustCrypto = jest.spyOn(testPeg.safeGet(), "initRustCrypto").mockResolvedValue(undefined); diff --git a/test/unit-tests/ScalarAuthClient-test.ts b/test/unit-tests/ScalarAuthClient-test.ts index bbf29a8652..4221fca0ed 100644 --- a/test/unit-tests/ScalarAuthClient-test.ts +++ b/test/unit-tests/ScalarAuthClient-test.ts @@ -97,7 +97,7 @@ describe("ScalarAuthClient", function () { body: { errcode: "M_TERMS_NOT_SIGNED" }, }); sac.exchangeForScalarToken = jest.fn(() => Promise.resolve("testtoken1")); - mocked(client.getTerms).mockResolvedValue({ policies: [] }); + mocked(client.getTerms).mockResolvedValue({ policies: {} }); await expect(sac.registerForToken()).resolves.toBe("testtoken1"); }); diff --git a/test/unit-tests/SecurityManager-test.ts b/test/unit-tests/SecurityManager-test.ts index 4575223a50..f81e08ada2 100644 --- a/test/unit-tests/SecurityManager-test.ts +++ b/test/unit-tests/SecurityManager-test.ts @@ -7,11 +7,18 @@ Please see LICENSE files in the repository root for full details. */ import { mocked } from "jest-mock"; -import { CryptoApi } from "matrix-js-sdk/src/crypto-api"; +import { act } from "react"; +import { Crypto } from "@peculiar/webcrypto"; +import { CryptoApi, deriveRecoveryKeyFromPassphrase } from "matrix-js-sdk/src/crypto-api"; +import { SecretStorage } from "matrix-js-sdk/src/matrix"; -import { accessSecretStorage } from "../../src/SecurityManager"; +import { accessSecretStorage, crossSigningCallbacks } from "../../src/SecurityManager"; import { filterConsole, stubClient } from "../test-utils"; import Modal from "../../src/Modal.tsx"; +import { + default as AccessSecretStorageDialog, + KeyParams, +} from "../../src/components/views/dialogs/security/AccessSecretStorageDialog.tsx"; jest.mock("react", () => { const React = jest.requireActual("react"); @@ -19,6 +26,10 @@ jest.mock("react", () => { return React; }); +afterEach(() => { + jest.restoreAllMocks(); +}); + describe("SecurityManager", () => { describe("accessSecretStorage", () => { filterConsole("Not setting dehydration key: no SSSS key found"); @@ -74,4 +85,81 @@ describe("SecurityManager", () => { await expect(spy.mock.lastCall![0]).resolves.toEqual(expect.objectContaining({ __test: true })); }); }); + + describe("getSecretStorageKey", () => { + const { getSecretStorageKey } = crossSigningCallbacks; + + /** Polyfill crypto.subtle, which is unavailable in jsdom */ + function polyFillSubtleCrypto() { + Object.defineProperty(globalThis.crypto, "subtle", { value: new Crypto().subtle }); + } + + it("should prompt the user if the key is uncached", async () => { + polyFillSubtleCrypto(); + + const client = stubClient(); + mocked(client.secretStorage.getDefaultKeyId).mockResolvedValue("my_default_key"); + + const passphrase = "s3cret"; + const { recoveryKey, keyInfo } = await deriveKeyFromPassphrase(passphrase); + + jest.spyOn(Modal, "createDialog").mockImplementation((component) => { + expect(component).toBe(AccessSecretStorageDialog); + + const modalFunc = async () => [{ passphrase }] as [KeyParams]; + return { + finished: modalFunc(), + close: () => {}, + }; + }); + + const [keyId, key] = (await act(() => + getSecretStorageKey!({ keys: { my_default_key: keyInfo } }, "my_secret"), + ))!; + expect(keyId).toEqual("my_default_key"); + expect(key).toEqual(recoveryKey); + }); + + it("should not prompt the user if the requested key is not the default", async () => { + const client = stubClient(); + mocked(client.secretStorage.getDefaultKeyId).mockResolvedValue("my_default_key"); + const createDialogSpy = jest.spyOn(Modal, "createDialog"); + + await expect( + act(() => + getSecretStorageKey!( + { keys: { other_key: {} as SecretStorage.SecretStorageKeyDescription } }, + "my_secret", + ), + ), + ).rejects.toThrow("Request for non-default 4S key"); + expect(createDialogSpy).not.toHaveBeenCalled(); + }); + }); }); + +/** Derive a key from a passphrase, also returning the KeyInfo */ +async function deriveKeyFromPassphrase( + passphrase: string, +): Promise<{ recoveryKey: Uint8Array; keyInfo: SecretStorage.SecretStorageKeyDescription }> { + const salt = "SALTYGOODNESS"; + const iterations = 1000; + + const recoveryKey = await deriveRecoveryKeyFromPassphrase(passphrase, salt, iterations); + + const check = await SecretStorage.calculateKeyCheck(recoveryKey); + return { + recoveryKey, + keyInfo: { + iv: check.iv, + mac: check.mac, + algorithm: SecretStorage.SECRET_STORAGE_ALGORITHM_V1_AES, + name: "", + passphrase: { + algorithm: "m.pbkdf2", + iterations, + salt, + }, + }, + }; +} diff --git a/test/unit-tests/Terms-test.tsx b/test/unit-tests/Terms-test.tsx index 9fc29bde9a..042e0c7826 100644 --- a/test/unit-tests/Terms-test.tsx +++ b/test/unit-tests/Terms-test.tsx @@ -6,9 +6,10 @@ 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 { MatrixEvent, EventType, SERVICE_TYPES } from "matrix-js-sdk/src/matrix"; +import { EventType, MatrixEvent, Policy, SERVICE_TYPES, Terms } from "matrix-js-sdk/src/matrix"; +import { screen, within } from "jest-matrix-react"; -import { startTermsFlow, Service } from "../../src/Terms"; +import { dialogTermsInteractionCallback, Service, startTermsFlow } from "../../src/Terms"; import { getMockClientWithEventEmitter } from "../test-utils"; import { MatrixClientPeg } from "../../src/MatrixClientPeg"; @@ -18,7 +19,7 @@ const POLICY_ONE = { name: "The first policy", url: "http://example.com/one", }, -}; +} satisfies Policy; const POLICY_TWO = { version: "IX", @@ -26,7 +27,7 @@ const POLICY_TWO = { name: "The second policy", url: "http://example.com/two", }, -}; +} satisfies Policy; const IM_SERVICE_ONE = new Service(SERVICE_TYPES.IM, "https://imone.test", "a token token"); const IM_SERVICE_TWO = new Service(SERVICE_TYPES.IM, "https://imtwo.test", "a token token"); @@ -42,7 +43,7 @@ describe("Terms", function () { beforeEach(function () { jest.clearAllMocks(); mockClient.getAccountData.mockReturnValue(undefined); - mockClient.getTerms.mockResolvedValue(null); + mockClient.getTerms.mockResolvedValue({ policies: {} }); mockClient.setAccountData.mockResolvedValue({}); }); @@ -141,22 +142,25 @@ describe("Terms", function () { }); mockClient.getAccountData.mockReturnValue(directEvent); - mockClient.getTerms.mockImplementation(async (_serviceTypes: SERVICE_TYPES, baseUrl: string) => { - switch (baseUrl) { - case "https://imone.test": - return { - policies: { - policy_the_first: POLICY_ONE, - }, - }; - case "https://imtwo.test": - return { - policies: { - policy_the_second: POLICY_TWO, - }, - }; - } - }); + mockClient.getTerms.mockImplementation( + async (_serviceTypes: SERVICE_TYPES, baseUrl: string): Promise => { + switch (baseUrl) { + case "https://imone.test": + return { + policies: { + policy_the_first: POLICY_ONE, + }, + }; + case "https://imtwo.test": + return { + policies: { + policy_the_second: POLICY_TWO, + }, + }; + } + return { policies: {} }; + }, + ); const interactionCallback = jest.fn().mockResolvedValue(["http://example.com/one", "http://example.com/two"]); await startTermsFlow(mockClient, [IM_SERVICE_ONE, IM_SERVICE_TWO], interactionCallback); @@ -180,3 +184,29 @@ describe("Terms", function () { ]); }); }); + +describe("dialogTermsInteractionCallback", () => { + it("should render a dialog with the expected terms", async () => { + dialogTermsInteractionCallback( + [ + { + service: new Service(SERVICE_TYPES.IS, "http://base_url", "access_token"), + policies: { + sample: { + version: "VERSION", + en: { + name: "Terms", + url: "http://base_url/terms", + }, + }, + }, + }, + ], + [], + ); + + const dialog = await screen.findByRole("dialog"); + expect(within(dialog).getByRole("link")).toHaveAttribute("href", "http://base_url/terms"); + expect(dialog).toMatchSnapshot(); + }); +}); diff --git a/test/unit-tests/TextForEvent-test.ts b/test/unit-tests/TextForEvent-test.ts index 4dfccbb93e..17437de894 100644 --- a/test/unit-tests/TextForEvent-test.ts +++ b/test/unit-tests/TextForEvent-test.ts @@ -12,6 +12,7 @@ import { JoinRule, MatrixClient, MatrixEvent, + MRoomTopicEventContent, Room, RoomMember, } from "matrix-js-sdk/src/matrix"; @@ -613,4 +614,47 @@ describe("TextForEvent", () => { }, ); }); + + describe("textForTopicEvent()", () => { + type TestCase = [string, MRoomTopicEventContent, { result: string }]; + const testCases: TestCase[] = [ + ["the legacy key", { topic: "My topic" }, { result: '@a changed the topic to "My topic".' }], + [ + "the legacy key with an empty m.topic key", + { "topic": "My topic", "m.topic": [] }, + { result: '@a changed the topic to "My topic".' }, + ], + [ + "the m.topic key", + { "topic": "Ignore this", "m.topic": [{ mimetype: "text/plain", body: "My topic" }] }, + { result: '@a changed the topic to "My topic".' }, + ], + [ + "the m.topic key and the legacy key undefined", + { "topic": undefined, "m.topic": [{ mimetype: "text/plain", body: "My topic" }] }, + { result: '@a changed the topic to "My topic".' }, + ], + ["the legacy key undefined", { topic: undefined }, { result: "@a removed the topic." }], + ["the legacy key empty string", { topic: "" }, { result: "@a removed the topic." }], + [ + "both the legacy and new keys removed", + { "topic": undefined, "m.topic": [] }, + { result: "@a removed the topic." }, + ], + ]; + + it.each(testCases)("returns correct message for topic event with %s", (_caseName, content, { result }) => { + expect( + textForEvent( + new MatrixEvent({ + type: "m.room.topic", + sender: "@a", + content: content, + state_key: "", + }), + mockClient, + ), + ).toEqual(result); + }); + }); }); diff --git a/test/unit-tests/__snapshots__/Terms-test.tsx.snap b/test/unit-tests/__snapshots__/Terms-test.tsx.snap new file mode 100644 index 0000000000..a35963cd51 --- /dev/null +++ b/test/unit-tests/__snapshots__/Terms-test.tsx.snap @@ -0,0 +1,113 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`dialogTermsInteractionCallback should render a dialog with the expected terms 1`] = ` +
+
+

+ Terms of Service +

+
+
+

+ To continue you need to accept the terms of this service. +

+ + + + + + + + + + + + + + + +
+ Service + + Summary + + Document + + Accept +
+
+ Identity server +
+ ( + base_url + ) +
+
+
+ Find others by phone or email +
+ Be found by phone or email +
+
+ + Terms + + + + +
+
+
+ + + + +
+
+`; diff --git a/test/unit-tests/components/structures/MatrixChat-test.tsx b/test/unit-tests/components/structures/MatrixChat-test.tsx index a29834d51f..9891c07b4b 100644 --- a/test/unit-tests/components/structures/MatrixChat-test.tsx +++ b/test/unit-tests/components/structures/MatrixChat-test.tsx @@ -125,10 +125,10 @@ describe("", () => { }), getVisibleRooms: jest.fn().mockReturnValue([]), getRooms: jest.fn().mockReturnValue([]), - setGlobalErrorOnUnknownDevices: jest.fn(), getCrypto: jest.fn().mockReturnValue({ getVerificationRequestsToDeviceInProgress: jest.fn().mockReturnValue([]), isCrossSigningReady: jest.fn().mockReturnValue(false), + isDehydrationSupported: jest.fn().mockReturnValue(false), getUserDeviceInfo: jest.fn().mockReturnValue(new Map()), getUserVerificationStatus: jest.fn().mockResolvedValue(new UserVerificationStatus(false, false, false)), getVersion: jest.fn().mockReturnValue("1"), @@ -1006,6 +1006,7 @@ describe("", () => { resetKeyBackup: jest.fn(), isEncryptionEnabledInRoom: jest.fn().mockResolvedValue(false), checkKeyBackupAndEnable: jest.fn().mockResolvedValue(null), + isDehydrationSupported: jest.fn().mockReturnValue(false), }; loginClient.getCrypto.mockReturnValue(mockCrypto as any); }); diff --git a/test/unit-tests/components/structures/RoomView-test.tsx b/test/unit-tests/components/structures/RoomView-test.tsx index eabfe0c85c..67bcacf407 100644 --- a/test/unit-tests/components/structures/RoomView-test.tsx +++ b/test/unit-tests/components/structures/RoomView-test.tsx @@ -424,7 +424,7 @@ describe("RoomView", () => { jest.spyOn(cli, "getCrypto").mockReturnValue(crypto); jest.spyOn(cli.getCrypto()!, "isEncryptionEnabledInRoom").mockResolvedValue(true); jest.spyOn(cli.getCrypto()!, "getUserVerificationStatus").mockResolvedValue( - new UserVerificationStatus(false, true, false), + new UserVerificationStatus(false, false, false), ); localRoom.encrypted = true; localRoom.currentState.setStateEvents([ diff --git a/test/unit-tests/components/structures/__snapshots__/RoomView-test.tsx.snap b/test/unit-tests/components/structures/__snapshots__/RoomView-test.tsx.snap index 65a755058b..eb775bdcbe 100644 --- a/test/unit-tests/components/structures/__snapshots__/RoomView-test.tsx.snap +++ b/test/unit-tests/components/structures/__snapshots__/RoomView-test.tsx.snap @@ -110,6 +110,7 @@ exports[`RoomView for a local room in state CREATING should match the snapshot 1 style="--cpd-icon-button-size: 100%;" > ", () => { expect(submitAuthDict).toHaveBeenCalledWith({}); }); }); + +describe("", () => { + const renderAuth = (policy: Policy, props = {}) => { + const matrixClient = createTestClient(); + + return render( + , + ); + }; + + test("should render", () => { + const { container } = renderAuth({ + version: "alpha", + en: { + name: "Test Policy", + url: "https://example.com/en", + }, + }); + expect(container).toMatchSnapshot(); + }); +}); diff --git a/test/unit-tests/components/views/auth/__snapshots__/InteractiveAuthEntryComponents-test.tsx.snap b/test/unit-tests/components/views/auth/__snapshots__/InteractiveAuthEntryComponents-test.tsx.snap index 78c95a945a..99364af5d4 100644 --- a/test/unit-tests/components/views/auth/__snapshots__/InteractiveAuthEntryComponents-test.tsx.snap +++ b/test/unit-tests/components/views/auth/__snapshots__/InteractiveAuthEntryComponents-test.tsx.snap @@ -82,3 +82,38 @@ exports[` should render 1`] = `
`; + +exports[` should render 1`] = ` +
+
+

+ Please review and accept the policies of this homeserver: +

+ +
+ Accept +
+
+
+`; diff --git a/test/unit-tests/components/views/dialogs/UserSettingsDialog-test.tsx b/test/unit-tests/components/views/dialogs/UserSettingsDialog-test.tsx index c6c3eeb142..9e7a71b74b 100644 --- a/test/unit-tests/components/views/dialogs/UserSettingsDialog-test.tsx +++ b/test/unit-tests/components/views/dialogs/UserSettingsDialog-test.tsx @@ -57,9 +57,9 @@ describe("", () => { let sdkContext: SdkContextClass; const defaultProps = { onFinished: jest.fn() }; - const getComponent = (props: Partial = {}): ReactElement => ( - - ); + const getComponent = ( + props: Partial }> = {}, + ): ReactElement => ; beforeEach(() => { jest.clearAllMocks(); diff --git a/test/unit-tests/components/views/right_panel/__snapshots__/UserInfo-test.tsx.snap b/test/unit-tests/components/views/right_panel/__snapshots__/UserInfo-test.tsx.snap index 1744081389..5ba2d3b5fc 100644 --- a/test/unit-tests/components/views/right_panel/__snapshots__/UserInfo-test.tsx.snap +++ b/test/unit-tests/components/views/right_panel/__snapshots__/UserInfo-test.tsx.snap @@ -142,7 +142,7 @@ exports[` with crypto enabled renders 1`] = `
with crypto enabled renders 1`] = ` dir="auto" >
@user:example.com @@ -456,7 +456,7 @@ exports[` with crypto enabled should render a deactivate button for
with crypto enabled should render a deactivate button for dir="auto" >
@user:example.com diff --git a/test/unit-tests/components/views/rooms/RoomHeader-test.tsx b/test/unit-tests/components/views/rooms/RoomHeader/RoomHeader-test.tsx similarity index 95% rename from test/unit-tests/components/views/rooms/RoomHeader-test.tsx rename to test/unit-tests/components/views/rooms/RoomHeader/RoomHeader-test.tsx index 633100eec4..6385356b96 100644 --- a/test/unit-tests/components/views/rooms/RoomHeader-test.tsx +++ b/test/unit-tests/components/views/rooms/RoomHeader/RoomHeader-test.tsx @@ -36,28 +36,33 @@ import { import { ViewRoomOpts } from "@matrix-org/react-sdk-module-api/lib/lifecycles/RoomViewLifecycle"; import { mocked } from "jest-mock"; -import { filterConsole, stubClient } from "../../../../test-utils"; -import RoomHeader from "../../../../../src/components/views/rooms/RoomHeader"; -import DMRoomMap from "../../../../../src/utils/DMRoomMap"; -import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg"; -import RightPanelStore from "../../../../../src/stores/right-panel/RightPanelStore"; -import { RightPanelPhases } from "../../../../../src/stores/right-panel/RightPanelStorePhases"; -import LegacyCallHandler from "../../../../../src/LegacyCallHandler"; -import SettingsStore from "../../../../../src/settings/SettingsStore"; -import SdkConfig from "../../../../../src/SdkConfig"; -import dispatcher from "../../../../../src/dispatcher/dispatcher"; -import { CallStore } from "../../../../../src/stores/CallStore"; -import { Call, ElementCall } from "../../../../../src/models/Call"; -import * as ShieldUtils from "../../../../../src/utils/ShieldUtils"; -import { Container, WidgetLayoutStore } from "../../../../../src/stores/widgets/WidgetLayoutStore"; -import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext"; -import { _t } from "../../../../../src/languageHandler"; -import * as UseCall from "../../../../../src/hooks/useCall"; -import { SdkContextClass } from "../../../../../src/contexts/SDKContext"; -import WidgetStore, { IApp } from "../../../../../src/stores/WidgetStore"; -import { UIFeature } from "../../../../../src/settings/UIFeature"; +import { filterConsole, stubClient } from "../../../../../test-utils"; +import RoomHeader from "../../../../../../src/components/views/rooms/RoomHeader/RoomHeader"; +import DMRoomMap from "../../../../../../src/utils/DMRoomMap"; +import { MatrixClientPeg } from "../../../../../../src/MatrixClientPeg"; +import RightPanelStore from "../../../../../../src/stores/right-panel/RightPanelStore"; +import { RightPanelPhases } from "../../../../../../src/stores/right-panel/RightPanelStorePhases"; +import LegacyCallHandler from "../../../../../../src/LegacyCallHandler"; +import SettingsStore from "../../../../../../src/settings/SettingsStore"; +import SdkConfig from "../../../../../../src/SdkConfig"; +import dispatcher from "../../../../../../src/dispatcher/dispatcher"; +import { CallStore } from "../../../../../../src/stores/CallStore"; +import { Call, ElementCall } from "../../../../../../src/models/Call"; +import * as ShieldUtils from "../../../../../../src/utils/ShieldUtils"; +import { Container, WidgetLayoutStore } from "../../../../../../src/stores/widgets/WidgetLayoutStore"; +import MatrixClientContext from "../../../../../../src/contexts/MatrixClientContext"; +import { _t } from "../../../../../../src/languageHandler"; +import * as UseCall from "../../../../../../src/hooks/useCall"; +import { SdkContextClass } from "../../../../../../src/contexts/SDKContext"; +import WidgetStore, { IApp } from "../../../../../../src/stores/WidgetStore"; +import { UIFeature } from "../../../../../../src/settings/UIFeature"; -jest.mock("../../../../../src/utils/ShieldUtils"); +jest.mock("../../../../../../src/utils/ShieldUtils"); +jest.mock("../../../../../../src/hooks/right-panel/useCurrentPhase", () => ({ + useCurrentPhase: () => { + return { currentPhase: "foo", isOpen: false }; + }, +})); function getWrapper(): RenderOptions { return { diff --git a/test/unit-tests/components/views/rooms/__snapshots__/RoomHeader-test.tsx.snap b/test/unit-tests/components/views/rooms/RoomHeader/__snapshots__/RoomHeader-test.tsx.snap similarity index 99% rename from test/unit-tests/components/views/rooms/__snapshots__/RoomHeader-test.tsx.snap rename to test/unit-tests/components/views/rooms/RoomHeader/__snapshots__/RoomHeader-test.tsx.snap index 3db3fb67fb..6d0c2dc3e4 100644 --- a/test/unit-tests/components/views/rooms/__snapshots__/RoomHeader-test.tsx.snap +++ b/test/unit-tests/components/views/rooms/RoomHeader/__snapshots__/RoomHeader-test.tsx.snap @@ -105,6 +105,7 @@ exports[`RoomHeader dm does not show the face pile for DMs 1`] = ` style="--cpd-icon-button-size: 100%;" > renders button with an unread marker when room style="--cpd-icon-button-size: 100%;" > { + if (Array.isArray(user)) { + return encryptToInvited || user[1] === "joined"; + } else { + return true; + } + }) + .map((id) => { + if (Array.isArray(id)) { + return mockRoomMember(id[0]); + } else { + return mockRoomMember(id); + } + }); + + jest.spyOn(room, "getEncryptionTargetMembers").mockResolvedValue(members); + + jest.spyOn(room, "getMember").mockImplementation((userId) => { + return members.find((member) => member.userId === userId) ?? null; + }); +} + +function emitMembershipChange(client: MatrixClient, userId: string, membership: "join" | "leave" | "invite"): void { + const sender = membership === "invite" ? "@carol:example.org" : userId; + client.emit( + RoomStateEvent.Events, + new MatrixEvent({ + event_id: "$event_id", + type: EventType.RoomMember, + state_key: userId, + content: { + membership: membership, + }, + room_id: ROOM_ID, + sender: sender, + }), + dummyRoomState(), + null, + ); +} + function mockRoomMember(userId: string, name?: string): RoomMember { return { userId, @@ -97,7 +141,7 @@ describe("UserIdentityWarning", () => { jest.spyOn(crypto, "getUserVerificationStatus").mockResolvedValue( new UserVerificationStatus(false, false, false, true), ); - crypto.pinCurrentUserIdentity = jest.fn(); + crypto.pinCurrentUserIdentity = jest.fn().mockResolvedValue(undefined); renderComponent(client, room); await waitFor(() => @@ -109,6 +153,49 @@ describe("UserIdentityWarning", () => { await waitFor(() => expect(crypto.pinCurrentUserIdentity).toHaveBeenCalledWith("@alice:example.org")); }); + // This tests the basic functionality of the component. If we have a room + // member whose identity is in verification violation, we should display a warning. When + // the "Withdraw verification" button gets pressed, it should call `withdrawVerification`. + it("displays a warning when a user's identity is in verification violation", async () => { + jest.spyOn(room, "getEncryptionTargetMembers").mockResolvedValue([ + mockRoomMember("@alice:example.org", "Alice"), + ]); + const crypto = client.getCrypto()!; + jest.spyOn(crypto, "getUserVerificationStatus").mockResolvedValue( + new UserVerificationStatus(false, true, false, true), + ); + crypto.withdrawVerificationRequirement = jest.fn().mockResolvedValue(undefined); + renderComponent(client, room); + + await waitFor(() => + expect(getWarningByText("Alice's (@alice:example.org) verified identity has changed.")).toBeInTheDocument(), + ); + + expect( + screen.getByRole("button", { + name: "Withdraw verification", + }), + ).toBeInTheDocument(); + await userEvent.click(screen.getByRole("button")!); + await waitFor(() => expect(crypto.withdrawVerificationRequirement).toHaveBeenCalledWith("@alice:example.org")); + }); + + it("Should not display a warning if the user was verified and is still verified", async () => { + jest.spyOn(room, "getEncryptionTargetMembers").mockResolvedValue([ + mockRoomMember("@alice:example.org", "Alice"), + ]); + const crypto = client.getCrypto()!; + jest.spyOn(crypto, "getUserVerificationStatus").mockResolvedValue( + new UserVerificationStatus(true, true, false, false), + ); + + renderComponent(client, room); + await sleep(10); // give it some time to finish initialising + + expect(() => getWarningByText("Alice's (@alice:example.org) identity appears to have changed.")).toThrow(); + expect(() => getWarningByText("Alice's (@alice:example.org) verified identity has changed.")).toThrow(); + }); + // We don't display warnings in non-encrypted rooms, but if encryption is // enabled, then we should display a warning if there are any users whose // identity need accepting. @@ -124,6 +211,7 @@ describe("UserIdentityWarning", () => { ); renderComponent(client, room); + await sleep(10); // give it some time to finish initialising expect(() => getWarningByText("Alice's (@alice:example.org) identity appears to have changed.")).toThrow(); @@ -152,6 +240,57 @@ describe("UserIdentityWarning", () => { ); }); + describe("Warnings are displayed in consistent order", () => { + it("Ensure lexicographic order for prompt", async () => { + // members are not returned lexicographic order + mockMembershipForRoom(room, ["@b:example.org", "@a:example.org"]); + + const crypto = client.getCrypto()!; + + // All identities needs approval + jest.spyOn(crypto, "getUserVerificationStatus").mockResolvedValue( + new UserVerificationStatus(false, false, false, true), + ); + + crypto.pinCurrentUserIdentity = jest.fn(); + renderComponent(client, room); + + await waitFor(() => + expect(getWarningByText("@a:example.org's identity appears to have changed.")).toBeInTheDocument(), + ); + }); + + it("Ensure existing prompt stays even if a new violation with lower lexicographic order detected", async () => { + mockMembershipForRoom(room, ["@b:example.org"]); + + const crypto = client.getCrypto()!; + + // All identities needs approval + jest.spyOn(crypto, "getUserVerificationStatus").mockResolvedValue( + new UserVerificationStatus(false, false, false, true), + ); + + crypto.pinCurrentUserIdentity = jest.fn(); + renderComponent(client, room); + + await waitFor(() => + expect(getWarningByText("@b:example.org's identity appears to have changed.")).toBeInTheDocument(), + ); + + // Simulate a new member joined with lower lexico order and also in violation + mockMembershipForRoom(room, ["@a:example.org", "@b:example.org"]); + + act(() => { + emitMembershipChange(client, "@a:example.org", "join"); + }); + + // We should still display the warning for @b:example.org + await waitFor(() => + expect(getWarningByText("@b:example.org's identity appears to have changed.")).toBeInTheDocument(), + ); + }); + }); + // When a user's identity needs approval, or has been approved, the display // should update appropriately. it("updates the display when identity changes", async () => { @@ -163,18 +302,20 @@ describe("UserIdentityWarning", () => { jest.spyOn(crypto, "getUserVerificationStatus").mockResolvedValue( new UserVerificationStatus(false, false, false, false), ); - renderComponent(client, room); - await sleep(10); // give it some time to finish initialising + await act(async () => { + renderComponent(client, room); + await sleep(50); + }); + expect(() => getWarningByText("Alice's (@alice:example.org) identity appears to have changed.")).toThrow(); // The user changes their identity, so we should show the warning. act(() => { - client.emit( - CryptoEvent.UserTrustStatusChanged, - "@alice:example.org", - new UserVerificationStatus(false, false, false, true), - ); + const newStatus = new UserVerificationStatus(false, false, false, true); + jest.spyOn(crypto, "getUserVerificationStatus").mockResolvedValue(newStatus); + client.emit(CryptoEvent.UserTrustStatusChanged, "@alice:example.org", newStatus); }); + await waitFor(() => expect( getWarningByText("Alice's (@alice:example.org) identity appears to have changed."), @@ -184,11 +325,9 @@ describe("UserIdentityWarning", () => { // Simulate the user's new identity having been approved, so we no // longer show the warning. act(() => { - client.emit( - CryptoEvent.UserTrustStatusChanged, - "@alice:example.org", - new UserVerificationStatus(false, false, false, false), - ); + const newStatus = new UserVerificationStatus(false, false, false, false); + jest.spyOn(crypto, "getUserVerificationStatus").mockResolvedValue(newStatus); + client.emit(CryptoEvent.UserTrustStatusChanged, "@alice:example.org", newStatus); }); await waitFor(() => expect(() => getWarningByText("Alice's (@alice:example.org) identity appears to have changed.")).toThrow(), @@ -200,8 +339,7 @@ describe("UserIdentityWarning", () => { describe("updates the display when a member joins/leaves", () => { it("when invited users can see encrypted messages", async () => { // Nobody in the room yet - jest.spyOn(room, "getEncryptionTargetMembers").mockResolvedValue([]); - jest.spyOn(room, "getMember").mockImplementation((userId) => mockRoomMember(userId)); + mockMembershipForRoom(room, []); jest.spyOn(room, "shouldEncryptForInvitedMembers").mockReturnValue(true); const crypto = client.getCrypto()!; jest.spyOn(crypto, "getUserVerificationStatus").mockResolvedValue( @@ -211,62 +349,29 @@ describe("UserIdentityWarning", () => { await sleep(10); // give it some time to finish initialising // Alice joins. Her identity needs approval, so we should show a warning. - client.emit( - RoomStateEvent.Events, - new MatrixEvent({ - event_id: "$event_id", - type: EventType.RoomMember, - state_key: "@alice:example.org", - content: { - membership: "join", - }, - room_id: ROOM_ID, - sender: "@alice:example.org", - }), - dummyRoomState(), - null, - ); + act(() => { + mockMembershipForRoom(room, ["@alice:example.org"]); + emitMembershipChange(client, "@alice:example.org", "join"); + }); + await waitFor(() => expect(getWarningByText("@alice:example.org's identity appears to have changed.")).toBeInTheDocument(), ); // Bob is invited. His identity needs approval, so we should show a // warning for him after Alice's warning is resolved by her leaving. - client.emit( - RoomStateEvent.Events, - new MatrixEvent({ - event_id: "$event_id", - type: EventType.RoomMember, - state_key: "@bob:example.org", - content: { - membership: "invite", - }, - room_id: ROOM_ID, - sender: "@carol:example.org", - }), - dummyRoomState(), - null, - ); + act(() => { + mockMembershipForRoom(room, ["@alice:example.org", "@bob:example.org"]); + emitMembershipChange(client, "@bob:example.org", "invite"); + }); // Alice leaves, so we no longer show her warning, but we will show // a warning for Bob. act(() => { - client.emit( - RoomStateEvent.Events, - new MatrixEvent({ - event_id: "$event_id", - type: EventType.RoomMember, - state_key: "@alice:example.org", - content: { - membership: "leave", - }, - room_id: ROOM_ID, - sender: "@alice:example.org", - }), - dummyRoomState(), - null, - ); + mockMembershipForRoom(room, ["@bob:example.org"]); + emitMembershipChange(client, "@alice:example.org", "leave"); }); + await waitFor(() => expect(() => getWarningByText("@alice:example.org's identity appears to have changed.")).toThrow(), ); @@ -277,8 +382,9 @@ describe("UserIdentityWarning", () => { it("when invited users cannot see encrypted messages", async () => { // Nobody in the room yet - jest.spyOn(room, "getEncryptionTargetMembers").mockResolvedValue([]); - jest.spyOn(room, "getMember").mockImplementation((userId) => mockRoomMember(userId)); + mockMembershipForRoom(room, []); + // jest.spyOn(room, "getEncryptionTargetMembers").mockResolvedValue([]); + // jest.spyOn(room, "getMember").mockImplementation((userId) => mockRoomMember(userId)); jest.spyOn(room, "shouldEncryptForInvitedMembers").mockReturnValue(false); const crypto = client.getCrypto()!; jest.spyOn(crypto, "getUserVerificationStatus").mockResolvedValue( @@ -288,21 +394,10 @@ describe("UserIdentityWarning", () => { await sleep(10); // give it some time to finish initialising // Alice joins. Her identity needs approval, so we should show a warning. - client.emit( - RoomStateEvent.Events, - new MatrixEvent({ - event_id: "$event_id", - type: EventType.RoomMember, - state_key: "@alice:example.org", - content: { - membership: "join", - }, - room_id: ROOM_ID, - sender: "@alice:example.org", - }), - dummyRoomState(), - null, - ); + act(() => { + mockMembershipForRoom(room, ["@alice:example.org"]); + emitMembershipChange(client, "@alice:example.org", "join"); + }); await waitFor(() => expect(getWarningByText("@alice:example.org's identity appears to have changed.")).toBeInTheDocument(), ); @@ -310,40 +405,19 @@ describe("UserIdentityWarning", () => { // Bob is invited. His identity needs approval, but we don't encrypt // to him, so we won't show a warning. (When Alice leaves, the // display won't be updated to show a warningfor Bob.) - client.emit( - RoomStateEvent.Events, - new MatrixEvent({ - event_id: "$event_id", - type: EventType.RoomMember, - state_key: "@bob:example.org", - content: { - membership: "invite", - }, - room_id: ROOM_ID, - sender: "@carol:example.org", - }), - dummyRoomState(), - null, - ); + act(() => { + mockMembershipForRoom(room, [ + ["@alice:example.org", "joined"], + ["@bob:example.org", "invited"], + ]); + emitMembershipChange(client, "@bob:example.org", "invite"); + }); // Alice leaves, so we no longer show her warning, and we don't show // a warning for Bob. act(() => { - client.emit( - RoomStateEvent.Events, - new MatrixEvent({ - event_id: "$event_id", - type: EventType.RoomMember, - state_key: "@alice:example.org", - content: { - membership: "leave", - }, - room_id: ROOM_ID, - sender: "@alice:example.org", - }), - dummyRoomState(), - null, - ); + mockMembershipForRoom(room, [["@bob:example.org", "invited"]]); + emitMembershipChange(client, "@alice:example.org", "leave"); }); await waitFor(() => expect(() => getWarningByText("@alice:example.org's identity appears to have changed.")).toThrow(), @@ -354,37 +428,26 @@ describe("UserIdentityWarning", () => { }); it("when member leaves immediately after component is loaded", async () => { + let hasLeft = false; jest.spyOn(room, "getEncryptionTargetMembers").mockImplementation(async () => { + if (hasLeft) return []; setTimeout(() => { - // Alice immediately leaves after we get the room - // membership, so we shouldn't show the warning any more - client.emit( - RoomStateEvent.Events, - new MatrixEvent({ - event_id: "$event_id", - type: EventType.RoomMember, - state_key: "@alice:example.org", - content: { - membership: "leave", - }, - room_id: ROOM_ID, - sender: "@alice:example.org", - }), - dummyRoomState(), - null, - ); + emitMembershipChange(client, "@alice:example.org", "leave"); + hasLeft = true; }); return [mockRoomMember("@alice:example.org")]; }); - jest.spyOn(room, "getMember").mockImplementation((userId) => mockRoomMember(userId)); + jest.spyOn(room, "shouldEncryptForInvitedMembers").mockReturnValue(false); const crypto = client.getCrypto()!; jest.spyOn(crypto, "getUserVerificationStatus").mockResolvedValue( new UserVerificationStatus(false, false, false, true), ); - renderComponent(client, room); - await sleep(10); + await act(async () => { + renderComponent(client, room); + await sleep(10); + }); expect(() => getWarningByText("@alice:example.org's identity appears to have changed.")).toThrow(); }); @@ -461,6 +524,51 @@ describe("UserIdentityWarning", () => { // Simulate Alice's new identity having been approved, so now we warn // about Bob's identity. act(() => { + const newStatus = new UserVerificationStatus(false, false, false, false); + jest.spyOn(crypto, "getUserVerificationStatus").mockImplementation(async (userId) => { + if (userId == "@alice:example.org") { + return newStatus; + } else { + return new UserVerificationStatus(false, false, false, true); + } + }); + client.emit(CryptoEvent.UserTrustStatusChanged, "@alice:example.org", newStatus); + }); + await waitFor(() => + expect(getWarningByText("@bob:example.org's identity appears to have changed.")).toBeInTheDocument(), + ); + }); + + it("displays the next user when the verification requirement is withdrawn", async () => { + jest.spyOn(room, "getEncryptionTargetMembers").mockResolvedValue([ + mockRoomMember("@alice:example.org", "Alice"), + mockRoomMember("@bob:example.org"), + ]); + const crypto = client.getCrypto()!; + jest.spyOn(crypto, "getUserVerificationStatus").mockImplementation(async (userId) => { + if (userId == "@alice:example.org") { + return new UserVerificationStatus(false, true, false, true); + } else { + return new UserVerificationStatus(false, false, false, true); + } + }); + + renderComponent(client, room); + // We should warn about Alice's identity first. + await waitFor(() => + expect(getWarningByText("Alice's (@alice:example.org) verified identity has changed.")).toBeInTheDocument(), + ); + + // Simulate Alice's new identity having been approved, so now we warn + // about Bob's identity. + act(() => { + jest.spyOn(crypto, "getUserVerificationStatus").mockImplementation(async (userId) => { + if (userId == "@alice:example.org") { + return new UserVerificationStatus(false, false, false, false); + } else { + return new UserVerificationStatus(false, false, false, true); + } + }); client.emit( CryptoEvent.UserTrustStatusChanged, "@alice:example.org", @@ -484,51 +592,36 @@ describe("UserIdentityWarning", () => { ]); jest.spyOn(room, "getMember").mockReturnValue(mockRoomMember("@alice:example.org", "Alice")); const crypto = client.getCrypto()!; + + const firstStatusPromise = defer(); + let callNumber = 0; jest.spyOn(crypto, "getUserVerificationStatus").mockImplementation(async () => { - act(() => { - client.emit( - CryptoEvent.UserTrustStatusChanged, - "@alice:example.org", - new UserVerificationStatus(false, false, false, true), - ); - }); - return Promise.resolve(new UserVerificationStatus(false, false, false, false)); + await firstStatusPromise.promise; + callNumber++; + if (callNumber == 1) { + await sleep(40); + return new UserVerificationStatus(false, false, false, false); + } else { + return new UserVerificationStatus(false, false, false, true); + } }); + renderComponent(client, room); await sleep(10); // give it some time to finish initialising + + act(() => { + client.emit( + CryptoEvent.UserTrustStatusChanged, + "@alice:example.org", + new UserVerificationStatus(false, false, false, true), + ); + firstStatusPromise.resolve(undefined); + }); await waitFor(() => expect( getWarningByText("Alice's (@alice:example.org) identity appears to have changed."), ).toBeInTheDocument(), ); }); - - // Second case: check that if the update says that the user identity - // doesn't needs approval, but the fetch says it does, we don't show the - // warning. - it("update says identity doesn't need approval", async () => { - jest.spyOn(room, "getEncryptionTargetMembers").mockResolvedValue([ - mockRoomMember("@alice:example.org", "Alice"), - ]); - jest.spyOn(room, "getMember").mockReturnValue(mockRoomMember("@alice:example.org", "Alice")); - const crypto = client.getCrypto()!; - jest.spyOn(crypto, "getUserVerificationStatus").mockImplementation(async () => { - act(() => { - client.emit( - CryptoEvent.UserTrustStatusChanged, - "@alice:example.org", - new UserVerificationStatus(false, false, false, false), - ); - }); - return Promise.resolve(new UserVerificationStatus(false, false, false, true)); - }); - renderComponent(client, room); - await sleep(10); // give it some time to finish initialising - await waitFor(() => - expect(() => - getWarningByText("Alice's (@alice:example.org) identity appears to have changed."), - ).toThrow(), - ); - }); }); }); diff --git a/test/unit-tests/components/views/rooms/memberlist/MemberListHeaderView-test.tsx b/test/unit-tests/components/views/rooms/memberlist/MemberListHeaderView-test.tsx index 28d4c105a4..4ad9502961 100644 --- a/test/unit-tests/components/views/rooms/memberlist/MemberListHeaderView-test.tsx +++ b/test/unit-tests/components/views/rooms/memberlist/MemberListHeaderView-test.tsx @@ -62,7 +62,7 @@ describe("MemberListHeaderView", () => { }); it("Does not show search box when there's less than 20 members", async () => { - expect(screen.queryByPlaceholderText("Filter room members")).toBeNull(); + expect(screen.queryByPlaceholderText("Search room members")).toBeNull(); }); it("Shows search box when there's more than 20 members", async () => { @@ -80,7 +80,7 @@ describe("MemberListHeaderView", () => { memberListRoom.currentState.members[newMember.userId] = newMember; } await reRender(); - expect(screen.queryByPlaceholderText("Filter room members")).toBeVisible(); + expect(screen.queryByPlaceholderText("Search room members")).toBeVisible(); }); describe("Invite button functionality", () => { diff --git a/test/unit-tests/components/views/rooms/memberlist/__snapshots__/MemberTileView-test.tsx.snap b/test/unit-tests/components/views/rooms/memberlist/__snapshots__/MemberTileView-test.tsx.snap index b9ab5a491e..44b7476cbe 100644 --- a/test/unit-tests/components/views/rooms/memberlist/__snapshots__/MemberTileView-test.tsx.snap +++ b/test/unit-tests/components/views/rooms/memberlist/__snapshots__/MemberTileView-test.tsx.snap @@ -7,7 +7,7 @@ exports[`MemberTileView RoomMemberTileView should display an verified E2EIcon wh aria-label="@userId:matrix.org (power 0)" class="mx_AccessibleButton mx_MemberTileView" role="button" - tabindex="0" + tabindex="-1" >
})), ); +const sampleTerms = { + policies: { + terms: { version: "alpha", en: { name: "No ball games", url: "https://foobar" } }, + }, +} satisfies Terms; + +const invalidTerms = { + policies: { + terms: { version: "invalid" }, + }, +} satisfies Terms; + describe("DiscoverySettings", () => { let client: MatrixClient; @@ -51,20 +63,17 @@ describe("DiscoverySettings", () => { it("displays alert if an identity server needs terms accepting", async () => { mocked(client).getIdentityServerUrl.mockReturnValue("https://example.com"); - mocked(client).getTerms.mockResolvedValue({ - ["policies"]: { en: "No ball games" }, - }); + mocked(client).getTerms.mockResolvedValue(sampleTerms); render(, { wrapper: DiscoveryWrapper }); - await expect(await screen.findByText("Let people find you")).toBeInTheDocument(); + expect(await screen.findByText("Let people find you")).toBeInTheDocument(); + expect(screen.getByRole("link")).toHaveAttribute("href", "https://foobar"); }); it("button to accept terms is disabled if checkbox not checked", async () => { mocked(client).getIdentityServerUrl.mockReturnValue("https://example.com"); - mocked(client).getTerms.mockResolvedValue({ - ["policies"]: { en: "No ball games" }, - }); + mocked(client).getTerms.mockResolvedValue(sampleTerms); render(, { wrapper: DiscoveryWrapper }); @@ -93,4 +102,40 @@ describe("DiscoverySettings", () => { expect(client.getThreePids).toHaveBeenCalled(); }); + + it("should not disable share button if terms accepted", async () => { + mocked(client).getThreePids.mockResolvedValue({ + threepids: [ + { + medium: ThreepidMedium.Email, + address: "test@email.com", + bound: false, + added_at: 123, + validated_at: 234, + }, + ], + }); + mocked(client).getIdentityServerUrl.mockReturnValue("https://example.com"); + mocked(client).getTerms.mockResolvedValue(sampleTerms); + mocked(client).getAccountData.mockReturnValue( + new MatrixEvent({ + content: { accepted: [sampleTerms.policies["terms"]["en"].url] }, + }), + ); + + render(, { wrapper: DiscoveryWrapper }); + + const shareButton = await screen.findByRole("button", { name: "Share" }); + expect(shareButton).not.toHaveAttribute("aria-disabled", "true"); + }); + + it("should not show invalid terms", async () => { + mocked(client).getIdentityServerUrl.mockReturnValue("https://example.com"); + mocked(client).getTerms.mockResolvedValue(invalidTerms); + + render(, { wrapper: DiscoveryWrapper }); + + expect(await screen.findByText("Let people find you")).toBeInTheDocument(); + expect(screen.queryByRole("link")).not.toBeInTheDocument(); + }); }); diff --git a/test/unit-tests/components/views/settings/encryption/RecoveryPanel-test.tsx b/test/unit-tests/components/views/settings/encryption/RecoveryPanel-test.tsx index 6ef79876c7..d13e857954 100644 --- a/test/unit-tests/components/views/settings/encryption/RecoveryPanel-test.tsx +++ b/test/unit-tests/components/views/settings/encryption/RecoveryPanel-test.tsx @@ -10,22 +10,15 @@ import { MatrixClient } from "matrix-js-sdk/src/matrix"; import { render, screen } from "jest-matrix-react"; import { waitFor } from "@testing-library/dom"; import userEvent from "@testing-library/user-event"; -import { mocked } from "jest-mock"; import { createTestClient, withClientContextRenderOptions } from "../../../../../test-utils"; import { RecoveryPanel } from "../../../../../../src/components/views/settings/encryption/RecoveryPanel"; -import { accessSecretStorage } from "../../../../../../src/SecurityManager"; - -jest.mock("../../../../../../src/SecurityManager", () => ({ - accessSecretStorage: jest.fn(), -})); describe("", () => { let matrixClient: MatrixClient; beforeEach(() => { matrixClient = createTestClient(); - mocked(accessSecretStorage).mockClear().mockResolvedValue(); }); function renderRecoverPanel(onChangeRecoveryKeyClick = jest.fn()) { @@ -56,18 +49,6 @@ describe("", () => { expect(onChangeRecoveryKeyClick).toHaveBeenCalledWith(true); }); - it("should ask to enter the recovery key when secrets are not cached", async () => { - jest.spyOn(matrixClient.secretStorage, "getDefaultKeyId").mockResolvedValue("default key"); - const user = userEvent.setup(); - const { asFragment } = renderRecoverPanel(); - - await waitFor(() => screen.getByRole("button", { name: "Enter recovery key" })); - expect(asFragment()).toMatchSnapshot(); - - await user.click(screen.getByRole("button", { name: "Enter recovery key" })); - expect(accessSecretStorage).toHaveBeenCalled(); - }); - it("should allow to change the recovery key when everything is good", async () => { jest.spyOn(matrixClient.secretStorage, "getDefaultKeyId").mockResolvedValue("default key"); jest.spyOn(matrixClient.getCrypto()!, "getCrossSigningStatus").mockResolvedValue({ diff --git a/test/unit-tests/components/views/settings/encryption/ResetIdentityPanel-test.tsx b/test/unit-tests/components/views/settings/encryption/ResetIdentityPanel-test.tsx index dc791a6a35..3caa1cc667 100644 --- a/test/unit-tests/components/views/settings/encryption/ResetIdentityPanel-test.tsx +++ b/test/unit-tests/components/views/settings/encryption/ResetIdentityPanel-test.tsx @@ -25,7 +25,7 @@ describe("", () => { const onFinish = jest.fn(); const { asFragment } = render( - , + , withClientContextRenderOptions(matrixClient), ); expect(asFragment()).toMatchSnapshot(); @@ -34,4 +34,13 @@ describe("", () => { expect(matrixClient.getCrypto()!.resetEncryption).toHaveBeenCalled(); expect(onFinish).toHaveBeenCalled(); }); + + it("should display the 'forgot recovery key' variant correctly", async () => { + const onFinish = jest.fn(); + const { asFragment } = render( + , + withClientContextRenderOptions(matrixClient), + ); + expect(asFragment()).toMatchSnapshot(); + }); }); diff --git a/test/unit-tests/components/views/settings/encryption/__snapshots__/RecoveryPanel-test.tsx.snap b/test/unit-tests/components/views/settings/encryption/__snapshots__/RecoveryPanel-test.tsx.snap index ff43b40677..d4d860d2cb 100644 --- a/test/unit-tests/components/views/settings/encryption/__snapshots__/RecoveryPanel-test.tsx.snap +++ b/test/unit-tests/components/views/settings/encryption/__snapshots__/RecoveryPanel-test.tsx.snap @@ -41,67 +41,6 @@ exports[` should allow to change the recovery key when everythi `; -exports[` should ask to enter the recovery key when secrets are not cached 1`] = ` - -
-
-

- Recovery -

-
- Recover your cryptographic identity and message history with a recovery key if you’ve lost all your existing devices. - - - - - Your key storage is out of sync. Click the button below to fix the problem. - -
-
- -
-
-`; - exports[` should ask to set up a recovery key when there is no recovery key 1`] = `
should display the 'forgot recovery key' variant correctly 1`] = ` + + +
+
+
+ + + +
+

+ Forgot your recovery key? You’ll need to reset your identity. +

+
+
+
    +
  • + + Your account details, contacts, preferences, and chat list will be kept +
  • +
  • + + You will lose any message history that’s stored only on the server +
  • +
  • + + You will need to verify all your existing devices and contacts again +
  • +
+
+ +
+
+`; + exports[` should reset the encryption when the continue button is clicked 1`] = `